diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md new file mode 100644 index 00000000..a2257444 --- /dev/null +++ b/.changeset/calm-firefox-focus.md @@ -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. diff --git a/packages/ui/src/components/bible-card.stories.tsx b/packages/ui/src/components/bible-card.stories.tsx index cb800f78..d01aa363 100644 --- a/packages/ui/src/components/bible-card.stories.tsx +++ b/packages/ui/src/components/bible-card.stories.tsx @@ -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) diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 614d70ba..158af1eb 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -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 () => { diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx index dfaf7807..8c42a6ad 100644 --- a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -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( 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); }, @@ -417,7 +427,6 @@ export const StylingFocusDismissalAndRapidReopen: Story = { if (!topLayer) throw new Error('local top-layer container not created synchronously'); const panel = topLayer.querySelector('[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'); @@ -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, @@ -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((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( + 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( - 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'); @@ -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( panel, '[data-slot="tabs-list"]', 'language tabs did not render', ); const languageTabsContainer = languageTabs.parentElement; - const languageContent = languageTabs.closest('[data-yv-sdk]'); - const languageInputGroup = languageContent?.querySelector( - '[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( + 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( + panel, + '[data-slot="tabs-list"]', + 'language tabs did not return', + ); + await expect(languageTabsAfterSearchRoundTrip.offsetWidth).toBe(languageTabsInlineSize); const hostileStyle = ownerDocument.createElement('style'); hostileStyle.textContent = ` @@ -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( + 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( + 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( + 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(); diff --git a/packages/ui/src/components/bible-version-picker/index.tsx b/packages/ui/src/components/bible-version-picker/index.tsx index 607fc43e..d6f6f59a 100644 --- a/packages/ui/src/components/bible-version-picker/index.tsx +++ b/packages/ui/src/components/bible-version-picker/index.tsx @@ -588,6 +588,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) background, side, view, + isPopoverOpen, setIsPopoverOpen, closeLanguagePanel, closeLanguageSearch, @@ -595,20 +596,57 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) } = picker; const previousViewRef = useRef(view); const popoverContentRef = useRef(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('[data-slot="language-back"]')?.focus(); return; @@ -618,7 +656,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) } }); return () => window.cancelAnimationFrame(frame); - }, [view]); + }, [isPopoverOpen, view]); if (onVersionPickerPress && open === undefined && !onRequestClose) { return null; @@ -661,6 +699,13 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) headerChild={view === 'languages' ? : 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' ? ( setIsPopoverOpen(false)} /> diff --git a/packages/ui/src/components/bible-version-picker/version-list.tsx b/packages/ui/src/components/bible-version-picker/version-list.tsx index 8dd8d7af..c878a6b7 100644 --- a/packages/ui/src/components/bible-version-picker/version-list.tsx +++ b/packages/ui/src/components/bible-version-picker/version-list.tsx @@ -249,7 +249,8 @@ export function VersionList({ {headerOwnsChrome ? : null} -
+ {/* Firefox makes scroll containers a Tab stop; the version rows are the keyboard targets. */} +
{filteredRecentVersions.length > 0 && ( <>