diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index bc148919..c42cee51 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -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 }); diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index 92d2e15a..f0cc2e8b 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + expect(screen.getByTestId('morphology-slot-spacer')).toBeInTheDocument(); + }); + + it('hides the reserved second row from assistive tech', () => { + render( + + + , + ); + expect(screen.getByTestId('morphology-slot-spacer')).toHaveAttribute('aria-hidden', 'true'); + }); + it('shows surface text on the define button for unanalyzed tokens', () => { render( @@ -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( @@ -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( + + + , + ); + + expect(screen.getByTestId('readonly-morphology-slot-spacer').parentElement).toHaveClass( + 'tw:morphology-slot', + ); + }); + + it('hides the unanalyzed token slot from assistive tech', () => { + setMockAnalysisReadOnly(true); + render( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(screen.queryByTestId('readonly-morphology-slot-spacer')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index de53ddda..6d227be0 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -77,7 +77,7 @@ export function MorphemeBox({ return (
setIsFormsHovered(true)} onMouseLeave={() => setIsFormsHovered(false)} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index a9578ef0..7337e736 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -421,8 +421,18 @@ export function TokenChip({ {token.surfaceText} - {/* 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 && ( + + )} {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. @@ -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" @@ -461,7 +471,16 @@ export function TokenChip({ if (!disabled) openMorphemeEditor(); }} > - {token.surfaceText} + + {token.surfaceText} + + )} diff --git a/src/tailwind.css b/src/tailwind.css index 3e4ba707..891771f7 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -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; }