From ff8a71031efd68537ec7d9e47eb8f4ca203b31d8 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 12:08:10 -0600 Subject: [PATCH 1/8] test(ui): align shadow journeys with picker v2 --- .../ui/src/components/bible-card.stories.tsx | 7 +- .../src/components/bible-reader.stories.tsx | 5 +- ...ersion-picker.shadow-isolation.stories.tsx | 72 ++++++++++++++----- 3 files changed, 60 insertions(+), 24 deletions(-) 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..39d18ed7 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,31 @@ export const TopLayerEscapesClippingAndPreservesSemantics: Story = { }, }; -export const ForwardTabMovesFromCloseToFirstVersion: Story = { +export const ForwardTabFollowsPickerV2Order: 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)); + 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 +424,7 @@ 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 closeButton = within(panel).getByRole('button', { name: /close/i }); const openingPanelWindow = panel.ownerDocument.defaultView; if (!openingPanelWindow) throw new Error('version picker window not available'); @@ -436,7 +443,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(closeButton); }); await expect(canvasElement.ownerDocument.scrollingElement?.scrollTop ?? 0).toBe( initialScrollTop, @@ -546,25 +553,34 @@ 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 userEvent.click(within(panel).getByRole('button', { name: /^back$/i })); + const languageTabsBeforeHostileStyle = await waitForElement( + panel, + '[data-slot="tabs-list"]', + 'language tabs did not return', + ); + await expect(languageTabsBeforeHostileStyle.offsetWidth).toBe(languageTabsInlineSize); const hostileStyle = ownerDocument.createElement('style'); hostileStyle.textContent = ` @@ -616,14 +632,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(languageTabsBeforeHostileStyle.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(); From bf42d1f41405cbcba2c17280d0b7983e46744225 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 12:27:59 -0600 Subject: [PATCH 2/8] fix(ui): skip version list viewport in tab order --- .changeset/calm-firefox-focus.md | 5 +++++ .../ui/src/components/bible-version-picker/version-list.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-firefox-focus.md diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md new file mode 100644 index 00000000..c0950715 --- /dev/null +++ b/.changeset/calm-firefox-focus.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep Firefox keyboard traversal moving directly from the Bible version picker controls to the first version row. 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..f4a45f03 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,7 @@ export function VersionList({ {headerOwnsChrome ? : null} -
+
{filteredRecentVersions.length > 0 && ( <>

From 2285bbb57b64c5cf54b20868e44fcc7657721e57 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 12:50:37 -0600 Subject: [PATCH 3/8] fix(ui): preserve focus while picker closes --- .changeset/calm-firefox-focus.md | 2 +- .../bible-version-picker.shadow-isolation.stories.tsx | 5 +++++ packages/ui/src/components/bible-version-picker/index.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md index c0950715..aa7d2d69 100644 --- a/.changeset/calm-firefox-focus.md +++ b/.changeset/calm-firefox-focus.md @@ -2,4 +2,4 @@ '@youversion/platform-react-ui': patch --- -Keep Firefox keyboard traversal moving directly from the Bible version picker controls to the first version row. +Keep Bible version picker focus on the intended target during Firefox keyboard traversal and outside-click dismissal. 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 39d18ed7..93089a01 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 @@ -473,12 +473,17 @@ export const StylingFocusDismissalAndRapidReopen: Story = { hostileStyle.remove(); } + await userEvent.click(within(panel).getByRole('button', { name: /^language /i })); + await expect(within(panel).getByRole('heading', { name: /bible languages/i })).toBeVisible(); + 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 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); diff --git a/packages/ui/src/components/bible-version-picker/index.tsx b/packages/ui/src/components/bible-version-picker/index.tsx index 607fc43e..9fd40677 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, @@ -602,13 +603,14 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) 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 +620,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) } }); return () => window.cancelAnimationFrame(frame); - }, [view]); + }, [isPopoverOpen, view]); if (onVersionPickerPress && open === undefined && !onRequestClose) { return null; From 11faa69216897df51d3c5fb12bf956ff3fce0f48 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 13:18:28 -0600 Subject: [PATCH 4/8] test(ui): cover search dismissal focus --- .../bible-version-picker.shadow-isolation.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 93089a01..bd4020e8 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 @@ -475,6 +475,8 @@ export const StylingFocusDismissalAndRapidReopen: Story = { 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 userEvent.click(outsideControl); await waitFor(async () => { From d79228e0b182545ceab726e397203fd2c6410107 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 16:04:07 -0600 Subject: [PATCH 5/8] test(ui): tighten picker v2 focus journey evidence Assert the version list viewport overflows before checking Tab order, cover outside dismissal from the Languages screen, restore a pill-shape check for the language search input, and narrow the changeset wording. Co-Authored-By: Claude Opus 5.5 --- .changeset/calm-firefox-focus.md | 2 +- ...ersion-picker.shadow-isolation.stories.tsx | 64 ++++++++++++------- .../components/bible-version-picker/index.tsx | 1 + 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md index aa7d2d69..3f902ed0 100644 --- a/.changeset/calm-firefox-focus.md +++ b/.changeset/calm-firefox-focus.md @@ -2,4 +2,4 @@ '@youversion/platform-react-ui': patch --- -Keep Bible version picker focus on the intended target during Firefox keyboard traversal and outside-click dismissal. +Skip the Bible version picker's version list scroll area in keyboard Tab order, and keep focus on the outside target when dismissing the picker from a language screen. 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 bd4020e8..5949e702 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 @@ -368,6 +368,9 @@ export const ForwardTabFollowsPickerV2Order: Story = { '[data-testid="version-list"] button', 'version row not rendered', ); + 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}' @@ -473,34 +476,48 @@ export const StylingFocusDismissalAndRapidReopen: Story = { hostileStyle.remove(); } + 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(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); - 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 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 languagesPanel = await reopenPicker(); + await userEvent.click(within(languagesPanel).getByRole('button', { name: /^language /i })); + await expect( + within(languagesPanel).getByRole('heading', { name: /bible languages/i }), + ).toBeVisible(); + await dismissOutsideKeepsOutsideFocus(languagesPanel); - 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); - }); + await reopenPicker(); const focused = root.activeElement; if (!(focused instanceof HTMLElement)) throw new Error('picker focus not available'); @@ -581,6 +598,9 @@ export const ProviderDirectionRejectsHostVisualValues: Story = { 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 languageTabsBeforeHostileStyle = await waitForElement( panel, diff --git a/packages/ui/src/components/bible-version-picker/index.tsx b/packages/ui/src/components/bible-version-picker/index.tsx index 9fd40677..c77af982 100644 --- a/packages/ui/src/components/bible-version-picker/index.tsx +++ b/packages/ui/src/components/bible-version-picker/index.tsx @@ -600,6 +600,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) // 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; From 594f490fd40bee649fd63a034074f9a20ac384c7 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 16:14:54 -0600 Subject: [PATCH 6/8] test(ui): name picker journeys after their claims Co-Authored-By: Claude Opus 5.5 --- .../bible-version-picker.shadow-isolation.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 5949e702..fceefb1e 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,7 +356,7 @@ export const TopLayerEscapesClippingAndPreservesSemantics: Story = { }, }; -export const ForwardTabFollowsPickerV2Order: Story = { +export const ForwardTabSkipsScrollableVersionList: Story = { play: async ({ canvasElement }) => { const { userEvent: browserUserEvent } = await import('vitest/browser'); const { root, panel } = await openPicker(canvasElement); @@ -602,12 +602,12 @@ export const ProviderDirectionRejectsHostVisualValues: Story = { baselineLanguageInputGroup.offsetHeight / 2, ); await userEvent.click(within(panel).getByRole('button', { name: /^back$/i })); - const languageTabsBeforeHostileStyle = await waitForElement( + const languageTabsAfterSearchRoundTrip = await waitForElement( panel, '[data-slot="tabs-list"]', 'language tabs did not return', ); - await expect(languageTabsBeforeHostileStyle.offsetWidth).toBe(languageTabsInlineSize); + await expect(languageTabsAfterSearchRoundTrip.offsetWidth).toBe(languageTabsInlineSize); const hostileStyle = ownerDocument.createElement('style'); hostileStyle.textContent = ` @@ -659,7 +659,7 @@ 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(languageTabsBeforeHostileStyle.offsetWidth).toBe(languageTabsInlineSize); + await expect(languageTabsAfterSearchRoundTrip.offsetWidth).toBe(languageTabsInlineSize); await userEvent.click(within(panel).getByRole('button', { name: /search languages/i })); const languageInputGroup = await waitForElement( From 9b107f0dd8028af4386cdd912086de8a19134e09 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 3 Nov 2026 16:20:40 -0600 Subject: [PATCH 7/8] test(ui): wait for reopened picker before checking languages heading Co-Authored-By: Claude Opus 5.5 --- .../bible-version-picker.shadow-isolation.stories.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 fceefb1e..eb658937 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 @@ -512,9 +512,11 @@ export const StylingFocusDismissalAndRapidReopen: Story = { const languagesPanel = await reopenPicker(); await userEvent.click(within(languagesPanel).getByRole('button', { name: /^language /i })); - await expect( - within(languagesPanel).getByRole('heading', { name: /bible languages/i }), - ).toBeVisible(); + await waitFor(async () => { + await expect( + within(languagesPanel).getByRole('heading', { name: /bible languages/i }), + ).toBeVisible(); + }); await dismissOutsideKeepsOutsideFocus(languagesPanel); await reopenPicker(); From 80117ed36f3276e5b1bfcc7302a871dadbadb430 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 4 Nov 2026 09:01:48 -0600 Subject: [PATCH 8/8] fix(ui): normalize picker reopen focus --- .changeset/calm-firefox-focus.md | 2 +- ...ersion-picker.shadow-isolation.stories.tsx | 3 +- .../components/bible-version-picker/index.tsx | 42 +++++++++++++++++++ .../bible-version-picker/version-list.tsx | 1 + 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.changeset/calm-firefox-focus.md b/.changeset/calm-firefox-focus.md index 3f902ed0..a2257444 100644 --- a/.changeset/calm-firefox-focus.md +++ b/.changeset/calm-firefox-focus.md @@ -2,4 +2,4 @@ '@youversion/platform-react-ui': patch --- -Skip the Bible version picker's version list scroll area in keyboard Tab order, and keep focus on the outside target when dismissing the picker from a language screen. +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-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx index eb658937..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 @@ -427,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 closeButton = within(panel).getByRole('button', { name: /close/i }); const openingPanelWindow = panel.ownerDocument.defaultView; if (!openingPanelWindow) throw new Error('version picker window not available'); @@ -446,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(closeButton); + await expect(root.activeElement).toBe(panel); }); await expect(canvasElement.ownerDocument.scrollingElement?.scrollTop ?? 0).toBe( initialScrollTop, diff --git a/packages/ui/src/components/bible-version-picker/index.tsx b/packages/ui/src/components/bible-version-picker/index.tsx index c77af982..d6f6f59a 100644 --- a/packages/ui/src/components/bible-version-picker/index.tsx +++ b/packages/ui/src/components/bible-version-picker/index.tsx @@ -596,6 +596,41 @@ 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. @@ -664,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 f4a45f03..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,6 +249,7 @@ export function VersionList({ {headerOwnsChrome ? : null}

+ {/* Firefox makes scroll containers a Tab stop; the version rows are the keyboard targets. */}
{filteredRecentVersions.length > 0 && ( <>