Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,8 @@ export function PopoverAnchor({
* implements positioning, portaling, and dismissal internally; this stub exposes the dismissal
* callbacks so tests can simulate them:
*
* - `role`, `id`, and `style` land on the panel element, as they do in the real component, which
* spreads caller props after its own — so a caller can override the panel's dialog role and id.
* - The panel's children render only from the second commit, mirroring Radix's portal (which renders
* nothing until its own layout effect flips its `mounted` state). Consumers must therefore not
* reach for a child element from their own mount effect — in the real app that element does not
Expand All @@ -768,13 +770,21 @@ export function PopoverAnchor({
* - A sentinel `data-testid="popover-outside"` button invokes `onPointerDownOutside` on click,
* simulating a pointer press outside the popover.
* - A sentinel `data-testid="popover-close"` button invokes `onCloseAutoFocus` on click,
* simulating Radix's focus-restoration event fired as the popover closes.
* simulating Radix's focus-restoration event fired as the popover closes; unless it is prevented,
* the focused element is blurred. Which element Radix restores focus to — its trigger, or the
* element focused before the panel opened — is not modeled; only that an unprevented event lets
* the panel take focus away.
* - The layout props are accepted and ignored: they steer positioning the real component computes
* from measurements jsdom does not produce.
*/
export function PopoverContent({
'aria-label': ariaLabel,
children,
className,
'data-testid': testId = 'popover-content',
id,
role = 'dialog',
style,
onEscapeKeyDown,
onPointerDownOutside,
onOpenAutoFocus,
Expand All @@ -786,8 +796,12 @@ export function PopoverContent({
children?: ReactNode;
className?: string;
'data-testid'?: string;
id?: string;
role?: string;
style?: CSSProperties;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
align?: 'start' | 'center' | 'end';
sideOffset?: number;
hideWhenDetached?: boolean;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: CustomEvent) => void;
onOpenAutoFocus?: (event: Event) => void;
Expand Down Expand Up @@ -822,7 +836,9 @@ export function PopoverContent({
aria-label={ariaLabel}
className={className}
data-testid={testId}
role="dialog"
id={id}
role={role}
style={style}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Escape') onEscapeKeyDown?.(e.nativeEvent);
Expand All @@ -849,7 +865,12 @@ export function PopoverContent({
<button
data-testid="popover-close"
type="button"
onClick={() => onCloseAutoFocus(new Event('closeAutoFocus'))}
onClick={() => {
const event = new Event('closeAutoFocus', { cancelable: true });
onCloseAutoFocus(event);
if (event.defaultPrevented) return;
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
}}
>
close
</button>
Expand Down
116 changes: 58 additions & 58 deletions src/__tests__/components/TokenChip.suggestions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -654,78 +654,78 @@ describe('TokenChip suggestion + button', () => {
});
});

describe('TokenChip suggestion dropdown scrolling', () => {
/**
* Stubs the gloss input's `getBoundingClientRect` so the dropdown's scroll handler sees a
* definite on- or off-screen anchor (jsdom returns an all-zero rect by default, which reads as
* on-screen).
*/
function stubGlossRect(top: number): void {
jest.spyOn(HTMLInputElement.prototype, 'getBoundingClientRect').mockReturnValue({
top,
bottom: top + 10,
left: 0,
right: 50,
x: 0,
y: top,
width: 50,
height: 10,
toJSON: () => ({}),
});
}
describe('TokenChip suggestion combobox wiring', () => {
it('points the input at the open panel as its listbox', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });

it('stays open and follows the anchor when the surrounding view scrolls it in view', async () => {
renderChip(wordToken('tok-2', 'logos'), { initialAnalysis: poolWithOneApproved('word') });
await focusGloss('logos');
expect(screen.getByTestId('suggestion-accept')).toBeInTheDocument();
const input = await focusGloss('bank');

// The token strip centers the focused phrase on focus: the anchor moves but stays in view, so
// the dropdown repositions under it rather than dismissing the panel that just opened.
stubGlossRect(100);
fireEvent.scroll(window);
expect(input).toHaveAttribute('role', 'combobox');
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id);
});

const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
// `left` is the input's center (left 0 + width 50 / 2) and the panel is translated -50% so it
// stays centered on the input; `min-width` pins it to at least the input's width.
expect(listbox).toHaveStyle({
top: '112px',
left: '25px',
minWidth: '50px',
transform: 'translateX(-50%)',
});
it('collapses the input to the closed combobox state when the panel closes', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });

const input = await focusGloss('bank');
await userEvent.type(input, 'mine');

expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(input).toHaveAttribute('aria-expanded', 'false');
expect(input).not.toHaveAttribute('aria-controls');
});

it('closes when the surrounding view scrolls the anchor out of the viewport', async () => {
renderChip(wordToken('tok-2', 'logos'), { initialAnalysis: poolWithOneApproved('word') });
await focusGloss('logos');
expect(screen.getByTestId('suggestion-accept')).toBeInTheDocument();
it('names no active descendant while no row is highlighted', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });

// A far user scroll pushes the anchor above the top of the viewport, abandoning this token.
stubGlossRect(-50);
fireEvent.scroll(window);
const input = await focusGloss('bank');

expect(screen.queryByTestId('suggestion-accept')).not.toBeInTheDocument();
expect(input).not.toHaveAttribute('aria-activedescendant');
});

it('closes when the surrounding view scrolls the anchor below the viewport', async () => {
renderChip(wordToken('tok-2', 'logos'), { initialAnalysis: poolWithOneApproved('word') });
await focusGloss('logos');
expect(screen.getByTestId('suggestion-accept')).toBeInTheDocument();
it('names the keyboard-highlighted row as the active descendant', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });

// A far user scroll pushes the anchor below the bottom edge, abandoning this token.
stubGlossRect(window.innerHeight + 50);
fireEvent.scroll(window);
const input = await focusGloss('bank');
await userEvent.keyboard('{ArrowDown}');

expect(screen.queryByTestId('suggestion-accept')).not.toBeInTheDocument();
const [first] = screen.getAllByRole('option');
expect(first).toHaveAttribute('aria-selected', 'true');
expect(input).toHaveAttribute('aria-activedescendant', first.id);
});

it('stays open when the dropdown list itself is scrolled', async () => {
renderChip(wordToken('tok-2', 'logos'), { initialAnalysis: poolWithOneApproved('word') });
await focusGloss('logos');
it('keeps focus in the gloss input while the panel is open', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });

fireEvent.scroll(screen.getByRole('listbox'));
const input = await focusGloss('bank');

expect(screen.getByTestId('suggestion-accept')).toBeInTheDocument();
expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(input).toHaveFocus();
});

it('keeps focus in the gloss input when the panel restores focus as it closes', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });
const input = await focusGloss('bank');

// fireEvent so the sentinel itself never takes focus, leaving the panel's own focus-restoration
// as the only thing that could move it — which, left unprevented, blurs the input.
fireEvent.click(screen.getByTestId('popover-close'));

expect(input).toHaveFocus();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});

describe('TokenChip suggestion dropdown scrolling', () => {
// The other half of scrolling — the panel hiding itself once the anchor is clipped away — is the
// popover's own doing, off measurements jsdom does not produce, so it stays beyond reach here.
it('keeps the panel open when the surrounding view scrolls', async () => {
renderChip(wordToken('tok-new', 'bank'), { initialAnalysis: homographBankPool('finance') });
const input = await focusGloss('bank');

fireEvent.scroll(window);

expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(input).toHaveFocus();
});
});
Loading
Loading