diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx new file mode 100644 index 00000000..f108d819 --- /dev/null +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -0,0 +1,262 @@ +/** @file Unit tests for components/MorphemeBox.tsx. */ +/// +/// + +import { useLocalizedStrings } from '@papi/frontend/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'; +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('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 }); + 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..9f35bd33 --- /dev/null +++ b/src/components/MorphemeBox.tsx @@ -0,0 +1,210 @@ +/** + * @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 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. + * + * @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 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( + '{token}', + () => token.surfaceText, + ); + + return ( + +
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 + 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(); + }; + + if (i === 0) + return ( + + ); + + return ( + + ); + })} + {/* 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 && (