diff --git a/.changeset/quiet-dialogs-return.md b/.changeset/quiet-dialogs-return.md new file mode 100644 index 00000000..3e8a0f49 --- /dev/null +++ b/.changeset/quiet-dialogs-return.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Fix focus restoration for opt-in Shadow DOM dialogs by preserving the original opener across a close/reopen during exit, while ignoring disconnected targets and targets moved out of their captured root. diff --git a/docs/adr/0007-prototype-shadow-dom-style-isolation.md b/docs/adr/0007-prototype-shadow-dom-style-isolation.md index a54cb786..ccec539d 100644 --- a/docs/adr/0007-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0007-prototype-shadow-dom-style-isolation.md @@ -142,20 +142,21 @@ roots are a CSS boundary, not a security boundary. ## Nested and concurrent overlays -YPE-5355 exercised nested and concurrent overlays through the production -`ShadowRootHost` seam. Both nesting directions preserve topmost-only Escape -dismissal and restore focus into the remaining parent overlay. Either +YPE-5355 exercised nested and concurrent overlays through the real shared +`ShadowRootHost` implementation. Both nesting directions preserve topmost-only +Escape dismissal and restore focus into the remaining parent overlay. Either nested-overlay teardown order also works with the current architecture. Separate component shadow roots own distinct portal containers, but do not isolate Radix outside interaction: opening a peer popover dismisses the existing peer across -the same or separate roots. A verse action popover does not restore final focus -after its nested dialog and then the popover close, and rapid dialog close/reopen -also loses final focus restoration. +the same or separate roots. Nested dialog-to-popover dismissal restores focus in +order, and final dismissal returns to the original outside control. A dialog +that closes and reopens during retained exit presence also preserves and restores +its original opener; disconnected targets and targets moved out of their captured +root (into the light DOM, another shadow root, or another document) are ignored. These observations do not select or design production overlay coordination. -YPE-5356 owns deciding whether and how to support concurrent peers and exact -final focus restoration. The detailed Chromium evidence and remaining -validation live in the rollout plan. +YPE-5356 owns deciding whether and how to support concurrent peers. The detailed +Chromium evidence and remaining validation live in the rollout plan. Radix's development-only relationship checks can also emit warnings for valid IDs inside a shadow root because those checks query the document rather than diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index d7409a6b..ec917bdf 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -11,7 +11,8 @@ This is a working plan, not approval for package-wide rollout. ## Current scope -- `YouVersionAuthButton` creates its Shadow DOM boundary automatically. +- `YouVersionAuthButton` is the only component automatically isolated by the + current prototype. - `BibleVersionPicker` validates shadow-local inline and native top-layer floating content through opt-in stories. - The shared Dialog and Popover primitives support opt-in shadow-local portals. @@ -22,19 +23,19 @@ This is a working plan, not approval for package-wide rollout. - The internal `SignInDialog` is validated only through an opt-in `ShadowRootHost` story. - Nested and concurrent overlays within and across component shadow roots were - exercised through the production `ShadowRootHost` seam (YPE-5355). The Shadow - DOM ADR records the architectural boundary; the results below record the - supported contract and peer-dismissal and final focus-restoration gaps. Runtime - changes remain YPE-5356. + exercised through the real shared `ShadowRootHost` implementation (YPE-5355). + The Shadow DOM ADR records the architectural boundary; the results below record + the supported contract and the remaining peer-dismissal gap. Broader component + rollout and peer-overlay coordination remain with YPE-5356. ## Nested and concurrent overlay evidence | Scenario | Result | Evidence and limitation | | --- | --- | --- | -| Verse action popover opens the highlights permission dialog | Partially supported | Both render in the shadow-local top layer. The dialog receives focus and the wrapper is inert. A first Escape closes only the dialog and restores focus inside the popover; a second closes the popover. Chromium spike observation found no final focus restoration. Automated evidence covers the stable dismissal, containment, and teardown contract without requiring that known gap to remain. | +| Verse action popover opens the highlights permission dialog | Supported | Both render in the shadow-local top layer. The dialog receives focus and the wrapper is inert. A first Escape closes only the dialog and restores focus inside the popover; a second closes the popover and restores the original outside control. | | Dialog contains a popover | Supported | The popover is interactive and receives focus. Escape closes it first and restores its trigger while the dialog remains modal. A second Escape closes the dialog and restores its opener. | | Two independent popovers in the same or separate roots | Unsupported as concurrent peers | Chromium spike observation found that opening a popover dismisses an existing peer through Radix outside interaction, including when the peers use separate component shadow roots. Automated evidence covers the stable contract that the new peer receives focus, remains non-modal, tears down cleanly, and that separate roots remain usable after primary teardown. | -| Rapid dialog close/reopen during exit | Unsupported | The first dialog remains connected in its closed state when it is reopened. The reopened dialog receives focus and the wrapper remains inert. Chromium spike observation found that final dismissal loses the opener; automated evidence requires safe modal release without locking in that focus loss. | +| Rapid dialog close/reopen during exit | Supported | The first dialog remains connected in its closed state when it is reopened. The reopened dialog receives focus and the wrapper remains inert. Final dismissal releases the modal state and restores the original opener. Disconnected targets and targets moved out of their captured root—including into the light DOM, another shadow root, or another document—are ignored. | Separate `ShadowRootHost` instances use different portal containers and tear those containers down independently. This provides lifecycle isolation, not @@ -55,12 +56,12 @@ open. | Portal lifecycle | Unit and browser coverage exercise lazy creation, exit-animation retention, cleanup, immediate reopen behavior, and the direct-Radix `VerseActionPopover` consumer. | Validated for shared primitives and the known bypass | Repeat the consumer audit when adding another direct overlay primitive. | | Dialog relationships | Chromium resolves title and description relationships inside the component tree. | Validated in Chromium | Verify announcements with real assistive technology. | | Dialog keyboard containment | Browser coverage exercises initial focus, programmatic escape redirection, forward and reverse traversal, radio-group collapsing, negative `tabindex`, and wraparound. | Validated in Chromium | Expand the browser and assistive-technology matrix. | -| Dialog modal lifetime | Coverage verifies inert background content while open and through staggered Content and Overlay exit animations. YPE-5355 also exercises both unmount orders for overlapping popover and dialog exits. | Validated for order-independent teardown | YPE-5356 owns peer concurrency across component roots and final focus-restoration gaps. Expand browser and assistive-technology coverage. | +| Dialog modal lifetime | Coverage verifies inert background content while open and through staggered Content and Overlay exit animations. YPE-5355 also exercises both unmount orders for overlapping popover and dialog exits. | Validated for order-independent teardown | YPE-5356 owns peer concurrency across component roots. Expand browser and assistive-technology coverage. | | Dialog dismissal and restoration | Coverage exercises Escape, backdrop click, full-viewport hit testing, overlay-only focus, and restoration after both modal nodes unmount. | Validated in Chromium | Verify real screen-reader and cross-browser behavior. | | Consumer form participation | Chromium coverage verifies that a light-DOM form does not own or serialize a native control inside an SDK shadow root. | Unsupported across tree scopes | Use an explicit component contract if a rollout target requires outer-form participation. | | Consumer labels and ARIA ID references | Chromium coverage verifies that external native labels, `aria-labelledby`, and `aria-describedby` relationships do not resolve to controls inside the root. | Unsupported across tree scopes | Keep relationships in one tree scope; verify real assistive technology separately. | | Consumer events, refs, and automation | Coverage verifies native retargeting, the auth button's React handler and forwarded ref, open-root queries, and effect-driven attachment timing. | Supported with documented constraints | Repeat for each public component selected for rollout. | -| Nested shadow roots | Coverage verifies basic rendering, recursive queries, and event retargeting at each boundary. | Supported for the validated basics | Nested and concurrent overlay ownership remains with YPE-5355. | +| Nested shadow roots | Coverage verifies basic rendering, recursive queries, and event retargeting at each boundary. | Supported for the validated basics | Peer-overlay coordination remains with YPE-5356; expand nested-overlay browser and assistive-technology validation during rollout. | | Realistic same-page usage | YPE-5437 mounts, removes, and re-adds a 12-component mix in Normal and Strict Mode. Chromium coverage verifies exact host counts, rendered scripture content, and one shared stylesheet object across roots and remounts. A production-build comparison found a small warm-run mount-cost difference on one machine. | No shared-host blocker found | Repeat user-visible performance and compatibility checks for each component selected for rollout. | ## Direct overlay inventory @@ -85,10 +86,9 @@ decision. YPE-5356 owns whether and how to implement that coordination. - Apply [ADR 0007's client-only SSR and hydration contract](adr/0007-prototype-shadow-dom-style-isolation.md#ssr-and-hydration-contract) per rollout component. YPE-5356 decides whether its first-paint, layout, and no-JavaScript limitations are acceptable for that component. -- Resolve the YPE-5355 peer-dismissal and final focus-restoration gaps before - shipping nested and concurrent overlays (YPE-5356). The decision must consider - trigger-time peer dismissal as well as overlay order and restore targets; - ADR 0007 records the gaps but does not select a coordination design. +- Resolve the YPE-5355 peer-dismissal gap before shipping concurrent peer + overlays (YPE-5356). The decision must consider trigger-time peer dismissal + and overlay order; ADR 0007 records the gap but does not select a coordination design. Cross-browser and assistive-technology coverage still remain. - Keep the YPE-5400 custom-property contract and compiled-stylesheet prevention guard green as component styles change. The audit below closes the known diff --git a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx index 446a955b..e185bc34 100644 --- a/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/shadow-overlay-production-seam.shadow-isolation.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { http, HttpResponse } from 'msw'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { expect, userEvent, waitFor } from 'storybook/test'; +import { expect, spyOn, userEvent, waitFor } from 'storybook/test'; import { ShadowRootHost } from '../lib/shadow-root-host'; import { requireShadowRoot } from '../test/dom-stubs'; import { HighlightPermissionDialog } from './highlight-permission-dialog'; @@ -53,7 +53,7 @@ function IsolatedProductionOverlaySeam({ rapidTimers.current.push( window.setTimeout(() => { setPermissionOpen(false); - rapidTimers.current.push(window.setTimeout(() => setPermissionOpen(true), 75)); + rapidTimers.current.push(window.setTimeout(() => setPermissionOpen(true), 650)); }, 300), ); }; @@ -369,7 +369,7 @@ function manualWorkflow( export const PopoverOpensDialog = manualWorkflow( 'Popover opens dialog — manual workflow', - 'Focus Prior control, click First verse, choose a highlight color, then press Escape twice. Focus returns inside the popover after the first Escape; after the second, confirm it does not return to Prior control.', + 'Focus Prior control, click First verse, choose a highlight color, then press Escape twice. Confirm focus returns inside the popover after the first Escape and to Prior control after the second.', ); export const DialogContainsPopover = manualWorkflow( 'Dialog contains popover — manual workflow', @@ -381,11 +381,11 @@ export const TwoIndependentOverlays = manualWorkflow( ); export const RapidCloseReopenDuringExit = manualWorkflow( 'Rapid close/reopen during exit — manual workflow', - 'Focus Run rapid close/reopen and press Enter once. The dialog opens, closes, and reopens during retained exit presence. Press Escape after it reopens; confirm the page unlocks but focus does not return to the Run button.', + 'Focus Run rapid close/reopen and press Enter once. The dialog opens, closes, and reopens during retained exit presence. Press Escape after it reopens; confirm the page unlocks and focus returns to the Run button.', { enableRapidReopen: true }, ); -/** YPE-5355 stable regression evidence; unsupported observations remain in the decision docs. */ +/** YPE-5355 regression evidence with YPE-5889 final-focus coverage. */ export const PopoverOpensDialogEvidence: Story = { tags: ['!dev'], play: async ({ canvasElement, step }) => { @@ -453,8 +453,7 @@ export const PopoverOpensDialogEvidence: Story = { await waitFor(() => { void expect(topLayer.querySelector('[data-slot="verse-action-popover"]')).toBeNull(); void expect(topLayer.matches(':popover-open')).toBe(false); - const focused = root.activeElement; - void expect(focused === null || !topLayer.contains(focused)).toBe(true); + void expect(root.activeElement).toBe(priorControl); }); }); @@ -701,7 +700,7 @@ export const TwoIndependentOverlaysEvidence: Story = { }, }; -/** YPE-5355 stable regression evidence; final-focus observations remain in the docs. */ +/** YPE-5355 lifecycle evidence with YPE-5889 final-focus coverage. */ export const RapidCloseReopenDuringExitEvidence: Story = { tags: ['!dev'], args: { enableRapidReopen: true }, @@ -742,8 +741,21 @@ export const RapidCloseReopenDuringExitEvidence: Story = { void expect(contentWrapper.inert).toBe(true); }); - await step('Reopen before the first dialog unmounts', async () => { - void expect(firstDialog.isConnected).toBe(true); + await step('Retain the overlay after the first dialog unmounts', async () => { + const retainedOverlay = await waitFor(() => { + void expect(firstDialog.isConnected).toBe(false); + const overlay = topLayer.querySelector('[data-slot="dialog-overlay"]'); + if (!overlay) throw new Error('dialog overlay did not remain during content exit'); + return overlay; + }); + await waitFor(() => { + void expect(root.activeElement).toBe(retainedOverlay); + void expect(contentWrapper.inert).toBe(true); + void expect(topLayer.matches(':popover-open')).toBe(true); + }); + }); + + await step('Reopen from overlay-only retained presence', async () => { const reopenedDialog = await waitFor(() => { const dialog = getPermissionDialog(topLayer); if (!dialog || dialog.getAttribute('data-state') === 'closed') { @@ -751,6 +763,7 @@ export const RapidCloseReopenDuringExitEvidence: Story = { } return dialog; }); + void expect(reopenedDialog).not.toBe(firstDialog); await waitFor(() => { const focused = root.activeElement; void expect(focused !== null && reopenedDialog.contains(focused)).toBe(true); @@ -765,9 +778,173 @@ export const RapidCloseReopenDuringExitEvidence: Story = { void expect(getPermissionDialog(topLayer)).toBeNull(); void expect(contentWrapper.inert).toBe(false); void expect(topLayer.matches(':popover-open')).toBe(false); - const focused = root.activeElement; - void expect(focused === null || !topLayer.contains(focused)).toBe(true); + void expect(root.activeElement).toBe(runRapidCloseReopen); + }); + }); + } finally { + exitAnimationStyle.remove(); + } + }, +}; + +export const RapidCloseReopenIgnoresInvalidOpenerEvidence: Story = { + tags: ['!dev'], + args: { enableRapidReopen: true }, + play: async ({ canvasElement, step }) => { + const { contentWrapper, root } = await getPrimaryHarness(canvasElement); + const runRapidCloseReopen = await waitForElement( + root, + '[data-testid="run-rapid-close-reopen"]', + 'rapid close/reopen control not rendered', + ); + const secondaryIsland = await waitForElement( + canvasElement, + '[data-testid="secondary-island"]', + 'secondary island not rendered', + ); + const secondaryRoot = await waitFor(() => requireShadowRoot(secondaryIsland)); + const exitAnimationStyle = installUnequalExitDurations(root, { + dialog: 400, + overlay: 800, + popover: 180, + }); + let topLayer!: HTMLElement; + + try { + await step('Reopen the dialog before its retained exit completes', async () => { + await userEvent.click(runRapidCloseReopen); + topLayer = await waitForElement( + root, + '[data-yv-shadow-local-overlay]', + 'shadow-local top layer not created', + ); + const firstDialog = await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog) throw new Error('permission dialog did not open'); + return dialog; + }); + await waitFor(() => void expect(firstDialog).toHaveAttribute('data-state', 'closed')); + await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog || dialog.getAttribute('data-state') === 'closed') { + throw new Error('permission dialog did not reopen during exit'); + } + void expect(contentWrapper.inert).toBe(true); + }); + }); + + await step('Ignore the opener after it moves to another shadow root', async () => { + secondaryRoot.append(runRapidCloseReopen); + void expect(runRapidCloseReopen.getRootNode()).toBe(secondaryRoot); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + void expect(getPermissionDialog(topLayer)).toBeNull(); + void expect(contentWrapper.inert).toBe(false); + void expect(topLayer.matches(':popover-open')).toBe(false); + }); + void expect(secondaryRoot.activeElement).not.toBe(runRapidCloseReopen); + }); + + await step('Ignore the opener after it moves into the light DOM', async () => { + contentWrapper.append(runRapidCloseReopen); + await userEvent.click(runRapidCloseReopen); + const firstDialog = await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog) throw new Error('permission dialog did not reopen for light-DOM target'); + return dialog; + }); + await waitFor(() => void expect(firstDialog).toHaveAttribute('data-state', 'closed')); + await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog || dialog.getAttribute('data-state') === 'closed') { + throw new Error('permission dialog did not rapidly reopen for light-DOM target'); + } + }); + + const focus = spyOn(runRapidCloseReopen, 'focus'); + try { + canvasElement.ownerDocument.body.append(runRapidCloseReopen); + void expect(runRapidCloseReopen.getRootNode()).toBe(canvasElement.ownerDocument); + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + void expect(getPermissionDialog(topLayer)).toBeNull(); + void expect(contentWrapper.inert).toBe(false); + void expect(topLayer.matches(':popover-open')).toBe(false); + }); + void expect(focus).not.toHaveBeenCalled(); + void expect(canvasElement.ownerDocument.activeElement).not.toBe(runRapidCloseReopen); + } finally { + focus.mockRestore(); + } + }); + + await step('Ignore the opener after it disconnects', async () => { + contentWrapper.append(runRapidCloseReopen); + await userEvent.click(runRapidCloseReopen); + const firstDialog = await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog) throw new Error('permission dialog did not reopen for disconnected target'); + return dialog; }); + await waitFor(() => void expect(firstDialog).toHaveAttribute('data-state', 'closed')); + await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog || dialog.getAttribute('data-state') === 'closed') { + throw new Error('permission dialog did not rapidly reopen for disconnected target'); + } + }); + + const focus = spyOn(runRapidCloseReopen, 'focus'); + try { + runRapidCloseReopen.remove(); + void expect(runRapidCloseReopen.isConnected).toBe(false); + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + void expect(getPermissionDialog(topLayer)).toBeNull(); + void expect(contentWrapper.inert).toBe(false); + void expect(topLayer.matches(':popover-open')).toBe(false); + }); + void expect(focus).not.toHaveBeenCalled(); + void expect(root.activeElement).not.toBe(runRapidCloseReopen); + } finally { + focus.mockRestore(); + } + }); + + await step('Ignore the opener after it moves to another document', async () => { + contentWrapper.append(runRapidCloseReopen); + await userEvent.click(runRapidCloseReopen); + const firstDialog = await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog) + throw new Error('permission dialog did not reopen for cross-document target'); + return dialog; + }); + await waitFor(() => void expect(firstDialog).toHaveAttribute('data-state', 'closed')); + await waitFor(() => { + const dialog = getPermissionDialog(topLayer); + if (!dialog || dialog.getAttribute('data-state') === 'closed') { + throw new Error('permission dialog did not rapidly reopen for cross-document target'); + } + }); + + const secondaryDocument = canvasElement.ownerDocument.implementation.createHTMLDocument(); + const focus = spyOn(runRapidCloseReopen, 'focus'); + try { + secondaryDocument.body.append(runRapidCloseReopen); + void expect(runRapidCloseReopen.isConnected).toBe(true); + void expect(runRapidCloseReopen.ownerDocument).toBe(secondaryDocument); + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + void expect(getPermissionDialog(topLayer)).toBeNull(); + void expect(contentWrapper.inert).toBe(false); + void expect(topLayer.matches(':popover-open')).toBe(false); + }); + void expect(focus).not.toHaveBeenCalled(); + } finally { + focus.mockRestore(); + } }); } finally { exitAnimationStyle.remove(); diff --git a/packages/ui/src/components/ui/dialog.test.tsx b/packages/ui/src/components/ui/dialog.test.tsx index a58b0826..a8adc52e 100644 --- a/packages/ui/src/components/ui/dialog.test.tsx +++ b/packages/ui/src/components/ui/dialog.test.tsx @@ -1,10 +1,173 @@ -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState, type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ShadowRootHost } from '@/lib/shadow-root-host'; import { requireShadowRoot } from '@/test/dom-stubs'; import { Dialog, DialogContent, DialogTitle } from './dialog'; +function PreventedCloseAutoFocusHarness(): ReactNode { + const [open, setOpen] = useState(false); + const [preventCloseAutoFocus, setPreventCloseAutoFocus] = useState(false); + + return ( + + + + + { + if (preventCloseAutoFocus) event.preventDefault(); + }} + > + Title + + + + + ); +} + +function InterruptedCloseHarness({ preventFinalRestore = false }): ReactNode { + const [open, setOpen] = useState(false); + const [preventCloseAutoFocus, setPreventCloseAutoFocus] = useState(false); + + return ( + + + + + { + if (preventCloseAutoFocus) event.preventDefault(); + }} + > + Title + + + + + ); +} + +function mockDialogAnimations() { + const getComputedStyle = window.getComputedStyle.bind(window); + return vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const styles = getComputedStyle(element); + if (!(element instanceof HTMLElement)) return styles; + + const htmlElement = element; + const animationOwner = + htmlElement.dataset.slot === 'dialog-overlay' + ? 'dialog-overlay' + : htmlElement.getAttribute('role') === 'dialog' + ? 'dialog-content' + : null; + if (!animationOwner) return styles; + + Object.defineProperty(styles, 'animationName', { + configurable: true, + get: () => `${animationOwner}-${htmlElement.dataset.state}`, + }); + return styles; + }); +} + +function finishDialogAnimation(element: HTMLElement): void { + const animationOwner = + element.dataset.slot === 'dialog-overlay' ? 'dialog-overlay' : 'dialog-content'; + const event = new Event('animationend', { bubbles: true }); + Object.defineProperty(event, 'animationName', { + value: `${animationOwner}-${element.dataset.state}`, + }); + fireEvent(element, event); +} + +async function runInterruptedClose( + preventFinalRestore = false, +): Promise<{ opener: HTMLButtonElement; shadowRoot: ShadowRoot }> { + const user = userEvent.setup(); + const { container } = render( + , + ); + const shadowRoot = requireShadowRoot(container); + const [opener, reopen] = Array.from(shadowRoot.querySelectorAll('button')); + + await user.click(opener!); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).not.toBeNull()); + await user.keyboard('{Escape}'); + + const firstContent = await waitFor(() => { + const element = shadowRoot.querySelector('[role="dialog"]'); + expect(element).toHaveAttribute('data-state', 'closed'); + return element!; + }); + finishDialogAnimation(firstContent); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).toBeNull()); + + const retainedOverlay = shadowRoot.querySelector('[data-slot="dialog-overlay"]'); + expect(retainedOverlay).not.toBeNull(); + await waitFor(() => expect(shadowRoot.activeElement).toBe(retainedOverlay)); + + fireEvent.click(reopen!); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).not.toBeNull()); + await user.keyboard('{Escape}'); + + const finalContent = await waitFor(() => { + const element = shadowRoot.querySelector('[role="dialog"]'); + expect(element).toHaveAttribute('data-state', 'closed'); + return element!; + }); + finishDialogAnimation(finalContent); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).toBeNull()); + + const finalOverlay = shadowRoot.querySelector('[data-slot="dialog-overlay"]'); + expect(finalOverlay).not.toBeNull(); + finishDialogAnimation(finalOverlay!); + await waitFor(() => expect(shadowRoot.querySelector('[data-slot="dialog-overlay"]')).toBeNull()); + + return { opener: opener!, shadowRoot }; +} + +async function finishInterruptedClose( + preventFinalRestore = false, +): Promise<{ opener: HTMLButtonElement; shadowRoot: ShadowRoot }> { + const computedStyleSpy = mockDialogAnimations(); + try { + return await runInterruptedClose(preventFinalRestore); + } finally { + computedStyleSpy.mockRestore(); + } +} + describe('Dialog shadow portal coordination', () => { it('preserves light-DOM state and ref behavior without changing overlay focusability', async () => { const cleanup = vi.fn(); @@ -109,4 +272,36 @@ describe('Dialog shadow portal coordination', () => { }); expect(wrapper?.inert).toBe(false); }); + + it('captures a new opener after a consumer prevents close autofocus', async () => { + const user = userEvent.setup(); + const { container } = render(); + const shadowRoot = requireShadowRoot(container); + const [firstOpener, secondOpener] = Array.from(shadowRoot.querySelectorAll('button')); + + await user.click(firstOpener!); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).not.toBeNull()); + await user.keyboard('{Escape}'); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).toBeNull()); + + await user.click(secondOpener!); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).not.toBeNull()); + await user.keyboard('{Escape}'); + await waitFor(() => expect(shadowRoot.querySelector('[role="dialog"]')).toBeNull()); + + expect(shadowRoot.activeElement).toBe(secondOpener); + }); + + it('restores the original opener after reopening during an overlay-only exit', async () => { + const { opener, shadowRoot } = await finishInterruptedClose(); + + expect(shadowRoot.activeElement).toBe(opener); + }); + + it('keeps deferred focus canceled when a reopened dialog prevents close autofocus', async () => { + const { opener, shadowRoot } = await finishInterruptedClose(true); + + expect(shadowRoot.activeElement).toBeNull(); + expect(opener).not.toHaveFocus(); + }); }); diff --git a/packages/ui/src/components/ui/use-shadow-dialog-focus.ts b/packages/ui/src/components/ui/use-shadow-dialog-focus.ts index 57d7711c..63d009a4 100644 --- a/packages/ui/src/components/ui/use-shadow-dialog-focus.ts +++ b/packages/ui/src/components/ui/use-shadow-dialog-focus.ts @@ -5,6 +5,7 @@ import { getOwnShadowRoot, isElementFromOwnerDocument, useShadowModalPresence, + type ShadowFocusRestoreSnapshot, } from '@/lib/shadow-root-host'; interface ShadowDialogFocusOptions { @@ -111,8 +112,8 @@ export function useShadowDialogFocus({ }: ShadowDialogFocusOptions): ShadowDialogFocus { const modalPresent = modal && (overlay !== null || content !== null); useShadowDialogFocusContainment(modalPresent, content, overlay); - const restoreFocusWhenModalReleased = useShadowModalPresence(modalPresent); - const restoreFocusRef = React.useRef(null); + const focusRestoration = useShadowModalPresence(modalPresent); + const restoreFocusRef = React.useRef(null); const capturedRestoreFocusRef = React.useRef(false); React.useLayoutEffect(() => { @@ -120,30 +121,43 @@ export function useShadowDialogFocus({ capturedRestoreFocusRef.current = false; return; } - if (!container || capturedRestoreFocusRef.current) return; + if (!container || capturedRestoreFocusRef.current || restoreFocusRef.current) return; + + const reclaimedRestoreFocus = focusRestoration?.reclaim(); + if (reclaimedRestoreFocus) { + restoreFocusRef.current = reclaimedRestoreFocus; + capturedRestoreFocusRef.current = true; + return; + } const shadowRoot = getOwnShadowRoot(container); const activeElement = shadowRoot ? (shadowRoot.activeElement ?? container.ownerDocument.activeElement) : container.ownerDocument.activeElement; if (isElementFromOwnerDocument(activeElement, container, 'HTMLElement')) { - restoreFocusRef.current = activeElement; + restoreFocusRef.current = { + capturedRoot: activeElement.getRootNode(), + target: activeElement, + }; } capturedRestoreFocusRef.current = true; - }, [container, open]); + }, [container, focusRestoration, open]); const onCloseAutoFocus = React.useCallback( (event: Event): void => { - if (event.defaultPrevented || container === undefined) return; + if (container === undefined) return; + if (event.defaultPrevented) { + restoreFocusRef.current = null; + focusRestoration?.cancel(); + return; + } event.preventDefault(); - const restoreFocusTo = restoreFocusRef.current; + const restoreFocus = restoreFocusRef.current; restoreFocusRef.current = null; - if (restoreFocusTo && restoreFocusWhenModalReleased) { - restoreFocusWhenModalReleased(restoreFocusTo); - } + if (restoreFocus && focusRestoration) focusRestoration.schedule(restoreFocus); }, - [container, restoreFocusWhenModalReleased], + [container, focusRestoration], ); return { onCloseAutoFocus }; diff --git a/packages/ui/src/lib/shadow-root-host.tsx b/packages/ui/src/lib/shadow-root-host.tsx index f50528ff..1571da2c 100644 --- a/packages/ui/src/lib/shadow-root-host.tsx +++ b/packages/ui/src/lib/shadow-root-host.tsx @@ -20,13 +20,27 @@ const SDK_SHADOW_STYLE_HREF = 'yv-sdk-shadow-styles'; const SDK_SHADOW_STYLE_PRECEDENCE = 'yv-sdk'; type ShadowPortalStrategy = 'local-inline' | 'local-top-layer'; +/** @internal Focus target retained across one shadow-modal lifecycle. */ +export interface ShadowFocusRestoreSnapshot { + capturedRoot: Node; + target: HTMLElement; +} + +interface ShadowModalFocusRestoration { + cancel: () => void; + reclaim: () => ShadowFocusRestoreSnapshot | null; + schedule: (snapshot: ShadowFocusRestoreSnapshot) => void; +} + interface ShadowPortalController { + cancelFocusRestore: (instanceId: string) => void; container: HTMLElement | null; getLastFocusedElement: () => HTMLElement | null; prepareOpen: (instanceId: string) => void; + reclaimFocusRestore: (instanceId: string) => ShadowFocusRestoreSnapshot | null; requestClose: (instanceId: string) => void; + scheduleFocusRestore: (instanceId: string, snapshot: ShadowFocusRestoreSnapshot) => void; setModalPresent: (instanceId: string, present: boolean) => void; - restoreFocusWhenModalReleased: (target: HTMLElement) => void; } const ShadowPortalContext = createContext(null); @@ -54,13 +68,14 @@ export function useShadowPortalTarget(open: boolean): HTMLElement | null | undef /** * @internal Keeps sibling shadow content inert while mounted modal UI is present - * and returns the host-owned focus restoration function. + * and returns focus restoration operations bound to that modal owner. */ -export function useShadowModalPresence( - present: boolean, -): ShadowPortalController['restoreFocusWhenModalReleased'] | undefined { +export function useShadowModalPresence(present: boolean): ShadowModalFocusRestoration | undefined { const controller = useContext(ShadowPortalContext); const instanceId = useId(); + const cancelFocusRestore = controller?.cancelFocusRestore; + const reclaimFocusRestore = controller?.reclaimFocusRestore; + const scheduleFocusRestore = controller?.scheduleFocusRestore; const setModalPresent = controller?.setModalPresent; useLayoutEffect(() => { @@ -70,7 +85,18 @@ export function useShadowModalPresence( return () => setModalPresent(instanceId, false); }, [instanceId, present, setModalPresent]); - return controller?.restoreFocusWhenModalReleased; + return useMemo( + () => + cancelFocusRestore && reclaimFocusRestore && scheduleFocusRestore + ? { + cancel: () => cancelFocusRestore(instanceId), + reclaim: () => reclaimFocusRestore(instanceId), + schedule: (snapshot: ShadowFocusRestoreSnapshot) => + scheduleFocusRestore(instanceId, snapshot), + } + : undefined, + [cancelFocusRestore, instanceId, reclaimFocusRestore, scheduleFocusRestore], + ); } /** @internal Returns the most recent focus target from the component's non-overlay content. */ @@ -147,7 +173,9 @@ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps const activePortalIdsRef = useRef(new Set()); const contentWrapperRef = useRef(null); const presentModalIdsRef = useRef(new Set()); - const pendingFocusTargetRef = useRef(null); + const pendingFocusTargetRef = useRef< + (ShadowFocusRestoreSnapshot & { ownerId: string }) | null + >(null); const lastFocusedElementRef = useRef(null); const [shadowRoot, setShadowRoot] = useState(null); const [portalContainer, setPortalContainer] = useState(null); @@ -228,6 +256,22 @@ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps [hideIfIdle], ); + const canRestoreFocus = useCallback( + (target: HTMLElement, capturedRoot: Node): boolean => { + const shadowRoot = shadowRootRef.current; + if (!shadowRoot || !target.isConnected || target.ownerDocument !== shadowRoot.ownerDocument) { + return false; + } + + const targetRoot = target.getRootNode(); + return ( + targetRoot === capturedRoot && + (targetRoot === shadowRoot || targetRoot === shadowRoot.ownerDocument) + ); + }, + [], + ); + const setModalPresent = useCallback( (instanceId: string, present: boolean): void => { const ids = presentModalIdsRef.current; @@ -238,20 +282,42 @@ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps if (wrapper) wrapper.inert = ids.size > 0; if (ids.size > 0) return; - const target = pendingFocusTargetRef.current; + const pendingTarget = pendingFocusTargetRef.current; pendingFocusTargetRef.current = null; - if (target?.isConnected) target.focus(); + if (pendingTarget && canRestoreFocus(pendingTarget.target, pendingTarget.capturedRoot)) { + pendingTarget.target.focus(); + } + }, + [canRestoreFocus], + ); + + const scheduleFocusRestore = useCallback( + (instanceId: string, snapshot: ShadowFocusRestoreSnapshot): void => { + if (presentModalIdsRef.current.size > 0) { + pendingFocusTargetRef.current = { ...snapshot, ownerId: instanceId }; + return; + } + + if (canRestoreFocus(snapshot.target, snapshot.capturedRoot)) snapshot.target.focus(); + }, + [canRestoreFocus], + ); + + const reclaimFocusRestore = useCallback( + (instanceId: string): ShadowFocusRestoreSnapshot | null => { + const pendingTarget = pendingFocusTargetRef.current; + if (!pendingTarget || pendingTarget.ownerId !== instanceId) return null; + + pendingFocusTargetRef.current = null; + return { capturedRoot: pendingTarget.capturedRoot, target: pendingTarget.target }; }, [], ); - const restoreFocusWhenModalReleased = useCallback((target: HTMLElement): void => { - if (presentModalIdsRef.current.size > 0) { - pendingFocusTargetRef.current = target; - return; + const cancelFocusRestore = useCallback((instanceId: string): void => { + if (pendingFocusTargetRef.current?.ownerId === instanceId) { + pendingFocusTargetRef.current = null; } - - if (target.isConnected) target.focus(); }, []); const getLastFocusedElement = useCallback( @@ -263,21 +329,25 @@ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps () => portalStrategy ? { + cancelFocusRestore, container: portalContainer, getLastFocusedElement, prepareOpen, + reclaimFocusRestore, requestClose, + scheduleFocusRestore, setModalPresent, - restoreFocusWhenModalReleased, } : null, [ + cancelFocusRestore, portalContainer, portalStrategy, getLastFocusedElement, prepareOpen, + reclaimFocusRestore, requestClose, - restoreFocusWhenModalReleased, + scheduleFocusRestore, setModalPresent, ], );