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 (
+ e.stopPropagation()}
+ style={formStyle}
+ >
+ {m.form}
+
+ );
+ })}
+ {/* 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 (
-
-