Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/__tests__/components/MorphemeBox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ describe('MorphemeBox', () => {
expect(box).toHaveStyle({ gridTemplateColumns: 'repeat(2, minmax(1ch, auto))' });
});

it('takes its box metrics from the shared morphology-slot utility', () => {
const { container } = renderBox();
expect(container.querySelector('[style*="grid-template-columns"]')).toHaveClass(
'tw:morphology-slot',
);
});

it('calls onEditBreakdown when a form cell is clicked', async () => {
const onEditBreakdown = jest.fn();
renderBox({ onEditBreakdown });
Expand Down
86 changes: 85 additions & 1 deletion src/__tests__/components/TokenChip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,36 @@ describe('TokenChip', () => {
).toBeInTheDocument();
});

// jsdom does no layout, so these assert the structure that gives an unanalyzed slot an analyzed
// one's height rather than the height itself.
it('gives the unanalyzed morphology slot the shared box metrics', () => {
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);
const spacer = screen.getByTestId('morphology-slot-spacer');
expect(spacer.parentElement).toHaveClass('tw:morphology-slot');
});

it('reserves a second row in the unanalyzed morphology slot', () => {
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);
expect(screen.getByTestId('morphology-slot-spacer')).toBeInTheDocument();
});

it('hides the reserved second row from assistive tech', () => {
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);
expect(screen.getByTestId('morphology-slot-spacer')).toHaveAttribute('aria-hidden', 'true');
});

it('shows surface text on the define button for unanalyzed tokens', () => {
render(
<AnalysisStoreProvider analysisLanguage="und">
Expand Down Expand Up @@ -825,7 +855,7 @@ describe('TokenChip read-only', () => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});

it('hides the morphology row for an unanalyzed token', () => {
it('hides the morphology editing affordances for an unanalyzed token', () => {
setMockAnalysisReadOnly(true);
render(
<AnalysisStoreProvider analysisLanguage="und">
Expand All @@ -838,4 +868,58 @@ describe('TokenChip read-only', () => {
screen.queryByRole('button', { name: '%interlinearizer_tokenChip_defineMorphemes%' }),
).not.toBeInTheDocument();
});

// jsdom does no layout, so the two slot-structure tests below assert the markup that keeps an
// unanalyzed token's gloss on its analyzed neighbors' line rather than the height itself.
it('keeps a slot of the shared box metrics for an unanalyzed token', () => {
setMockAnalysisReadOnly(true);
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);

expect(screen.getByTestId('readonly-morphology-slot-spacer').parentElement).toHaveClass(
'tw:morphology-slot',
);
});

it('hides the unanalyzed token slot from assistive tech', () => {
setMockAnalysisReadOnly(true);
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);

expect(screen.getByTestId('readonly-morphology-slot-spacer').parentElement).toHaveAttribute(
'aria-hidden',
'true',
);
});

it('omits the unanalyzed token slot when showMorphology is off', () => {
setMockAnalysisReadOnly(true);
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology={false} />
</AnalysisStoreProvider>,
);

expect(screen.queryByTestId('readonly-morphology-slot-spacer')).not.toBeInTheDocument();
});

it('omits the unanalyzed token slot when the token has morphemes', () => {
setMockAnalysisReadOnly(true);
jest
.spyOn(AnalysisStore, 'useMorphemes')
.mockReturnValue([{ id: 'm-1', form: 'hel', writingSystem: 'und' }]);
render(
<AnalysisStoreProvider analysisLanguage="und">
<TokenChip {...requiredProps()} showMorphology />
</AnalysisStoreProvider>,
);

expect(screen.queryByTestId('readonly-morphology-slot-spacer')).not.toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion src/components/MorphemeBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function MorphemeBox({
return (
<PopoverAnchor asChild>
<div
className={`tw:inline-grid tw:w-fit tw:items-center tw:gap-x-0.5 tw:gap-y-0.5 tw:rounded tw:border tw:border-border tw:bg-background tw:p-0.5${popoverOpen ? ' tw:ring-1 tw:ring-ring' : ''}`}
className={`tw:morphology-slot tw:border-border tw:bg-background${popoverOpen ? ' tw:ring-1 tw:ring-ring' : ''}`}
style={{ gridTemplateColumns: `repeat(${morphemes.length}, minmax(1ch, auto))` }}
onMouseEnter={() => setIsFormsHovered(true)}
onMouseLeave={() => setIsFormsHovered(false)}
Expand Down
27 changes: 23 additions & 4 deletions src/components/TokenChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,18 @@ export function TokenChip({
<span className="tw:whitespace-nowrap tw:font-mono tw:text-sm tw:text-foreground tw:cursor-text">
{token.surfaceText}
</span>
{/* Read-only hides the whole morphology row for an unanalyzed token: its only content
would be the define-breakdown affordance, which is an editing control. */}
{/* The define-breakdown affordance is an editing control, so read-only drops it but keeps an
inert slot, holding this token's gloss on its analyzed neighbors' line. */}
{showMorphology && readOnly && !hasMorphemes && (
<span aria-hidden="true" className="tw:morphology-slot tw:border-transparent">
<span className="tw:whitespace-nowrap tw:font-mono tw:text-xs tw:italic tw:text-transparent">
{token.surfaceText}
</span>
<span className="tw:invisible tw:text-xs" data-testid="readonly-morphology-slot-spacer">
&nbsp;
</span>
</span>
)}
{showMorphology && (!readOnly || hasMorphemes) && (
// The morpheme row is the popover anchor; the panel itself is portaled to document.body
// by PopoverContent, so it escapes both the clipping of ancestor scroll viewports (e.g.
Expand Down Expand Up @@ -452,7 +462,7 @@ export function TokenChip({
aria-label={formatReplacementString(labels.defineMorphemes, {
token: token.surfaceText,
})}
className={`tw:flex tw:h-auto tw:flex-row tw:items-center tw:rounded tw:px-0.5 tw:py-0 tw:font-mono tw:text-xs tw:italic tw:text-muted-foreground/50 tw:transition-colors${disabled ? '' : ' tw:cursor-pointer tw:hover:bg-accent'}`}
className={`tw:flex tw:h-auto tw:flex-row tw:items-center tw:rounded tw:p-0 tw:font-mono tw:text-xs tw:italic tw:text-muted-foreground/50 tw:transition-colors${disabled ? '' : ' tw:cursor-pointer tw:hover:bg-accent'}`}
tabIndex={-1}
type="button"
variant="ghost"
Expand All @@ -461,7 +471,16 @@ export function TokenChip({
if (!disabled) openMorphemeEditor();
}}
>
<span className="tw:whitespace-nowrap">{token.surfaceText}</span>
<span className="tw:morphology-slot tw:border-transparent">
<span className="tw:whitespace-nowrap">{token.surfaceText}</span>
<span
aria-hidden="true"
className="tw:invisible tw:rounded tw:border tw:text-xs"
data-testid="morphology-slot-spacer"
>
&nbsp;
</span>
</span>
</Button>
</PopoverAnchor>
)}
Expand Down
8 changes: 8 additions & 0 deletions src/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
@apply tw:inline-flex tw:items-start tw:gap-1;
}

/*
* The single source of truth for a morphology slot's outer metrics, so an analyzed and an
* unanalyzed token share a height and every token gloss lands on one line across the strip.
*/
@utility morphology-slot {
@apply tw:inline-grid tw:w-fit tw:items-center tw:gap-x-0.5 tw:gap-y-0.5 tw:rounded tw:border tw:p-0.5;
}

@utility section-label {
@apply tw:text-xs tw:font-medium tw:text-muted-foreground tw:uppercase tw:tracking-wide;
}
Expand Down
Loading