From 6b7f0c2188ca4b8f4e2faab6e91d305da39e3928 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 29 Jun 2026 13:11:23 -0600 Subject: [PATCH 1/5] Render analyzed-token breakdown as a boxed morpheme grid Extract the morpheme breakdown into a new MorphemeBox component (CSS grid of forms over glosses), simplifying TokenChip and MorphemeEditor accordingly. --- src/__tests__/components/MorphemeBox.test.tsx | 232 ++++++++++++++++++ .../components/MorphemeEditor.test.tsx | 79 +----- src/__tests__/components/TokenChip.test.tsx | 61 +++-- src/components/MorphemeBox.tsx | 182 ++++++++++++++ src/components/MorphemeEditor.tsx | 72 +----- src/components/PhraseBox.tsx | 2 +- src/components/TokenChip.tsx | 67 ++--- src/tailwind.css | 2 +- 8 files changed, 484 insertions(+), 213 deletions(-) create mode 100644 src/__tests__/components/MorphemeBox.test.tsx create mode 100644 src/components/MorphemeBox.tsx diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx new file mode 100644 index 00000000..68ea8ac9 --- /dev/null +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -0,0 +1,232 @@ +/** @file Unit tests for components/MorphemeBox.tsx. */ +/// +/// + +import { useLocalizedStrings } from '@papi/frontend/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { MorphemeAnalysis, Token } from 'interlinearizer'; +import * as AnalysisStore from '../../components/AnalysisStore'; +import { MorphemeBox, MorphemeGlossInput } from '../../components/MorphemeBox'; + +jest.mock('../../components/AnalysisStore'); + +const LOCALIZED = { + '%interlinearizer_tokenChip_editMorphemes%': 'Edit morpheme breakdown for {token}', + '%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}', +}; + +beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); +}); + +const WORD_TOKEN = { + ref: 'GEN 1:1:0', + surfaceText: 'hello', + writingSystem: 'en', + type: 'word', + charStart: 0, + charEnd: 5, +} satisfies Token; + +const MORPHEMES: MorphemeAnalysis[] = [ + { id: 'm-1', form: 'hel', writingSystem: 'en' }, + { id: 'm-2', form: '-lo', writingSystem: 'en' }, +]; + +/** + * Renders {@link MorphemeBox} with required props defaulted so each test overrides only what it + * asserts on. + * + * @param props - Overrides merged over the defaults. + * @returns The render result. + */ +function renderBox(props: Partial[0]> = {}) { + return render( + , + ); +} + +describe('MorphemeBox', () => { + it('renders one form cell per morpheme', () => { + renderBox(); + expect(screen.getByText('hel')).toBeInTheDocument(); + expect(screen.getByText('-lo')).toBeInTheDocument(); + }); + + it('renders one gloss input per morpheme', () => { + renderBox(); + expect(screen.getAllByRole('textbox')).toHaveLength(2); + }); + + it('exposes a single "edit breakdown" control for the whole forms row', () => { + renderBox(); + expect( + screen.getByRole('button', { name: 'Edit morpheme breakdown for hello' }), + ).toBeInTheDocument(); + }); + + it('places each form directly above its gloss in the same grid column', () => { + renderBox(); + const firstForm = screen.getByText('hel'); + const firstGloss = screen.getByRole('textbox', { name: 'Gloss for morpheme hel' }); + // A morpheme and its gloss must share a column; the form sits on row 1, its gloss on row 2. + expect(firstForm).toHaveStyle({ gridColumn: '1', gridRow: '1' }); + expect(firstGloss).toHaveStyle({ gridColumn: '1', gridRow: '2' }); + }); + + it('orders columns left-to-right by morpheme order', () => { + renderBox(); + expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '1' }); + expect(screen.getByText('-lo')).toHaveStyle({ gridColumn: '2' }); + }); + + it('preserves morpheme order under right-to-left document direction', () => { + // RTL is first-class: the grid honors the document `dir` for column flow (column 1 lands on the + // right), but DOM/source order — and thus the form-over-gloss column pairing — is unchanged. + document.documentElement.dir = 'rtl'; + try { + renderBox(); + expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '1' }); + expect(screen.getByText('-lo')).toHaveStyle({ gridColumn: '2' }); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toHaveStyle({ + gridColumn: '1', + }); + } finally { + document.documentElement.dir = ''; + } + }); + + it('sizes the column template to the morpheme count', () => { + const { container } = renderBox(); + const box = container.querySelector('[style*="grid-template-columns"]'); + expect(box).toHaveStyle({ gridTemplateColumns: 'repeat(2, minmax(1ch, auto))' }); + }); + + it('calls onEditBreakdown when a form cell is clicked', async () => { + const onEditBreakdown = jest.fn(); + renderBox({ onEditBreakdown }); + await userEvent.click(screen.getByText('hel')); + expect(onEditBreakdown).toHaveBeenCalledTimes(1); + }); + + it('does not call onEditBreakdown when disabled', async () => { + const onEditBreakdown = jest.fn(); + renderBox({ disabled: true, onEditBreakdown }); + await userEvent.click(screen.getByText('hel')); + expect(onEditBreakdown).not.toHaveBeenCalled(); + }); + + it('disables the gloss inputs when disabled', () => { + renderBox({ disabled: true }); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toBeDisabled(); + }); + + it('tints the forms row on hover and clears it on leave', async () => { + // Hovering any form cell tints the whole forms row (the edit action is breakdown-wide). The + // tint class itself would be brittle to assert; this exercises the hover handlers and the + // state they drive, leaving the box intact through enter/leave. + renderBox(); + const form = screen.getByText('hel'); + await userEvent.hover(form); + expect(form).toBeInTheDocument(); + await userEvent.unhover(form); + expect(form).toBeInTheDocument(); + }); + + it('still renders its cells while the breakdown editor is open (active look)', () => { + // The box takes an accent ring while `popoverOpen` (asserted via class would be brittle); what + // matters behaviorally is that the box stays intact and editable while the editor is open. + renderBox({ popoverOpen: true }); + expect(screen.getByText('hel')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme hel' })).toBeInTheDocument(); + }); +}); + +describe('MorphemeGlossInput', () => { + it('renders an empty input when no gloss exists', () => { + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue(''); + }); + + it('renders the existing gloss value', () => { + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('not'); + }); + + it('does not dispatch when blurring without changes', async () => { + const dispatchMock = jest.fn(); + jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); + + render( + , + ); + await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })); + await userEvent.tab(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('dispatches the gloss on blur when the draft differs', async () => { + const dispatchMock = jest.fn(); + jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); + + render( + , + ); + await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }), 'not'); + await userEvent.tab(); + expect(dispatchMock).toHaveBeenCalledWith('tok-1', 'm-1', 'not'); + }); + + it('does not dispatch when disabled', () => { + const dispatchMock = jest.fn(); + jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); + + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toBeDisabled(); + }); +}); diff --git a/src/__tests__/components/MorphemeEditor.test.tsx b/src/__tests__/components/MorphemeEditor.test.tsx index 2950d58d..0a86353b 100644 --- a/src/__tests__/components/MorphemeEditor.test.tsx +++ b/src/__tests__/components/MorphemeEditor.test.tsx @@ -6,8 +6,7 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ComponentProps } from 'react'; -import * as AnalysisStore from '../../components/AnalysisStore'; -import { MorphemeBreakdownPopover, MorphemeGlossInput } from '../../components/MorphemeEditor'; +import { MorphemeBreakdownPopover } from '../../components/MorphemeEditor'; jest.mock('../../components/AnalysisStore'); @@ -303,79 +302,3 @@ describe('MorphemeBreakdownPopover', () => { expect(screen.getByRole('textbox', { name: 'token gloss' })).toHaveFocus(); }); }); - -describe('MorphemeGlossInput', () => { - it('renders an empty input when no gloss exists', () => { - render( - , - ); - expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue(''); - }); - - it('renders the existing gloss value', () => { - render( - , - ); - expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveValue('not'); - }); - - it('does not dispatch when blurring without changes', async () => { - const dispatchMock = jest.fn(); - jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); - - render( - , - ); - await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })); - await userEvent.tab(); - expect(dispatchMock).not.toHaveBeenCalled(); - }); - - it('dispatches the gloss on blur when the draft differs', async () => { - const dispatchMock = jest.fn(); - jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); - - render( - , - ); - await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }), 'not'); - await userEvent.tab(); - expect(dispatchMock).toHaveBeenCalledWith('tok-1', 'm-1', 'not'); - }); - - it('does not dispatch when disabled', async () => { - const dispatchMock = jest.fn(); - jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); - - render( - , - ); - const input = screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }); - expect(input).toBeDisabled(); - }); -}); diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index 89c431d6..d5a7bc6d 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -51,13 +51,28 @@ jest.mock('../../components/MorphemeEditor', () => ({ ); }, +})); +jest.mock('../../components/MorphemeBox', () => ({ /** - * Stub gloss input that renders a placeholder for test assertions. + * Stub box that surfaces its `onEditBreakdown` callback as a button so analyzed-path tests can + * open the editor, and echoes its `disabled`/`popoverOpen` props for assertions. The box's grid + * internals (forms, gloss inputs, RTL order, hover, active look) are tested in MorphemeBox.test. * - * @returns A test stub element. + * @param props - Receives the same props as the real box. + * @returns A test stub element with an edit-breakdown trigger. */ - MorphemeGlossInput() { - return ; + MorphemeBox({ + onEditBreakdown, + disabled, + popoverOpen, + }: Readonly<{ onEditBreakdown: () => void; disabled: boolean; popoverOpen: boolean }>) { + return ( +
+ +
+ ); }, })); @@ -418,7 +433,7 @@ describe('TokenChip', () => { expect(screen.queryByTestId('morpheme-popover')).not.toBeInTheDocument(); }); - it('renders morpheme forms when morphemes exist', () => { + it('renders the morpheme box instead of the define button when morphemes exist', () => { // AnalysisStore imported at top level jest.spyOn(AnalysisStore, 'useMorphemes').mockReturnValue([ { id: 'm-1', form: 'hel', writingSystem: 'und' }, @@ -429,28 +444,28 @@ describe('TokenChip', () => { , ); + expect(screen.getByTestId('morpheme-box')).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'Edit morpheme breakdown for hello' }), - ).toBeInTheDocument(); - expect(screen.getByText('hel')).toBeInTheDocument(); - expect(screen.getByText('-lo')).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Define morpheme breakdown for hello' }), + ).not.toBeInTheDocument(); }); - it('renders morpheme gloss inputs when morphemes exist', () => { + it('marks the morpheme box active while the popover is open', async () => { // AnalysisStore imported at top level - jest.spyOn(AnalysisStore, 'useMorphemes').mockReturnValue([ - { id: 'm-1', form: 'hel', writingSystem: 'und' }, - { id: 'm-2', form: '-lo', writingSystem: 'und' }, - ]); + jest + .spyOn(AnalysisStore, 'useMorphemes') + .mockReturnValue([{ id: 'm-1', form: 'hel', writingSystem: 'und' }]); render( , ); - expect(screen.getAllByTestId('morpheme-gloss')).toHaveLength(2); + expect(screen.getByTestId('morpheme-box')).toHaveAttribute('data-morpheme-box-open', 'false'); + await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' })); + expect(screen.getByTestId('morpheme-box')).toHaveAttribute('data-morpheme-box-open', 'true'); }); - it('opens the popover when the edit button is clicked on analyzed token', async () => { + it('opens the popover when the box requests breakdown editing on an analyzed token', async () => { // AnalysisStore imported at top level jest .spyOn(AnalysisStore, 'useMorphemes') @@ -460,9 +475,7 @@ describe('TokenChip', () => { , ); - await userEvent.click( - screen.getByRole('button', { name: 'Edit morpheme breakdown for hello' }), - ); + await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' })); expect(screen.getByTestId('morpheme-popover')).toBeInTheDocument(); }); @@ -513,9 +526,7 @@ describe('TokenChip', () => { , ); - await userEvent.click( - screen.getByRole('button', { name: 'Edit morpheme breakdown for hello' }), - ); + await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' })); await userEvent.click(screen.getByRole('button', { name: 'mock-delete' })); expect(mockDispatch).toHaveBeenCalledWith('GEN 1:1:0'); }); @@ -533,7 +544,7 @@ describe('TokenChip', () => { expect(screen.queryByRole('button', { name: 'mock-delete' })).not.toBeInTheDocument(); }); - it('focuses the main gloss input on a surface-text mouse-down when morpheme inputs precede it', () => { + it('focuses the main gloss input on a surface-text mouse-down when the box precedes it', () => { // AnalysisStore imported at top level jest .spyOn(AnalysisStore, 'useMorphemes') @@ -544,8 +555,8 @@ describe('TokenChip', () => { , ); - // The morpheme gloss inputs sit before the main gloss input inside the label; the label - // handler must still route focus to the main gloss input, not the first input it finds. + // The morpheme box (with its gloss inputs) sits before the main gloss input inside the label; + // the label handler must route focus to the main gloss input by id, not the first input found. fireEvent.mouseDown(screen.getByText('hello')); expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveFocus(); diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx new file mode 100644 index 00000000..277c40c2 --- /dev/null +++ b/src/components/MorphemeBox.tsx @@ -0,0 +1,182 @@ +/** + * @file Inline display of an analyzed token's morpheme breakdown, rendered inside {@link TokenChip} + * when the morphology toggle is active and the token has a breakdown. {@link MorphemeBox} boxes + * the breakdown and lays it out as a grid so each morpheme form aligns vertically with its gloss + * field (and, in future, its lexicon link); {@link MorphemeGlossInput} provides a per-morpheme + * gloss field that fills its grid column. The breakdown _editor_ (the popover where forms are + * entered) lives separately in {@link ./MorphemeEditor}. + */ +import type { MorphemeAnalysis, Token } from 'interlinearizer'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { PopoverAnchor } from 'platform-bible-react'; +import { useEffect, useState } from 'react'; +import { useMorphemeGlossDispatch, useReportGlossEditing } from './AnalysisStore'; + +const MORPHEME_GLOSS_STRING_KEYS = [ + '%interlinearizer_morphemeGloss_label%', +] as const satisfies `%${string}%`[]; + +const MORPHEME_BOX_STRING_KEYS = [ + '%interlinearizer_tokenChip_editMorphemes%', +] as const satisfies `%${string}%`[]; + +/** + * Renders an analyzed token's morpheme breakdown as a boxed grid: each grid column is one morpheme, + * with its form on the top row directly above its gloss field on the bottom row, so a morpheme and + * its gloss always share a column (a future lexicon link slots into a third row with the same + * column alignment). The box appears only for tokens that have a breakdown; an unanalyzed token's + * "define breakdown" affordance lives in {@link TokenChip} instead. + * + * The whole forms row is a single accessible "edit breakdown" control rather than one labeled + * button per morpheme: every form cell opens the same whole-breakdown editor, so per-cell labels + * would be redundant for assistive tech. Hovering any form cell tints the whole forms row (the + * action is breakdown-wide, not per-morpheme), tracked with local hover state. While the editor + * popover is open the box takes an accent ring so it reads as the one being edited. + * + * Renders the {@link PopoverAnchor} the editor popover is positioned from; the caller owns the + * `Popover` root and the popover content. + * + * @param props - Component props. + * @param props.token - The analyzed word token whose breakdown is shown; used for the column forms, + * the token ref, and the accessible label. + * @param props.morphemes - The token's ordered morpheme breakdown; one grid column per entry. + * @param props.analysisLanguage - BCP 47 tag for reading/writing each morpheme gloss. + * @param props.disabled - When true, the box is non-interactive and form-cell clicks do not open + * the editor. + * @param props.popoverOpen - When true, the editor popover is open; the box renders its active + * look. + * @param props.onEditBreakdown - Called when a form cell is clicked (while enabled) to open the + * whole-breakdown editor. + * @returns A boxed grid of morpheme forms and their gloss fields, wrapped in a popover anchor. + */ +export function MorphemeBox({ + token, + morphemes, + analysisLanguage, + disabled, + popoverOpen, + onEditBreakdown, +}: Readonly<{ + token: Token & { type: 'word' }; + morphemes: readonly MorphemeAnalysis[]; + analysisLanguage: string; + disabled: boolean; + popoverOpen: boolean; + onEditBreakdown: () => void; +}>) { + const [localizedStrings] = useLocalizedStrings(MORPHEME_BOX_STRING_KEYS); + // Hovering any form cell tints the whole forms row: clicking any cell opens the same + // whole-breakdown editor, so the affordance is breakdown-wide, not per-morpheme. + const [isFormsHovered, setIsFormsHovered] = useState(false); + + const editLabel = localizedStrings['%interlinearizer_tokenChip_editMorphemes%'].replace( + '{token}', + token.surfaceText, + ); + + return ( + +
+ {/* Forms row: one clickable cell per morpheme, exposed to assistive tech as a single + "edit breakdown" control. The cells share grid columns with the gloss inputs below so + each form sits directly above its gloss. */} + {morphemes.map((m, i) => ( + + ))} + {/* Gloss row: each input fills its column and sits directly under its morpheme form. */} + {morphemes.map((m, i) => ( + + ))} +
+
+ ); +} + +/** + * Renders a single morpheme's gloss as an editable input filling its grid column, directly under + * the morpheme's form. Writes to the store on blur when the draft differs from the committed value. + * The input carries a `data-morpheme-gloss` attribute so container-level "focus the first gloss + * input" handlers (e.g. {@link PhraseBox}) can exclude morpheme glosses, which precede the token + * gloss input in DOM order. + * + * @param props - Component props. + * @param props.morpheme - The morpheme whose gloss is being edited. + * @param props.tokenRef - The token ref for dispatching gloss writes. + * @param props.analysisLanguage - BCP 47 tag for reading/writing the gloss. + * @param props.disabled - When true, the input is read-only. + * @param props.column - 1-based grid column the input occupies (shared with the morpheme's form). + * @returns A cell-filling text input for the morpheme gloss, placed in the gloss row. + */ +export function MorphemeGlossInput({ + morpheme, + tokenRef, + analysisLanguage, + disabled, + column, +}: Readonly<{ + morpheme: MorphemeAnalysis; + tokenRef: string; + analysisLanguage: string; + disabled: boolean; + column: number; +}>) { + const committed = morpheme.gloss?.[analysisLanguage] ?? ''; + const dispatchMorphemeGloss = useMorphemeGlossDispatch(); + const [draft, setDraft] = useState(committed); + const [localizedStrings] = useLocalizedStrings(MORPHEME_GLOSS_STRING_KEYS); + + useEffect(() => { + setDraft(committed); + }, [committed]); + + // Surface uncommitted typing to the unsaved indicator before the gloss commits on blur. + useReportGlossEditing(!disabled && draft !== committed); + + return ( + morpheme.form, + )} + className="tw:gloss-input tw:text-xs" + data-morpheme-gloss="true" + disabled={disabled} + placeholder="—" + // `field-sizing: content` sizes the input to its current value and grows it as the user types, + // so the `auto` grid track tracks the rendered gloss with no slack — matching the token gloss + // input in TokenChip. `min-width` keeps a small floor so an empty field stays clickable. + style={{ gridColumn: column, gridRow: 2, fieldSizing: 'content', minWidth: '2ch' }} + value={draft} + onChange={(e) => setDraft(e.target.value)} + onBlur={() => { + if (!disabled && draft !== committed) dispatchMorphemeGloss(tokenRef, morpheme.id, draft); + }} + type="text" + /> + ); +} diff --git a/src/components/MorphemeEditor.tsx b/src/components/MorphemeEditor.tsx index 415823d4..1070287f 100644 --- a/src/components/MorphemeEditor.tsx +++ b/src/components/MorphemeEditor.tsx @@ -1,14 +1,13 @@ /** - * @file Inline morpheme editing components rendered inside {@link TokenChip} when the morphology - * toggle is active. {@link MorphemeBreakdownPopover} lets the user define or re-split a token's - * morpheme forms; {@link MorphemeGlossInput} provides a per-morpheme gloss field. + * @file The morpheme breakdown editor rendered inside {@link TokenChip} when the morphology toggle + * is active. {@link MorphemeBreakdownPopover} lets the user define or re-split a token's morpheme + * forms. The inline _display_ of the breakdown (the boxed grid of forms and their gloss fields) + * lives separately in {@link ./MorphemeBox}. */ -import type { MorphemeAnalysis } from 'interlinearizer'; import { useLocalizedStrings } from '@papi/frontend/react'; import { PopoverContent } from 'platform-bible-react'; import { useEffect, useId, useRef, useState } from 'react'; import type { KeyboardEvent, MouseEvent } from 'react'; -import { useMorphemeGlossDispatch, useReportGlossEditing } from './AnalysisStore'; const POPOVER_STRING_KEYS = [ '%interlinearizer_morphemeEditor_splitLabel%', @@ -17,10 +16,6 @@ const POPOVER_STRING_KEYS = [ '%interlinearizer_morphemeEditor_done%', ] as const satisfies `%${string}%`[]; -const MORPHEME_GLOSS_STRING_KEYS = [ - '%interlinearizer_morphemeGloss_label%', -] as const satisfies `%${string}%`[]; - /** * Inline popover for defining or editing a token's morpheme breakdown. The user types * space-separated morpheme forms (e.g. "un- believe -able") and commits with Enter, Done, or by @@ -230,62 +225,3 @@ export function MorphemeBreakdownPopover({ ); } - -/** - * Renders a single morpheme's gloss as an inline editable input. Writes to the store on blur when - * the draft differs from the committed value. The input carries a `data-morpheme-gloss` attribute - * so container-level "focus the first gloss input" handlers (e.g. {@link PhraseBox}) can exclude - * morpheme glosses, which precede the token gloss input in DOM order. - * - * @param props - Component props. - * @param props.morpheme - The morpheme whose gloss is being edited. - * @param props.tokenRef - The token ref for dispatching gloss writes. - * @param props.analysisLanguage - BCP 47 tag for reading/writing the gloss. - * @param props.disabled - When true, the input is read-only. - * @returns A sized text input for the morpheme gloss. - */ -export function MorphemeGlossInput({ - morpheme, - tokenRef, - analysisLanguage, - disabled, -}: Readonly<{ - morpheme: MorphemeAnalysis; - tokenRef: string; - analysisLanguage: string; - disabled: boolean; -}>) { - const committed = morpheme.gloss?.[analysisLanguage] ?? ''; - const dispatchMorphemeGloss = useMorphemeGlossDispatch(); - const [draft, setDraft] = useState(committed); - const [localizedStrings] = useLocalizedStrings(MORPHEME_GLOSS_STRING_KEYS); - - useEffect(() => { - setDraft(committed); - }, [committed]); - - // Surface uncommitted typing to the unsaved indicator before the gloss commits on blur. - useReportGlossEditing(!disabled && draft !== committed); - - return ( - morpheme.form, - )} - className="tw:gloss-input tw:text-xs" - data-morpheme-gloss="true" - disabled={disabled} - placeholder="—" - style={{ fieldSizing: 'content', minWidth: '2ch' }} - value={draft} - onChange={(e) => setDraft(e.target.value)} - onBlur={() => { - if (!disabled && draft !== committed) { - dispatchMorphemeGloss(tokenRef, morpheme.id, draft); - } - }} - type="text" - /> - ); -} diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index 4907cad4..98b01c3b 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -56,7 +56,7 @@ function PhraseGlossInput({ return ( - -
+ {hasMorphemes ? ( + setPopoverOpen(true)} + popoverOpen={popoverOpen} + token={token} + /> + ) : ( + - {hasMorphemes && ( - - {morphemes.map((m) => ( - - ))} - - )} -
-
+ + )} {popoverOpen && ( Date: Mon, 29 Jun 2026 13:31:52 -0600 Subject: [PATCH 2/5] Make MorphemeBox forms row a single a11y control; fix open-state border Only the first form cell is a real edit-breakdown button; remaining cells are presentational spans (aria-hidden) so assistive tech sees one control for the whole breakdown. Move tw:border-border into the always-on base classes so the open (popover) state keeps an explicit border color instead of falling back to the gray-200 default. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/MorphemeBox.tsx | 70 +++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 277c40c2..9cef2f76 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -77,30 +77,56 @@ export function MorphemeBox({ return (
- {/* Forms row: one clickable cell per morpheme, exposed to assistive tech as a single - "edit breakdown" control. The cells share grid columns with the gloss inputs below so - each form sits directly above its gloss. */} - {morphemes.map((m, i) => ( - - ))} + {/* Forms row. The first cell is the single accessible "edit breakdown" control (a real + button); the rest are presentational form cells that share its click and hover behavior + but carry no button semantics, so assistive tech sees one control for the whole + breakdown. The cells share grid columns with the gloss inputs below so each form sits + directly above its gloss. */} + {morphemes.map((m, i) => { + const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${disabled ? '' : ' tw:cursor-pointer'}${isFormsHovered && !disabled ? ' tw:bg-accent' : ''}`; + const formStyle = { gridColumn: i + 1, gridRow: 1 }; + const handleClick = () => { + if (!disabled) onEditBreakdown(); + }; + const handleMouseEnter = () => setIsFormsHovered(true); + const handleMouseLeave = () => setIsFormsHovered(false); + + if (i === 0) + return ( + + ); + + return ( + + ); + })} {/* Gloss row: each input fills its column and sits directly under its morpheme form. */} {morphemes.map((m, i) => ( Date: Mon, 29 Jun 2026 13:37:43 -0600 Subject: [PATCH 3/5] Remove unused localized string, move hovering funcitonality to outer div in MorphemeBox --- src/components/MorphemeBox.tsx | 20 +++++++++----------- src/components/TokenChip.tsx | 1 - 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 9cef2f76..059d107c 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -29,9 +29,9 @@ const MORPHEME_BOX_STRING_KEYS = [ * * The whole forms row is a single accessible "edit breakdown" control rather than one labeled * button per morpheme: every form cell opens the same whole-breakdown editor, so per-cell labels - * would be redundant for assistive tech. Hovering any form cell tints the whole forms row (the - * action is breakdown-wide, not per-morpheme), tracked with local hover state. While the editor - * popover is open the box takes an accent ring so it reads as the one being edited. + * would be redundant for assistive tech. Hovering anywhere in the box tints the whole forms row + * (the action is breakdown-wide, not per-morpheme), tracked with local hover state. While the + * editor popover is open the box takes an accent ring so it reads as the one being edited. * * Renders the {@link PopoverAnchor} the editor popover is positioned from; the caller owns the * `Popover` root and the popover content. @@ -65,8 +65,10 @@ export function MorphemeBox({ onEditBreakdown: () => void; }>) { const [localizedStrings] = useLocalizedStrings(MORPHEME_BOX_STRING_KEYS); - // Hovering any form cell tints the whole forms row: clicking any cell opens the same - // whole-breakdown editor, so the affordance is breakdown-wide, not per-morpheme. + // Hovering anywhere in the box tints the whole forms row: clicking any cell opens the same + // whole-breakdown editor, so the affordance is breakdown-wide, not per-morpheme. Tracking hover + // on the container (rather than per cell) avoids a one-frame un-tint as the pointer crosses the + // gap between adjacent form cells. const [isFormsHovered, setIsFormsHovered] = useState(false); const editLabel = localizedStrings['%interlinearizer_tokenChip_editMorphemes%'].replace( @@ -79,6 +81,8 @@ export function MorphemeBox({
setIsFormsHovered(true)} + onMouseLeave={() => setIsFormsHovered(false)} > {/* Forms row. The first cell is the single accessible "edit breakdown" control (a real button); the rest are presentational form cells that share its click and hover behavior @@ -91,8 +95,6 @@ export function MorphemeBox({ const handleClick = () => { if (!disabled) onEditBreakdown(); }; - const handleMouseEnter = () => setIsFormsHovered(true); - const handleMouseLeave = () => setIsFormsHovered(false); if (i === 0) return ( @@ -106,8 +108,6 @@ export function MorphemeBox({ e.preventDefault(); handleClick(); }} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} > {m.form} @@ -120,8 +120,6 @@ export function MorphemeBox({ className={formClassName} style={formStyle} onClick={handleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} > {m.form} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index adce5acd..abea5929 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -33,7 +33,6 @@ import { MorphemeBreakdownPopover } from './MorphemeEditor'; import SuggestionDropdown from './SuggestionDropdown'; const STRING_KEYS = [ - '%interlinearizer_tokenChip_editMorphemes%', '%interlinearizer_tokenChip_defineMorphemes%', '%interlinearizer_glossInput_placeholder%', ] as const satisfies `%${string}%`[]; From 677b7b0a572f9222d88921c3e1dd720b2f8ff6a7 Mon Sep 17 00:00:00 2001 From: "D. Ror." Date: Thu, 2 Jul 2026 09:42:01 -0600 Subject: [PATCH 4/5] Fix gloss-input focus steal on non-first morpheme form cell click (#135) Also merge in `main` and fill test coverage. --- src/__tests__/components/MorphemeBox.test.tsx | 32 ++++++++++++++++++- src/components/MorphemeBox.tsx | 5 ++- src/components/TokenChip.tsx | 6 ++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index 68ea8ac9..f108d819 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -3,7 +3,7 @@ /// import { useLocalizedStrings } from '@papi/frontend/react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MorphemeAnalysis, Token } from 'interlinearizer'; import * as AnalysisStore from '../../components/AnalysisStore'; @@ -118,6 +118,36 @@ describe('MorphemeBox', () => { expect(onEditBreakdown).toHaveBeenCalledTimes(1); }); + it('calls onEditBreakdown when a non-first form cell is clicked', async () => { + // Non-first cells are spans, not buttons, so they need their own coverage. + const onEditBreakdown = jest.fn(); + renderBox({ onEditBreakdown }); + await userEvent.click(screen.getByText('-lo')); + expect(onEditBreakdown).toHaveBeenCalledTimes(1); + }); + + it('stops a non-first form cell mousedown from bubbling to an ancestor handler', () => { + // Regression test: an ancestor onMouseDown (e.g. TokenChip's label) would otherwise focus the + // gloss input, since a span (unlike a button) doesn't match its input/button guard. + const onAncestorMouseDown = jest.fn(); + render( + // Stand-in for an ancestor React handler, not real UI. + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ +
, + ); + fireEvent.mouseDown(screen.getByText('-lo')); + expect(onAncestorMouseDown).not.toHaveBeenCalled(); + }); + it('does not call onEditBreakdown when disabled', async () => { const onEditBreakdown = jest.fn(); renderBox({ disabled: true, onEditBreakdown }); diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 059d107c..789209b6 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -118,8 +118,11 @@ export function MorphemeBox({ key={m.id} aria-hidden="true" className={formClassName} - style={formStyle} onClick={handleClick} + // Not a button, so stop mousedown from bubbling to TokenChip's label handler (which + // would otherwise focus the gloss input). + onMouseDown={(e) => e.stopPropagation()} + style={formStyle} > {m.form} diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index abea5929..0ec99ad8 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -167,8 +167,10 @@ export function TokenChip({ * The input is looked up by id rather than `querySelector('input')` because the morpheme gloss * inputs precede it inside the label when morphology is shown. A mouse-down on any input is left * to that input's own handling ({@link handleMouseDown} for the gloss input, which bubbles here - * after already handling it); a mouse-down on a morpheme form cell or the unanalyzed "define" - * trigger (all `button`s) is left to that button's own click handler, which opens the popover. + * after already handling it); a mouse-down on the first morpheme form cell or the unanalyzed + * "define" trigger (both real `button`s) is left to that button's own click handler. The + * remaining morpheme form cells are `span`s that stop their own mousedown from bubbling here + * instead (see {@link MorphemeBox}). * * @param e - The label's mouse-down event. */ From 0427cdbcd1d845894575c62ae03b8e73e2aed266 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Thu, 2 Jul 2026 09:54:17 -0600 Subject: [PATCH 5/5] Fix inconsitencies --- src/components/MorphemeBox.tsx | 3 ++- src/components/TokenChip.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 789209b6..9f35bd33 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -73,7 +73,7 @@ export function MorphemeBox({ const editLabel = localizedStrings['%interlinearizer_tokenChip_editMorphemes%'].replace( '{token}', - token.surfaceText, + () => token.surfaceText, ); return ( @@ -103,6 +103,7 @@ export function MorphemeBox({ aria-label={editLabel} className={formClassName} style={formStyle} + tabIndex={-1} type="button" onClick={(e) => { e.preventDefault(); diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 0ec99ad8..34597621 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -414,7 +414,7 @@ export function TokenChip({