Skip to content

Commit 20aeccd

Browse files
Migrate MorphemeEditor to Platform.Bible popover, clean up analysisSlice, other minor improvements
1 parent 36151c1 commit 20aeccd

8 files changed

Lines changed: 615 additions & 331 deletions

File tree

__mocks__/platform-bible-react.tsx

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* without extra transform configuration. This stub provides the subset used by the extension.
44
*/
55

6-
import { forwardRef } from 'react';
7-
import type { ReactElement, ReactNode } from 'react';
6+
import { forwardRef, useEffect, useRef } from 'react';
7+
import type { MouseEventHandler, ReactElement, ReactNode } from 'react';
88

99
export interface MenuItemContainingCommand {
1010
label: `%${string}%`;
@@ -323,6 +323,104 @@ export function Switch({
323323
);
324324
}
325325

326+
/**
327+
* Stub popover root that renders its children unconditionally. The extension conditionally mounts
328+
* the content component while open (so its draft state re-initializes per open), so visibility
329+
* needs no simulation here.
330+
*
331+
* @param props - Component props.
332+
* @param props.children - Anchor and (while open) content elements.
333+
* @returns The children unchanged.
334+
*/
335+
export function Popover({
336+
children,
337+
}: Readonly<{ children?: ReactNode; open?: boolean; modal?: boolean }>): ReactElement {
338+
return <>{children}</>;
339+
}
340+
341+
/**
342+
* Stub popover anchor that renders its children as-is, matching the real component's `asChild`
343+
* pass-through behavior.
344+
*
345+
* @param props - Component props.
346+
* @param props.children - The element the popover is anchored to.
347+
* @returns The children unchanged.
348+
*/
349+
export function PopoverAnchor({
350+
children,
351+
}: Readonly<{ children?: ReactNode; asChild?: boolean }>): ReactElement {
352+
return <>{children}</>;
353+
}
354+
355+
/**
356+
* Stub popover content rendered as a plain `<div data-testid="popover-content">`. The real
357+
* component implements positioning, portaling, and dismissal internally; this stub exposes the
358+
* dismissal callbacks so tests can simulate them:
359+
*
360+
* - `onOpenAutoFocus` is invoked once on mount (mirroring Radix's open auto-focus event).
361+
* - An Escape keydown anywhere inside the content invokes `onEscapeKeyDown`.
362+
* - A sentinel `data-testid="popover-outside"` button invokes `onInteractOutside` on click,
363+
* simulating a pointer interaction outside the popover.
364+
*
365+
* @param props - Component props.
366+
* @param props.children - Panel content.
367+
* @param props.className - CSS class names forwarded to the div.
368+
* @param props.onEscapeKeyDown - Called when Escape is pressed inside the content.
369+
* @param props.onInteractOutside - Called when the sentinel outside button is clicked.
370+
* @param props.onOpenAutoFocus - Called once on mount with a plain `Event`.
371+
* @param props.onClick - Click handler forwarded to the div.
372+
* @param props.onMouseDown - Mouse-down handler forwarded to the div.
373+
* @returns A `<div data-testid="popover-content">` with the panel content and sentinel controls.
374+
*/
375+
export function PopoverContent({
376+
children,
377+
className,
378+
onEscapeKeyDown,
379+
onInteractOutside,
380+
onOpenAutoFocus,
381+
onClick,
382+
onMouseDown,
383+
}: Readonly<{
384+
children?: ReactNode;
385+
className?: string;
386+
align?: 'start' | 'center' | 'end';
387+
sideOffset?: number;
388+
onEscapeKeyDown?: () => void;
389+
onInteractOutside?: () => void;
390+
onOpenAutoFocus?: (event: Event) => void;
391+
onClick?: MouseEventHandler<HTMLDivElement>;
392+
onMouseDown?: MouseEventHandler<HTMLDivElement>;
393+
}>): ReactElement {
394+
// Capture the mount-time callback so the simulation fires exactly once, like the real event.
395+
const openAutoFocusRef = useRef(onOpenAutoFocus);
396+
useEffect(() => {
397+
openAutoFocusRef.current?.(new Event('openAutoFocus'));
398+
}, []);
399+
return (
400+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
401+
<div
402+
className={className}
403+
data-testid="popover-content"
404+
onClick={onClick}
405+
onKeyDown={(e) => {
406+
if (e.key === 'Escape') onEscapeKeyDown?.();
407+
}}
408+
onMouseDown={onMouseDown}
409+
>
410+
{children}
411+
{onInteractOutside && (
412+
<button
413+
data-testid="popover-outside"
414+
type="button"
415+
onClick={() => onInteractOutside()}
416+
>
417+
outside
418+
</button>
419+
)}
420+
</div>
421+
);
422+
}
423+
326424
/**
327425
* Stub label rendered as a native `<label>` element.
328426
*

src/__tests__/components/MorphemeEditor.test.tsx

Lines changed: 43 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/// <reference types="jest" />
33
/// <reference types="@testing-library/jest-dom" />
44

5-
import { render, screen } from '@testing-library/react';
5+
import { fireEvent, render, screen } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
77
import * as AnalysisStore from '../../components/AnalysisStore';
88
import { MorphemeBreakdownPopover, MorphemeGlossInput } from '../../components/MorphemeEditor';
@@ -105,43 +105,29 @@ describe('MorphemeBreakdownPopover', () => {
105105
expect(onClose).toHaveBeenCalledTimes(1);
106106
});
107107

108-
it('closes when the backdrop is clicked', async () => {
108+
it('closes without saving when interacting outside with unchanged text', async () => {
109+
const onSave = jest.fn();
109110
const onClose = jest.fn();
110-
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={onClose} />);
111-
// The backdrop is the fixed full-screen div; getByRole won't find it, so query the DOM.
112-
const backdrop = document.querySelector('.tw\\:fixed.tw\\:inset-0');
113-
if (!backdrop) throw new Error('Backdrop not found');
114-
await userEvent.click(backdrop);
111+
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
112+
// The platform-bible-react mock exposes a sentinel button that fires onInteractOutside,
113+
// simulating a pointer interaction outside the popover.
114+
await userEvent.click(screen.getByTestId('popover-outside'));
115+
expect(onSave).not.toHaveBeenCalled();
115116
expect(onClose).toHaveBeenCalledTimes(1);
116117
});
117118

118-
it('saves on backdrop click when the text was edited', async () => {
119+
it('saves on outside interaction when the text was edited', async () => {
119120
const onSave = jest.fn();
120121
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={jest.fn()} />);
121122
await userEvent.type(screen.getByRole('textbox'), ' -er');
122-
const backdrop = document.querySelector('.tw\\:fixed.tw\\:inset-0');
123-
if (!backdrop) throw new Error('Backdrop not found');
124-
await userEvent.click(backdrop);
123+
await userEvent.click(screen.getByTestId('popover-outside'));
125124
expect(onSave).toHaveBeenCalledWith('test -er');
126125
});
127126

128-
it('does not save on backdrop click when the text is unchanged', async () => {
129-
const onSave = jest.fn();
130-
const onClose = jest.fn();
131-
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
132-
const backdrop = document.querySelector('.tw\\:fixed.tw\\:inset-0');
133-
if (!backdrop) throw new Error('Backdrop not found');
134-
await userEvent.click(backdrop);
135-
expect(onSave).not.toHaveBeenCalled();
136-
expect(onClose).toHaveBeenCalledTimes(1);
137-
});
138-
139-
it('does not save on backdrop click when the input is only whitespace', async () => {
127+
it('does not save on outside interaction when the input is only whitespace', async () => {
140128
const onSave = jest.fn();
141129
render(<MorphemeBreakdownPopover initialValue=" " onSave={onSave} onClose={jest.fn()} />);
142-
const backdrop = document.querySelector('.tw\\:fixed.tw\\:inset-0');
143-
if (!backdrop) throw new Error('Backdrop not found');
144-
await userEvent.click(backdrop);
130+
await userEvent.click(screen.getByTestId('popover-outside'));
145131
expect(onSave).not.toHaveBeenCalled();
146132
});
147133

@@ -153,6 +139,32 @@ describe('MorphemeBreakdownPopover', () => {
153139
expect(onClose).not.toHaveBeenCalled();
154140
});
155141

142+
it('stops clicks inside the panel from reaching ancestor click handlers', async () => {
143+
// The panel is portaled to document.body, but React synthetic events bubble through the React
144+
// tree to the token chip and its phrase-selection click handlers; the panel must contain them.
145+
const ancestorClick = jest.fn();
146+
render(
147+
<div role="presentation" onClick={ancestorClick}>
148+
<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />
149+
</div>,
150+
);
151+
await userEvent.click(screen.getByText('Split into morphemes'));
152+
expect(ancestorClick).not.toHaveBeenCalled();
153+
});
154+
155+
it('stops mouse-downs inside the panel from reaching ancestor mouse-down handlers', () => {
156+
// A mouse-down that escaped the panel would reach the chip label's mouse-down handler, which
157+
// focuses the gloss input behind the popover and blurs the editor mid-edit.
158+
const ancestorMouseDown = jest.fn();
159+
render(
160+
<div role="presentation" onMouseDown={ancestorMouseDown}>
161+
<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />
162+
</div>,
163+
);
164+
fireEvent.mouseDown(screen.getByText('Split into morphemes'));
165+
expect(ancestorMouseDown).not.toHaveBeenCalled();
166+
});
167+
156168
it('does not call onSave when the input is empty', async () => {
157169
const onSave = jest.fn();
158170
render(<MorphemeBreakdownPopover initialValue="" onSave={onSave} onClose={jest.fn()} />);
@@ -190,35 +202,12 @@ describe('MorphemeBreakdownPopover', () => {
190202
expect(onSave).not.toHaveBeenCalled();
191203
});
192204

193-
it('portals the panel to document.body so segment rows cannot stack above it', () => {
194-
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />);
195-
const panel = screen.getByText('Split into morphemes').closest('div');
196-
expect(panel?.parentElement).toBe(document.body);
197-
});
198-
199-
it('positions the panel below the anchor when there is room under the viewport bottom', () => {
200-
// The layout effect measures the anchor (panel's DOM parent) first, then the panel itself.
201-
jest
202-
.spyOn(Element.prototype, 'getBoundingClientRect')
203-
.mockReturnValueOnce(new DOMRect(50, 100, 40, 20))
204-
.mockReturnValueOnce(new DOMRect(0, 0, 200, 100));
205-
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />);
206-
const panel = screen.getByText('Split into morphemes').closest('div');
207-
// Anchor bottom (120) plus the 4px margin.
208-
expect(panel).toHaveStyle({ top: '124px', left: '50px' });
209-
});
210-
211-
it('flips the panel above the anchor when the viewport bottom is too close', () => {
212-
// Anchor bottom at 720 leaves only 48px below in jsdom's 768px-tall window — not enough for
213-
// the 100px-tall panel, so it flips above the anchor.
214-
jest
215-
.spyOn(Element.prototype, 'getBoundingClientRect')
216-
.mockReturnValueOnce(new DOMRect(50, 700, 40, 20))
217-
.mockReturnValueOnce(new DOMRect(0, 0, 200, 100));
205+
it('renders inside the popover content panel', () => {
206+
// Positioning, portaling, and flipping are owned by the platform-bible-react Popover; this
207+
// only verifies the editor renders as the popover's content.
218208
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />);
219-
const panel = screen.getByText('Split into morphemes').closest('div');
220-
// Anchor top (700) minus panel height (100) minus the 4px margin.
221-
expect(panel).toHaveStyle({ top: '596px', left: '50px' });
209+
const content = screen.getByTestId('popover-content');
210+
expect(content).toContainElement(screen.getByText('Split into morphemes'));
222211
});
223212
});
224213

src/__tests__/components/PhraseBox.test.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ jest.mock('../../components/TokenChip', () => {
5959
* @param props.isSplitFree - When true, marks the chip as a would-be-free token.
6060
* @param props.onRemove - Called when the remove button is clicked; omitted for edge tokens.
6161
* @param props.showMorphology - Exposed as a data attribute so tests can verify every PhraseBox
62-
* render path forwards the strip-wide morphology toggle.
62+
* render path forwards the strip-wide morphology toggle. When true, also renders a
63+
* `data-morpheme-gloss` input before the main gloss input, mirroring the real chip's DOM order
64+
* so focus-routing tests exercise the morpheme-exclusion selector.
6365
* @returns A span containing the surface text, a gloss input, and an optional remove button.
6466
*/
6567
function MockTokenChip({
@@ -84,6 +86,12 @@ jest.mock('../../components/TokenChip', () => {
8486
data-show-morphology={showMorphology ? 'true' : 'false'}
8587
>
8688
{token.surfaceText}
89+
{showMorphology && (
90+
<input
91+
aria-label={`Gloss for morpheme ${token.surfaceText}`}
92+
data-morpheme-gloss="true"
93+
/>
94+
)}
8795
<input
8896
aria-label={`Gloss for ${token.surfaceText}`}
8997
onChange={(e) => dispatch(token.ref, token.surfaceText, e.target.value)}
@@ -300,6 +308,37 @@ describe('PhraseBox', () => {
300308
expect(onFocusPhrase).toHaveBeenCalledWith('test-group');
301309
});
302310

311+
it('clicking the box with morphology shown focuses the token gloss input, not the preceding morpheme gloss input', async () => {
312+
const onFocusPhrase = jest.fn();
313+
renderBox(
314+
<PhraseBox
315+
{...requiredProps()}
316+
onFocusPhrase={onFocusPhrase}
317+
tokens={[TEST_TOKEN, TEST_TOKEN_2]}
318+
/>,
319+
{ showMorphology: true },
320+
);
321+
322+
const phraseBox = document.querySelector('[data-phrase-box="true"]');
323+
await userEvent.click(phraseBox ?? document.body);
324+
325+
// Morpheme gloss inputs precede the token gloss input in DOM order; the box-click handler must
326+
// skip them — only the token gloss input fires onFocus → onFocusPhrase.
327+
expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveFocus();
328+
expect(onFocusPhrase).toHaveBeenCalledWith('test-group');
329+
});
330+
331+
it('Enter on the box container with morphology shown focuses the token gloss input, not the morpheme gloss input', async () => {
332+
renderBox(<PhraseBox {...requiredProps()} />, { showMorphology: true });
333+
334+
const box = document.querySelector('[data-phrase-box="true"]');
335+
expect(box).not.toBeNull();
336+
if (box instanceof HTMLElement) box.focus();
337+
await userEvent.keyboard('{Enter}');
338+
339+
expect(screen.getByRole('textbox', { name: 'Gloss for morpheme Hello' })).not.toHaveFocus();
340+
});
341+
303342
it('applies focused border and background when isFocused is true', () => {
304343
renderBox(<PhraseBox {...requiredProps()} isFocused />);
305344

0 commit comments

Comments
 (0)