Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/calm-firefox-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Skip the Bible version picker's version list scroll area in keyboard Tab order, keep focus on the outside target when dismissing from a language screen, and focus the dialog consistently when opening or rapidly reopening the picker.
7 changes: 3 additions & 4 deletions packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,10 @@ export const WithVersionPicker: Story = {
await userEvent.click(versionPickerButton);
const overlay = await getVersionPickerOverlay(pickerRoot);

await expect(await overlay.findByRole('dialog')).toBeInTheDocument();
const dialog = await overlay.findByRole('dialog');
await expect(dialog).toBeInTheDocument();
await waitFor(async () => {
await expect(pickerRoot.activeElement).toBe(
overlay.getByRole('button', { name: /select.*language/i }),
);
await expect(pickerRoot.activeElement).toBe(dialog);
});

// Wait for versions to actually load (not just the container)
Expand Down
5 changes: 2 additions & 3 deletions packages/ui/src/components/bible-reader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,10 +546,9 @@ export const SignedInReaderOverlayOwnership: Story = {
);
await userEvent.click(versionTrigger);
const versionCanvas = await getPickerOverlay(versionRoot);
const versionDialog = await versionCanvas.findByRole('dialog');
await waitFor(async () => {
await expect(versionRoot.activeElement).toBe(
versionCanvas.getByRole('button', { name: /select.*language/i }),
);
await expect(versionRoot.activeElement).toBe(versionDialog);
});
await userEvent.click(versionCanvas.getByRole('button', { name: 'Close' }));
await waitFor(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,24 +356,34 @@ export const TopLayerEscapesClippingAndPreservesSemantics: Story = {
},
};

export const ForwardTabMovesFromCloseToFirstVersion: Story = {
export const ForwardTabSkipsScrollableVersionList: Story = {
play: async ({ canvasElement }) => {
const { userEvent: browserUserEvent } = await import('vitest/browser');
const { root, panel } = await openPicker(canvasElement);
const languageTrigger = within(panel).getByRole('button', { name: /select.*language/i });
const closeButton = within(panel).getByRole('button', { name: /close/i });
const versionSearch = within(panel).getByRole('textbox', { name: /search bible versions/i });
const languageEntry = within(panel).getByRole('button', { name: /^language /i });
const firstVersion = await waitForElement<HTMLButtonElement>(
panel,
'[data-testid="version-list"] button',
'version row not rendered',
);
await waitFor(async () => await expect(root.activeElement).toBe(languageTrigger));
const scrollRegion = firstVersion.closest('[data-testid="version-list"]')?.parentElement;
if (!scrollRegion) throw new Error('version list scroll region not found');
await expect(scrollRegion.scrollHeight).toBeGreaterThan(scrollRegion.clientHeight);
await waitFor(async () => await expect(root.activeElement).toBe(panel));
const forwardTraversalKey = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
? '{Alt>}{Tab}{/Alt}'
: '{Tab}';
await browserUserEvent.keyboard(forwardTraversalKey);
await expect(root.activeElement).toBe(closeButton);

await browserUserEvent.keyboard(forwardTraversalKey);
await expect(root.activeElement).toBe(versionSearch);

await browserUserEvent.keyboard(forwardTraversalKey);
await expect(root.activeElement).toBe(languageEntry);

await browserUserEvent.keyboard(forwardTraversalKey);
await expect(root.activeElement).toBe(firstVersion);
},
Expand Down Expand Up @@ -417,7 +427,6 @@ export const StylingFocusDismissalAndRapidReopen: Story = {
if (!topLayer) throw new Error('local top-layer container not created synchronously');
const panel = topLayer.querySelector<HTMLElement>('[data-slot="popover-content"]');
if (!panel) throw new Error('version picker panel not rendered synchronously');
const languageTrigger = within(panel).getByRole('button', { name: /select.*language/i });

const openingPanelWindow = panel.ownerDocument.defaultView;
if (!openingPanelWindow) throw new Error('version picker window not available');
Expand All @@ -436,7 +445,7 @@ export const StylingFocusDismissalAndRapidReopen: Story = {
await waitFor(async () => {
await expect(topLayer.querySelector('[data-slot="popover-content"]')).toBe(panel);
await expect(panel).toHaveAttribute('data-state', 'open');
await expect(root.activeElement).toBe(languageTrigger);
await expect(root.activeElement).toBe(panel);
});
await expect(canvasElement.ownerDocument.scrollingElement?.scrollTop ?? 0).toBe(
initialScrollTop,
Expand Down Expand Up @@ -466,27 +475,50 @@ export const StylingFocusDismissalAndRapidReopen: Story = {
hostileStyle.remove();
}

await userEvent.click(outsideControl);
await waitFor(async () => {
await expect(panel).toHaveAttribute('data-state', 'closed');
await expect(topLayer).toContainElement(panel);
await expect(topLayer.matches(':popover-open')).toBe(true);
});
await waitFor(async () => {
await expect(topLayer.querySelector('[data-slot="popover-content"]')).toBeNull();
await expect(topLayer.matches(':popover-open')).toBe(false);
});
const dismissOutsideKeepsOutsideFocus = async (dismissedPanel: HTMLElement) => {
await userEvent.click(outsideControl);
await waitFor(async () => {
await expect(dismissedPanel).toHaveAttribute('data-state', 'closed');
await expect(topLayer).toContainElement(dismissedPanel);
await expect(topLayer.matches(':popover-open')).toBe(true);
});
await new Promise<void>((resolve) => panelWindow.requestAnimationFrame(() => resolve()));
await expect(canvasElement.ownerDocument.activeElement).toBe(outsideControl);
await waitFor(async () => {
await expect(topLayer.querySelector('[data-slot="popover-content"]')).toBeNull();
await expect(topLayer.matches(':popover-open')).toBe(false);
});
};
const reopenPicker = async () => {
await userEvent.click(trigger);
const reopenedPanel = await waitForElement<HTMLElement>(
topLayer,
'[data-slot="popover-content"]',
'picker panel did not reopen',
);
await waitFor(async () => {
const activeElement = root.activeElement;
await expect(activeElement !== null && reopenedPanel.contains(activeElement)).toBe(true);
});
return reopenedPanel;
};

await userEvent.click(trigger);
const reopenedPanel = await waitForElement<HTMLElement>(
topLayer,
'[data-slot="popover-content"]',
'picker panel did not reopen',
);
await userEvent.click(within(panel).getByRole('button', { name: /^language /i }));
await expect(within(panel).getByRole('heading', { name: /bible languages/i })).toBeVisible();
await userEvent.click(within(panel).getByRole('button', { name: /search languages/i }));
await expect(within(panel).getByRole('heading', { name: /search languages/i })).toBeVisible();
await dismissOutsideKeepsOutsideFocus(panel);

const languagesPanel = await reopenPicker();
await userEvent.click(within(languagesPanel).getByRole('button', { name: /^language /i }));
await waitFor(async () => {
const activeElement = root.activeElement;
await expect(activeElement !== null && reopenedPanel.contains(activeElement)).toBe(true);
await expect(
within(languagesPanel).getByRole('heading', { name: /bible languages/i }),
).toBeVisible();
});
await dismissOutsideKeepsOutsideFocus(languagesPanel);

await reopenPicker();

const focused = root.activeElement;
if (!(focused instanceof HTMLElement)) throw new Error('picker focus not available');
Expand Down Expand Up @@ -546,25 +578,37 @@ export const ProviderDirectionRejectsHostVisualValues: Story = {
await expect(ownerWindow.getComputedStyle(trigger).direction).toBe('rtl');
await expect(ownerWindow.getComputedStyle(wrapper).direction).toBe('ltr');

await userEvent.click(within(panel).getByRole('button', { name: /select a language/i }));
await userEvent.click(within(panel).getByRole('button', { name: /^language /i }));
const languageTabs = await waitForElement<HTMLElement>(
panel,
'[data-slot="tabs-list"]',
'language tabs did not render',
);
const languageTabsContainer = languageTabs.parentElement;
const languageContent = languageTabs.closest<HTMLElement>('[data-yv-sdk]');
const languageInputGroup = languageContent?.querySelector<HTMLElement>(
'[data-slot="input-group"]',
);
if (!languageTabsContainer || !languageInputGroup) {
throw new Error('language panel geometry landmarks not rendered');
}
if (!languageTabsContainer) throw new Error('language tabs container not rendered');
const languageTabsInlineSize = languageTabs.offsetWidth;
const languageInputRadius = ownerWindow.getComputedStyle(languageInputGroup).borderRadius;
const panelInlineSize = panel.offsetWidth;
await expect(languageTabsInlineSize).toBe(languageTabsContainer.clientWidth - 32);
await expect(languageInputRadius).toBe('30px');

await userEvent.click(within(panel).getByRole('button', { name: /search languages/i }));
const baselineLanguageInputGroup = await waitForElement<HTMLElement>(
panel,
'[data-slot="input-group"]',
'language search input group did not render',
);
const languageInputRadius = ownerWindow.getComputedStyle(
baselineLanguageInputGroup,
).borderRadius;
await expect(Number.parseFloat(languageInputRadius)).toBeGreaterThanOrEqual(
baselineLanguageInputGroup.offsetHeight / 2,
);
await userEvent.click(within(panel).getByRole('button', { name: /^back$/i }));
const languageTabsAfterSearchRoundTrip = await waitForElement<HTMLElement>(
panel,
'[data-slot="tabs-list"]',
'language tabs did not return',
);
await expect(languageTabsAfterSearchRoundTrip.offsetWidth).toBe(languageTabsInlineSize);

const hostileStyle = ownerDocument.createElement('style');
hostileStyle.textContent = `
Expand Down Expand Up @@ -616,14 +660,36 @@ export const ProviderDirectionRejectsHostVisualValues: Story = {
await expect(panelStyle.getPropertyValue('--yv-spacing').trim()).toBe('.25rem');
await expect(panelStyle.getPropertyValue('--spacing').trim()).toBe('.25rem');
await expect(panelStyle.getPropertyValue('--yv-radius').trim()).toBe('2rem');
await expect(languageTabs.offsetWidth).toBe(languageTabsInlineSize);
await expect(languageTabsAfterSearchRoundTrip.offsetWidth).toBe(languageTabsInlineSize);

await userEvent.click(within(panel).getByRole('button', { name: /search languages/i }));
const languageInputGroup = await waitForElement<HTMLElement>(
panel,
'[data-slot="input-group"]',
'language search input group did not render',
);
await expect(ownerWindow.getComputedStyle(languageInputGroup).borderRadius).toBe(
languageInputRadius,
);

await userEvent.click(within(panel).getByRole('button', { name: /^back$/i }));
const restoredLanguageTabs = await waitForElement<HTMLElement>(
panel,
'[data-slot="tabs-list"]',
'language tabs did not return',
);
await expect(restoredLanguageTabs.offsetWidth).toBe(languageTabsInlineSize);

await userEvent.click(within(panel).getByRole('button', { name: /back to bible versions/i }));
const restoredVersionInputGroup = await waitForElement<HTMLElement>(
panel,
'[data-slot="input-group"]',
'version search input group did not return',
);
await waitFor(async () => {
await expect(geometrySnapshot(trigger)).toEqual(triggerGeometry);
await expect(panel.offsetWidth).toBe(panelInlineSize);
await expect(geometrySnapshot(inputGroup)).toEqual(inputGeometry);
await expect(geometrySnapshot(restoredVersionInputGroup)).toEqual(inputGeometry);
});
} finally {
hostileStyle.remove();
Expand Down
49 changes: 47 additions & 2 deletions packages/ui/src/components/bible-version-picker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,27 +588,65 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {})
background,
side,
view,
isPopoverOpen,
setIsPopoverOpen,
closeLanguagePanel,
closeLanguageSearch,
onVersionPickerPress,
} = picker;
const previousViewRef = useRef(view);
const popoverContentRef = useRef<HTMLDivElement>(null);
const focusRequestRef = useRef(0);

const schedulePanelFocus = useCallback((panel: HTMLElement) => {
// SAFETY: Element roots are Documents or ShadowRoots, which both expose activeElement.
const focusRoot = panel.getRootNode() as Document | ShadowRoot;
const previousFocus = focusRoot.activeElement;
const focusRequest = ++focusRequestRef.current;
const focusPanel = () => {
if (
focusRequestRef.current === focusRequest &&
focusRoot.activeElement === previousFocus &&
panel.isConnected &&
panel.dataset.state === 'open'
) {
panel.focus({ preventScroll: true });
}
};
// Let Safari expose the newly painted dialog before moving focus into it.
const ownerWindow = panel.ownerDocument.defaultView;
if (!ownerWindow?.requestAnimationFrame) {
queueMicrotask(focusPanel);
return;
}
ownerWindow.requestAnimationFrame(() => ownerWindow.requestAnimationFrame(focusPanel));
}, []);

useEffect(() => {
if (!isPopoverOpen) {
focusRequestRef.current += 1;
return;
}

const panel = popoverContentRef.current;
if (panel) schedulePanelFocus(panel);
}, [isPopoverOpen, schedulePanelFocus]);

// Keep keyboard focus inside this picker's card when VersionList and LanguagePanel remount.
// Scope via ref so an outer host popover with the same data-slot is never selected.
// Standalone Content has no PopoverContent — skip. Language search owns its own focus.
// Never move focus while closed or closing: the exiting panel would steal outside focus.
useEffect(() => {
const previous = previousViewRef.current;
previousViewRef.current = view;
if (!isPopoverOpen) return;
if (previous === view) return;
if (view === 'languageSearch') return;
if (previous === 'languageSearch' && view === 'languages') return;

const frame = window.requestAnimationFrame(() => {
const scope = popoverContentRef.current;
if (!scope) return;
if (!scope || scope.dataset.state !== 'open') return;
if (view === 'languages' && previous === 'versions') {
scope.querySelector<HTMLElement>('[data-slot="language-back"]')?.focus();
return;
Expand All @@ -618,7 +656,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {})
}
});
return () => window.cancelAnimationFrame(frame);
}, [view]);
}, [isPopoverOpen, view]);

if (onVersionPickerPress && open === undefined && !onRequestClose) {
return null;
Expand Down Expand Up @@ -661,6 +699,13 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {})
headerChild={view === 'languages' ? <LanguageSearchButton /> : null}
theme={background}
side={side}
onOpenAutoFocus={(event) => {
event.preventDefault();
// SAFETY: Radix invokes this handler with the popover content as currentTarget.
const panel = event.currentTarget as HTMLDivElement;
popoverContentRef.current = panel;
schedulePanelFocus(panel);
}}
>
{view === 'versions' ? (
<VersionList onRequestClose={() => setIsPopoverOpen(false)} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ export function VersionList({
</section>
{headerOwnsChrome ? <LanguageEntry name={selectedLanguageName} /> : null}
</div>
<div className="yv:flex-1 yv:overflow-y-auto yv:py-2">
{/* Firefox makes scroll containers a Tab stop; the version rows are the keyboard targets. */}
<div className="yv:flex-1 yv:overflow-y-auto yv:py-2" tabIndex={-1}>
Comment thread
abharms marked this conversation as resolved.
Resolved
{filteredRecentVersions.length > 0 && (
<>
<h2 className="yv:px-4 yv:py-2 yv:text-xl yv:font-bold yv:leading-6">
Expand Down
Loading