diff --git a/.changeset/define-shadow-overlay-ownership.md b/.changeset/define-shadow-overlay-ownership.md new file mode 100644 index 00000000..adbc3d21 --- /dev/null +++ b/.changeset/define-shadow-overlay-ownership.md @@ -0,0 +1,4 @@ +--- +--- + +Document the nested and concurrent Shadow DOM overlay ownership contract without releasing package changes. diff --git a/docs/adr/0006-prototype-shadow-dom-style-isolation.md b/docs/adr/0006-prototype-shadow-dom-style-isolation.md index 76835e91..d642fbf6 100644 --- a/docs/adr/0006-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0006-prototype-shadow-dom-style-isolation.md @@ -77,9 +77,10 @@ accepts that host registrations can collide with SDK family names. It also cannot protect a component host from constraints applied to its ancestors. Open roots are a CSS boundary, not a security boundary. -Concurrent independent overlays in one shadow root and nested overlays launched -from an open dialog are unsupported until the package defines stacking, focus -ownership, and dismissal contracts. Radix's development-only relationship +[ADR 0007](0007-shadow-overlay-ownership-stack.md) selects an ownership +contract for concurrent and nested overlays. The current prototype does not +implement that contract, so those cases remain unsupported until the layer +registry and browser evidence land. 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 the root. diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md new file mode 100644 index 00000000..2d430df7 --- /dev/null +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -0,0 +1,94 @@ +# ADR 0007: Model shadow-root overlays as an ownership stack + +Status: Proposed (contract and executable proof selected by YPE-5355; production +runtime support not implemented) + +Nested and concurrent overlays inside one component shadow root will share one +shadow-local top-layer container and register with a root-owned LIFO overlay +stack. An overlay registration identifies its stable instance, modal or +non-modal kind, logical launch parent when nested, optional focus restoration +target, and mounted phase. The launch parent is the overlay whose interaction +opened the new overlay; it does not require DOM or React ancestry. A focus +restoration target may be absent for a default-open overlay, virtual anchor, or +launch without a focusable control. This keeps stacking, focus, dismissal, +inertness, and restoration behind the `ShadowRootHost` interface instead of +making each Dialog or Popover coordinate with its siblings. + +The topmost eligible mounted overlay owns interaction, focus, Escape, and +outside-click dismissal. With no modal mounted, every overlay is eligible. With +a modal mounted, eligibility is limited to the topmost modal and its descendant +chain; a later unrelated non-modal overlay remains mounted but non-interactive +until that modal unmounts. If the owner is not dismissible, dismissal attempts +do not fall through to a lower overlay. A nested overlay remains inside its +ancestor modal's focus scope. While a modal is mounted, Tab and Shift+Tab loop +within the current owner and programmatic focus outside it redirects back to the +owner. Shadow content stays inert through the modal's exit animation. An +ancestor close may start its own and its descendants' exit phases together, but +every descendant must unmount before the ancestor; a new child cannot mount +beneath an exiting parent. Repeated exit requests are idempotent and do not +extend an existing descendant's teardown deadline. Reopening a stable overlay +ID during exit cancels that exit and refreshes its registration, +including its focus restoration target, parent, kind, and dismissal policy. +Focus restoration starts only when the current owner unmounts. The ownership +model returns the eligible opener followed by the remaining top eligible layer, +or the outer opener when the last modal leaves. The DOM adapter attempts those +candidates in order and advances when a candidate is absent, disconnected, or +does not accept focus. Removing a lower layer does not steal focus from its +owner. Descendant unmounts during an ancestor-close cascade suppress focus +restoration; the ancestor's final unmount performs the single restore. Parent +updates must remain acyclic: an overlay cannot register under itself or one of +its descendants. Refreshing a stable ID moves its existing subtree to the top +so every parent remains before its children in registration order. + +## Scenario classification and proof + +The ownership contract supports these scenarios in the committed executable +proof: + +- a non-modal popover opening a modal dialog, including the verse action + popover opening the highlights permission dialog during the highlight auth + flow; +- a modal dialog containing a non-modal popover; +- two independent non-modal overlays; and +- closing and reopening the same modal during its exit animation. + +`shadow-overlay-ownership.ts` is the deterministic state model for the +contract. Its unit tests cover the four scenarios plus modal-scope exclusion, +ancestor/descendant exit ordering, dismissal blocking during exit, and +focus-restoration candidate ordering. The `Shadow overlay ownership` integration +story exercises the four required scenarios in Chromium inside one real shadow +root and one native top-layer container. It verifies LIFO DOM ordering, focus +and dismissal ownership, modal inertness through exit, rejected-candidate +fallback, restoration, and stable identity during a rapid reopen. + +These scenarios remain unsupported by production SDK Dialog and Popover +callers; the proof module is internal and deliberately not connected to those +primitives. See Consequences below for what production integration requires. + +## Considered options + +- A shared container without ownership was rejected because independent focus + traps can compete and unrelated overlays can escape modal inertness. +- Permanently forbidding nested and concurrent overlays was rejected because + composite SDK surfaces such as `BibleReader` legitimately coordinate several + dialogs and popovers. +- One native top-layer container per overlay was rejected because ownership, + ordering, and restoration would be distributed across callers rather than + localized at the shadow-root seam. + +## Consequences + +The current production active-ID sets prove lazy portal lifetime but do not +implement this contract. The executable state model reduces implementation +ambiguity but is not a production registry. Production support requires wiring +the model's rules into `ShadowRootHost`, descendant close ordering, topmost +interaction gating, modal-aware focus containment, and direct browser evidence +through the actual overlay primitives. The production adapter must register the +logical launch parent even when overlays are DOM or React siblings. It must omit +a focus restoration target unless that target belongs to the launch parent's +active focus scope, attempt the model's restoration candidates in order, and +advance whenever the browser rejects focus. It must also make shadow-specific +focus containment follow the current owner while other layers yield. Until that +work lands, nested or concurrent production overlays in one shadow root remain +unsupported. The contract may still be revised in response to cross-browser or +assistive-technology findings. diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index c393e630..3223fde1 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -21,8 +21,9 @@ This is a working plan, not approval for package-wide rollout. Shadow DOM boundaries. - The internal `SignInDialog` is validated only through an opt-in `ShadowRootHost` story. -- Concurrent independent overlays in one shadow root and nested overlays - launched from an open dialog are unsupported. +- Concurrent and nested overlays in one shadow root remain unsupported. + [ADR 0007](adr/0007-shadow-overlay-ownership-stack.md) selects the contract + to implement them; see "Blocking production-readiness decisions" below. ## Validation matrix @@ -36,8 +37,9 @@ This is a working plan, not approval for package-wide rollout. | 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. | Validated for one modal | Define ownership before supporting nested or competing overlays. | +| Dialog modal lifetime | Coverage verifies inert background content while open and through staggered Content and Overlay exit animations. | Validated for one modal | Implement ADR 0007 before supporting nested or competing overlays. | | 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. | +| Nested and concurrent ownership | A committed state model and unit suite exercise both nesting directions, concurrent siblings, modal-scope exclusion, ancestor-close cascading, exit-animation lifetime, rapid reopen, logical launch parentage, and absent or disconnected focus restoration fallback. A Chromium integration story proves both nesting directions, two independent overlays, and rapid close/reopen inside one shadow root and native top-layer container. | Contract proved; production runtime unsupported | Integrate the registry with `ShadowRootHost`, then repeat the browser proof through shared primitives and every inventoried direct overlay consumer. | ## Direct overlay inventory @@ -59,8 +61,9 @@ separate decision. or package-wide. - Define SSR, hydration, and first-paint behavior. The current effect-attached root renders an empty host on the server and delays content and forwarded refs. -- Define stacking, focus ownership, and dismissal contracts for nested or - competing modal and non-modal overlays. +- Implement and browser-validate ADR 0007's root-owned overlay stack before + enabling isolation on a component that can launch nested or concurrent + overlays. - Complete the package-wide custom-property inventory and prevention guard in YPE-5400. The known `BibleVersionPicker`, `InputGroup`, and `tw-animate-css` dependencies now resolve through locally-defined SDK-owned spacing and radius @@ -98,7 +101,8 @@ separate decision. ## Rollout sequence 1. Complete YPE-5400's custom-property inventory and prevention guard. -2. Resolve SSR/hydration, rollout-control, and overlay-ownership decisions. +2. Resolve SSR/hydration and rollout-control decisions, then implement and + browser-validate the selected overlay-ownership contract. 3. Select the next public component and add component-specific compatibility, browser, and accessibility coverage before enabling isolation. 4. Publish consumer guidance for DOM queries, automation, customization, forms, diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx new file mode 100644 index 00000000..4a69f680 --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -0,0 +1,601 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { http, HttpResponse } from 'msw'; +import { createPortal } from 'react-dom'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { expect, userEvent, waitFor } from 'storybook/test'; +import { tabbable } from 'tabbable'; +import { requireShadowRoot } from '../test/dom-stubs'; +import { + ShadowOverlayOwnership, + type ShadowOverlayFocusPlan, + type ShadowOverlayKind, +} from './shadow-overlay-ownership'; +import { + getOwnShadowRoot, + isElementFromOwnerDocument, + ShadowRootHost, +} from './shadow-root-host'; + +const EXIT_DURATION_MS = 150; + +function OwnershipProof(): React.ReactNode { + const markerRef = useRef(null); + const backgroundRef = useRef(null); + const rapidOpenerRef = useRef(null); + const ownershipRef = useRef(new ShadowOverlayOwnership()); + const exitTokensRef = useRef(new Map()); + const pendingFocusPlanRef = useRef([]); + const [topLayer, setTopLayer] = useState(null); + const [reopenRejected, setReopenRejected] = useState(false); + const [revision, setRevision] = useState(0); + const snapshot = ownershipRef.current.snapshot(); + + const refresh = useCallback(() => setRevision((value) => value + 1), []); + + const mount = useCallback( + ( + id: string, + kind: ShadowOverlayKind, + opener: HTMLElement, + parentId?: string, + ): void => { + ownershipRef.current.mount({ id, kind, opener, parentId }); + exitTokensRef.current.set(id, (exitTokensRef.current.get(id) ?? 0) + 1); + refresh(); + }, + [refresh], + ); + + const finishExit = useCallback( + (id: string, token: number): void => { + if (exitTokensRef.current.get(id) !== token) return; + const layer = ownershipRef.current.snapshot().layers.find((candidate) => candidate.id === id); + if (layer?.phase !== 'exiting') return; + + pendingFocusPlanRef.current = ownershipRef.current.unmount(id); + refresh(); + }, + [refresh], + ); + + const beginExit = useCallback( + (id: string): void => { + const exitingIds = ownershipRef.current.beginExit(id); + refresh(); + for (const exitingId of exitingIds) { + const token = (exitTokensRef.current.get(exitingId) ?? 0) + 1; + exitTokensRef.current.set(exitingId, token); + window.setTimeout(() => finishExit(exitingId, token), EXIT_DURATION_MS); + } + }, + [finishExit, refresh], + ); + + const dismissOwner = useCallback((): void => { + const ownerId = ownershipRef.current.requestDismiss(); + if (ownerId) beginExit(ownerId); + }, [beginExit]); + + useLayoutEffect(() => { + const marker = markerRef.current; + const root = marker ? getOwnShadowRoot(marker) : null; + if (!marker || !root) return; + + const container = marker.ownerDocument.createElement('div'); + container.setAttribute('data-testid', 'ownership-top-layer'); + container.setAttribute('popover', 'manual'); + Object.assign(container.style, { + background: 'transparent', + border: '0', + height: '100dvh', + inset: '0', + margin: '0', + maxHeight: 'none', + maxWidth: 'none', + padding: '0', + pointerEvents: 'none', + width: '100dvw', + }); + root.append(container); + setTopLayer(container); + return () => { + if (container.matches(':popover-open')) container.hidePopover(); + container.remove(); + }; + }, []); + + useLayoutEffect(() => { + if (!topLayer) return; + backgroundRef.current!.inert = snapshot.backgroundInert; + if (snapshot.layers.length > 0 && !topLayer.matches(':popover-open')) topLayer.showPopover(); + if (snapshot.layers.length === 0 && topLayer.matches(':popover-open')) topLayer.hidePopover(); + + const pendingFocusPlan = pendingFocusPlanRef.current; + pendingFocusPlanRef.current = []; + for (const candidate of pendingFocusPlan) { + const target = + candidate.kind === 'element' + ? candidate.element + : topLayer.querySelector(`[data-overlay-id="${candidate.id}"]`); + target?.focus(); + if (target?.matches(':focus')) return; + } + + const owner = topLayer.querySelector( + snapshot.ownerId ? `[data-overlay-id="${snapshot.ownerId}"]` : '[data-missing-owner]', + ); + if (owner && snapshot.layers.find((layer) => layer.id === snapshot.ownerId)?.phase === 'active') { + owner.focus(); + } + }, [revision, snapshot.backgroundInert, snapshot.layers, snapshot.ownerId, topLayer]); + + useLayoutEffect(() => { + if (!topLayer || !snapshot.modalOwnerId || !snapshot.ownerId) return; + const owner = topLayer.querySelector( + `[data-overlay-id="${snapshot.ownerId}"]`, + ); + if (!owner) return; + + const handleFocusIn = (event: FocusEvent): void => { + const [realTarget] = event.composedPath(); + if (!isElementFromOwnerDocument(realTarget, owner, 'Element')) return; + if (!owner.contains(realTarget)) owner.focus(); + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Tab' || event.defaultPrevented) return; + const candidates = tabbable(owner); + if (candidates.length === 0) { + event.preventDefault(); + owner.focus(); + return; + } + + const [realTarget] = event.composedPath(); + const currentIndex = candidates.findIndex((candidate) => candidate === realTarget); + let nextIndex = 0; + if (event.shiftKey) { + nextIndex = currentIndex <= 0 ? candidates.length - 1 : currentIndex - 1; + } else if (currentIndex !== -1 && currentIndex !== candidates.length - 1) { + nextIndex = currentIndex + 1; + } + + event.preventDefault(); + candidates[nextIndex]?.focus(); + }; + + const ownerDocument = owner.ownerDocument; + ownerDocument.addEventListener('focusin', handleFocusIn); + owner.addEventListener('keydown', handleKeyDown); + return () => { + ownerDocument.removeEventListener('focusin', handleFocusIn); + owner.removeEventListener('keydown', handleKeyDown); + }; + }, [snapshot.modalOwnerId, snapshot.ownerId, topLayer]); + + useEffect(() => { + const ownerDocument = topLayer?.ownerDocument; + if (!ownerDocument) return; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if (ownershipRef.current.snapshot().ownerId === null) return; + event.preventDefault(); + dismissOwner(); + }; + ownerDocument.addEventListener('keydown', handleKeyDown); + return () => ownerDocument.removeEventListener('keydown', handleKeyDown); + }, [dismissOwner, topLayer]); + + return ( + <> +
+
+ + + + + +
+ {topLayer + ? createPortal( + <> + + {snapshot.layers.map((layer, index) => ( +
+

{layer.id}

+ {layer.id === 'popover-parent' ? ( + + ) : null} + {layer.id === 'dialog-parent' ? ( + + ) : null} + {layer.id === 'rapid-dialog' ? ( + + ) : null} + {layer.id === 'dialog-child-popover' ? ( + + ) : null} + +
+ ))} + , + topLayer, + ) + : null} + + ); +} + +function OwnershipProofStory(): React.ReactNode { + return ( + <> + + + + + + ); +} + +const meta = { + title: 'Spikes/Shadow overlay ownership', + component: OwnershipProofStory, + tags: ['integration'], + parameters: { + msw: { + handlers: [ + http.get('*/v1/fonts/1/stylesheet', () => + HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }), + ), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function requireElement( + root: ParentNode, + selector: string, +): ElementType { + const element = root.querySelector(selector); + if (!element) throw new Error(`Missing proof element: ${selector}`); + return element; +} + +async function expectOwner(root: ShadowRoot, id: string): Promise { + return waitFor(() => { + const owner = requireElement(root, '[data-owner]'); + void expect(owner).toHaveAttribute('data-overlay-id', id); + return owner; + }); +} + +async function expectUnmounted(root: ShadowRoot, id: string): Promise { + await waitFor(() => + void expect(root.querySelector(`[data-overlay-id="${id}"]`)).toBeNull(), + ); +} + +async function expectActiveElement(root: ShadowRoot, element: Element): Promise { + await waitFor(() => void expect(root.activeElement).toBe(element)); +} + +async function expectFocusedOwner(root: ShadowRoot, id: string): Promise { + const owner = await expectOwner(root, id); + await expectActiveElement(root, owner); + return owner; +} + +export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { + play: async ({ canvasElement }) => { + const hostPageControl = await waitFor(() => + requireElement(canvasElement, '[data-testid="host-page-control"]'), + ); + const root = await waitFor(() => requireShadowRoot(canvasElement)); + const topLayer = await waitFor(() => + requireElement(root, '[data-testid="ownership-top-layer"]'), + ); + const outsideLayer = requireElement(topLayer, '[data-testid="outside-layer"]'); + void expect(outsideLayer.tabIndex).toBe(-1); + const background = requireElement(root, '[data-testid="proof-background"]'); + const unownedEscape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + canvasElement.ownerDocument.dispatchEvent(unownedEscape); + void expect(unownedEscape.defaultPrevented).toBe(false); + + const popoverParentOpener = requireElement( + root, + '[data-testid="open-popover-parent"]', + ); + await userEvent.click(popoverParentOpener); + const popoverParent = await expectFocusedOwner(root, 'popover-parent'); + await userEvent.click( + requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), + ); + await expectFocusedOwner(root, 'popover-child-dialog'); + void expect(background.inert).toBe(true); + void expect(popoverParent.inert).toBe(true); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'popover-child-dialog'); + await expectOwner(root, 'popover-parent'); + await expectActiveElement( + root, + requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), + ); + void expect(background.inert).toBe(false); + await userEvent.click(outsideLayer); + await expectUnmounted(root, 'popover-parent'); + await expectActiveElement(root, popoverParentOpener); + + const dialogParentOpener = requireElement( + root, + '[data-testid="open-dialog-parent"]', + ); + await userEvent.click(dialogParentOpener); + const dialogParent = await expectFocusedOwner(root, 'dialog-parent'); + const dialogOpensPopover = requireElement( + dialogParent, + '[data-testid="dialog-opens-popover"]', + ); + const closeDialogParent = requireElement( + dialogParent, + '[data-testid="close-dialog-parent"]', + ); + closeDialogParent.focus(); + await userEvent.keyboard('{Tab}'); + await expectActiveElement(root, dialogOpensPopover); + dialogOpensPopover.focus(); + await userEvent.keyboard('{Shift>}{Tab}{/Shift}'); + await expectActiveElement(root, closeDialogParent); + hostPageControl.focus(); + await expectActiveElement(root, dialogParent); + await userEvent.click(dialogOpensPopover); + await expectFocusedOwner(root, 'dialog-child-popover'); + void expect(background.inert).toBe(true); + void expect(dialogParent.inert).toBe(true); + await userEvent.click(outsideLayer); + await expectUnmounted(root, 'dialog-child-popover'); + await expectOwner(root, 'dialog-parent'); + await expectActiveElement( + root, + requireElement(dialogParent, '[data-testid="dialog-opens-popover"]'), + ); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'dialog-parent'); + await expectActiveElement(root, dialogParentOpener); + + await userEvent.click(dialogParentOpener); + const cascadingDialogParent = await expectFocusedOwner(root, 'dialog-parent'); + await userEvent.click( + requireElement( + cascadingDialogParent, + '[data-testid="dialog-opens-popover"]', + ), + ); + const cascadingPopover = await expectFocusedOwner(root, 'dialog-child-popover'); + const rejectedReopen = requireElement( + cascadingPopover, + '[data-testid="reject-reopen-under-exiting-parent"]', + ); + await userEvent.click(rejectedReopen); + void expect(rejectedReopen).toHaveAttribute('data-reopen-rejected'); + void expect(cascadingPopover).toHaveAttribute('data-phase', 'exiting'); + void expect(cascadingDialogParent).toHaveAttribute('data-phase', 'exiting'); + await expectUnmounted(root, 'dialog-child-popover'); + await expectUnmounted(root, 'dialog-parent'); + await expectActiveElement(root, dialogParentOpener); + + const firstOpener = requireElement(root, '[data-testid="open-first"]'); + const secondOpener = requireElement(root, '[data-testid="open-second"]'); + await userEvent.click(firstOpener); + const secondOpenerBounds = secondOpener.getBoundingClientRect(); + void expect( + root.elementFromPoint( + secondOpenerBounds.left + secondOpenerBounds.width / 2, + secondOpenerBounds.top + secondOpenerBounds.height / 2, + ), + ).toBe(secondOpener); + await userEvent.click(secondOpener); + const second = await expectFocusedOwner(root, 'second'); + const first = requireElement(topLayer, '[data-overlay-id="first"]'); + void expect(first.inert).toBe(true); + void expect(second.inert).toBe(false); + void expect( + Array.from(topLayer.querySelectorAll('[data-overlay-id]'), (element) => + element.getAttribute('data-overlay-id'), + ), + ).toEqual(['first', 'second']); + await userEvent.click(outsideLayer); + await expectUnmounted(root, 'second'); + await expectOwner(root, 'first'); + void expect(first.inert).toBe(false); + await expectActiveElement(root, secondOpener); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'first'); + await expectActiveElement(root, firstOpener); + + await userEvent.click(firstOpener); + await userEvent.click(secondOpener); + const fallbackFirst = requireElement(topLayer, '[data-overlay-id="first"]'); + secondOpener.disabled = true; + await userEvent.click(outsideLayer); + await expectUnmounted(root, 'second'); + await expectFocusedOwner(root, 'first'); + void expect(fallbackFirst.inert).toBe(false); + secondOpener.disabled = false; + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'first'); + await expectActiveElement(root, firstOpener); + + await userEvent.click(firstOpener); + await userEvent.click(secondOpener); + secondOpener.remove(); + await userEvent.click(outsideLayer); + await expectUnmounted(root, 'second'); + await expectFocusedOwner(root, 'first'); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'first'); + await expectActiveElement(root, firstOpener); + + const rapidOpener = requireElement( + root, + '[data-testid="open-rapid-dialog"]', + ); + await userEvent.click(rapidOpener); + const rapidDialog = await expectFocusedOwner(root, 'rapid-dialog'); + await userEvent.click( + requireElement(rapidDialog, '[data-testid="close-rapid-dialog"]'), + ); + void expect(rapidDialog).toHaveAttribute('data-phase', 'exiting'); + void expect(background.inert).toBe(true); + const exitingEscape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + canvasElement.ownerDocument.dispatchEvent(exitingEscape); + void expect(exitingEscape.defaultPrevented).toBe(true); + await userEvent.click( + requireElement(rapidDialog, '[data-testid="reopen-during-exit"]'), + ); + await waitFor(() => void expect(rapidDialog).toHaveAttribute('data-phase', 'active')); + await new Promise((resolve) => window.setTimeout(resolve, EXIT_DURATION_MS + 50)); + await expectOwner(root, 'rapid-dialog'); + void expect(topLayer.matches(':popover-open')).toBe(true); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'rapid-dialog'); + void expect(background.inert).toBe(false); + await expectActiveElement(root, rapidOpener); + await waitFor(() => void expect(topLayer.matches(':popover-open')).toBe(false)); + }, +}; diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts new file mode 100644 index 00000000..9194d34d --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from 'vitest'; +import { ShadowOverlayOwnership } from './shadow-overlay-ownership'; + +function button(label: string): HTMLButtonElement { + const element = document.createElement('button'); + element.textContent = label; + document.body.append(element); + return element; +} + +describe('ShadowOverlayOwnership', () => { + it('coordinates the verse action popover opening the highlights permission dialog', () => { + document.body.replaceChildren(); + const dialogOpener = button('Choose highlight color'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'verse-action-popover', kind: 'nonmodal', opener: null }); + ownership.mount({ + id: 'highlights-permission-dialog', + kind: 'modal', + opener: dialogOpener, + parentId: 'verse-action-popover', + }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'highlights-permission-dialog', + modalOwnerId: 'highlights-permission-dialog', + backgroundInert: true, + layers: [ + { id: 'verse-action-popover', eligible: false, phase: 'active' }, + { + id: 'highlights-permission-dialog', + parentId: 'verse-action-popover', + eligible: true, + phase: 'active', + }, + ], + }); + expect(ownership.requestDismiss()).toBe('highlights-permission-dialog'); + + ownership.beginExit('highlights-permission-dialog'); + expect(ownership.snapshot().backgroundInert).toBe(true); + expect(ownership.requestDismiss()).toBeNull(); + expect(ownership.unmount('highlights-permission-dialog')).toEqual([ + { kind: 'element', element: dialogOpener }, + { kind: 'layer', id: 'verse-action-popover' }, + ]); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'verse-action-popover', + modalOwnerId: null, + backgroundInert: false, + }); + + ownership.beginExit('verse-action-popover'); + expect(ownership.unmount('verse-action-popover')).toEqual([]); + }); + + it('coordinates a dialog containing a popover', () => { + document.body.replaceChildren(); + const pageOpener = button('Open dialog'); + const popoverOpener = button('Open popover'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'dialog', kind: 'modal', opener: pageOpener }); + ownership.mount({ + id: 'popover', + kind: 'nonmodal', + opener: popoverOpener, + parentId: 'dialog', + }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'popover', + modalOwnerId: 'dialog', + backgroundInert: true, + layers: [ + { id: 'dialog', eligible: true }, + { id: 'popover', eligible: true }, + ], + }); + expect(ownership.requestDismiss()).toBe('popover'); + + ownership.beginExit('popover'); + expect(ownership.unmount('popover')).toEqual([ + { kind: 'element', element: popoverOpener }, + { kind: 'layer', id: 'dialog' }, + ]); + expect(ownership.snapshot()).toMatchObject({ ownerId: 'dialog', backgroundInert: true }); + + ownership.beginExit('dialog'); + expect(ownership.unmount('dialog')).toEqual([{ kind: 'element', element: pageOpener }]); + expect(ownership.snapshot().backgroundInert).toBe(false); + }); + + it('coordinates two independent overlays in LIFO order', () => { + document.body.replaceChildren(); + const firstOpener = button('Open first'); + const secondOpener = button('Open second'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'first', kind: 'nonmodal', opener: firstOpener }); + ownership.mount({ id: 'second', kind: 'nonmodal', opener: secondOpener }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'second', + modalOwnerId: null, + backgroundInert: false, + }); + expect(ownership.requestDismiss()).toBe('second'); + + ownership.beginExit('second'); + expect(ownership.unmount('second')).toEqual([ + { kind: 'element', element: secondOpener }, + { kind: 'layer', id: 'first' }, + ]); + expect(ownership.snapshot().ownerId).toBe('first'); + + ownership.beginExit('first'); + expect(ownership.unmount('first')).toEqual([{ kind: 'element', element: firstOpener }]); + expect(ownership.snapshot().ownerId).toBeNull(); + }); + + it('reopens the same overlay during its exit without duplicating ownership', () => { + document.body.replaceChildren(); + const originalOpener = button('Open dialog'); + const reopenedOpener = button('Reopen dialog'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'dialog', kind: 'modal', opener: originalOpener }); + ownership.beginExit('dialog'); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + backgroundInert: true, + layers: [{ id: 'dialog', phase: 'exiting' }], + }); + + ownership.mount({ + id: 'dialog', + kind: 'modal', + opener: reopenedOpener, + dismissible: false, + }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + backgroundInert: true, + layers: [{ id: 'dialog', phase: 'active' }], + }); + expect(ownership.requestDismiss()).toBeNull(); + ownership.beginExit('dialog'); + expect(ownership.unmount('dialog')).toEqual([ + { kind: 'element', element: reopenedOpener }, + ]); + }); + + it('keeps an unrelated nonmodal overlay outside the active modal scope', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + const dialogOpener = button('Open dialog'); + ownership.mount({ id: 'dialog', kind: 'modal', opener: dialogOpener }); + ownership.mount({ id: 'unrelated', kind: 'nonmodal', opener: button('Open unrelated') }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + layers: [ + { id: 'dialog', eligible: true }, + { id: 'unrelated', eligible: false }, + ], + }); + + ownership.beginExit('dialog'); + expect(ownership.unmount('dialog')).toEqual([ + { kind: 'element', element: dialogOpener }, + { kind: 'layer', id: 'unrelated' }, + ]); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'unrelated', + modalOwnerId: null, + backgroundInert: false, + layers: [{ id: 'unrelated', eligible: true }], + }); + }); + + it('plans the newly eligible owner instead of an out-of-scope nested opener', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'parent', kind: 'nonmodal', opener: button('Open parent') }); + ownership.mount({ + id: 'dialog', + kind: 'modal', + opener: button('Open dialog'), + parentId: 'parent', + }); + ownership.mount({ id: 'unrelated', kind: 'nonmodal', opener: button('Open unrelated') }); + + ownership.beginExit('dialog'); + expect(ownership.unmount('dialog')).toEqual([{ kind: 'layer', id: 'unrelated' }]); + }); + + it('blocks dismissal fallthrough from a nondismissible owner', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: button('Open first') }); + ownership.mount({ + id: 'second', + kind: 'nonmodal', + opener: button('Open second'), + dismissible: false, + }); + + expect(ownership.snapshot().ownerId).toBe('second'); + expect(ownership.requestDismiss()).toBeNull(); + expect(ownership.snapshot().layers).toHaveLength(2); + }); + + it('does not restore focus when a non-owner unmounts', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: button('Open first') }); + ownership.mount({ id: 'second', kind: 'nonmodal', opener: button('Open second') }); + + expect(ownership.unmount('first')).toEqual([]); + expect(ownership.snapshot().ownerId).toBe('second'); + }); + + it('rejects cyclic parent updates', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + const dialogOpener = button('Open dialog'); + ownership.mount({ id: 'dialog', kind: 'modal', opener: dialogOpener }); + ownership.mount({ + id: 'popover', + kind: 'nonmodal', + opener: button('Open popover'), + parentId: 'dialog', + }); + + expect(() => + ownership.mount({ + id: 'dialog', + kind: 'modal', + opener: dialogOpener, + parentId: 'dialog', + }), + ).toThrow('Cannot mount overlay "dialog" under itself'); + expect(() => + ownership.mount({ + id: 'dialog', + kind: 'modal', + opener: dialogOpener, + parentId: 'popover', + }), + ).toThrow('Cannot mount overlay "dialog" under descendant "popover"'); + }); + + it('preserves parent-before-child order when reparenting a stable ID', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + const firstOpener = button('Open first'); + const secondOpener = button('Open second'); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: firstOpener }); + ownership.mount({ id: 'second', kind: 'nonmodal', opener: secondOpener }); + + ownership.mount({ + id: 'first', + kind: 'nonmodal', + opener: firstOpener, + parentId: 'second', + }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'first', + layers: [{ id: 'second' }, { id: 'first', parentId: 'second' }], + }); + expect(ownership.beginExit('second')).toEqual(['first', 'second']); + expect(ownership.unmount('first')).toEqual([]); + expect(ownership.unmount('second')).toEqual([ + { kind: 'element', element: secondOpener }, + ]); + }); + + it('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + const dialogOpener = button('Open dialog'); + ownership.mount({ id: 'dialog', kind: 'modal', opener: dialogOpener }); + ownership.mount({ + id: 'popover', + kind: 'nonmodal', + opener: button('Open popover'), + parentId: 'dialog', + }); + + expect(ownership.beginExit('dialog')).toEqual(['popover', 'dialog']); + expect(ownership.beginExit('popover')).toEqual([]); + expect(ownership.requestDismiss()).toBeNull(); + expect(() => + ownership.mount({ + id: 'late-child', + kind: 'nonmodal', + opener: button('Open late child'), + parentId: 'dialog', + }), + ).toThrow('Cannot mount overlay "late-child" under exiting parent "dialog"'); + expect(() => ownership.unmount('dialog')).toThrow( + 'Cannot unmount overlay "dialog" before descendant "popover"', + ); + expect(ownership.unmount('popover')).toEqual([]); + expect(ownership.snapshot().backgroundInert).toBe(true); + expect(ownership.unmount('dialog')).toEqual([ + { kind: 'element', element: dialogOpener }, + ]); + expect(ownership.snapshot().backgroundInert).toBe(false); + }); + + it('preserves candidate order when an opener disconnects', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: button('Open first') }); + const disconnectedOpener = button('Open second'); + ownership.mount({ id: 'second', kind: 'nonmodal', opener: disconnectedOpener }); + disconnectedOpener.remove(); + + ownership.beginExit('second'); + expect(ownership.unmount('second')).toEqual([ + { kind: 'element', element: disconnectedOpener }, + { kind: 'layer', id: 'first' }, + ]); + }); + + it('preserves fallback order without predicting whether an opener can take focus', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: button('Open first') }); + const opener = document.createElement('div'); + document.body.append(opener); + ownership.mount({ id: 'second', kind: 'nonmodal', opener }); + + ownership.beginExit('second'); + expect(ownership.unmount('second')).toEqual([ + { kind: 'element', element: opener }, + { kind: 'layer', id: 'first' }, + ]); + }); + + it('falls back to the remaining owner when no focus restoration target exists', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'first', kind: 'nonmodal', opener: button('Open first') }); + ownership.mount({ id: 'default-open', kind: 'nonmodal', opener: null }); + + ownership.beginExit('default-open'); + expect(ownership.unmount('default-open')).toEqual([{ kind: 'layer', id: 'first' }]); + }); +}); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts new file mode 100644 index 00000000..3903e6cc --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -0,0 +1,207 @@ +/** @internal */ +export type ShadowOverlayKind = 'modal' | 'nonmodal'; +/** @internal */ +export type ShadowOverlayPhase = 'active' | 'exiting'; + +/** @internal */ +export interface ShadowOverlayRegistration { + id: string; + kind: ShadowOverlayKind; + /** Focus restoration target captured at launch, or null when no target exists. */ + opener: HTMLElement | null; + /** Logical overlay that launched this overlay, independent of DOM or React ancestry. */ + parentId?: string; + dismissible?: boolean; +} + +/** @internal */ +export type ShadowOverlayFocusTarget = + | { kind: 'element'; element: HTMLElement } + | { kind: 'layer'; id: string }; +/** @internal Ordered candidates for the DOM adapter to try until one accepts focus. */ +export type ShadowOverlayFocusPlan = ShadowOverlayFocusTarget[]; + +/** @internal */ +export interface ShadowOverlaySnapshot { + ownerId: string | null; + modalOwnerId: string | null; + backgroundInert: boolean; + layers: Array<{ + id: string; + kind: ShadowOverlayKind; + parentId: string | null; + phase: ShadowOverlayPhase; + eligible: boolean; + }>; +} + +interface ShadowOverlayLayer extends ShadowOverlayRegistration { + dismissible: boolean; + phase: ShadowOverlayPhase; +} + +/** @internal Executable state model for ADR 0007's shadow-overlay ownership contract. */ +export class ShadowOverlayOwnership { + readonly #layers: ShadowOverlayLayer[] = []; + + mount(registration: ShadowOverlayRegistration): void { + const existing = this.#layers.find((layer) => layer.id === registration.id); + const parent = + registration.parentId === undefined + ? undefined + : this.#layers.find((layer) => layer.id === registration.parentId); + if (registration.parentId !== undefined && !parent) { + throw new Error( + `Cannot mount overlay "${registration.id}" with missing parent "${registration.parentId}"`, + ); + } + if (existing && parent === existing) { + throw new Error(`Cannot mount overlay "${registration.id}" under itself`); + } + if (existing && parent && this.#hasAncestor(parent, existing.id)) { + throw new Error( + `Cannot mount overlay "${registration.id}" under descendant "${registration.parentId}"`, + ); + } + if (parent?.phase === 'exiting') { + throw new Error( + `Cannot mount overlay "${registration.id}" under exiting parent "${registration.parentId}"`, + ); + } + + if (existing) { + Object.assign(existing, registration, { + dismissible: registration.dismissible ?? true, + parentId: registration.parentId, + phase: 'active' satisfies ShadowOverlayPhase, + }); + this.#moveSubtreeToTop(existing.id); + return; + } + + this.#layers.push({ + ...registration, + dismissible: registration.dismissible ?? true, + phase: 'active', + }); + } + + beginExit(id: string): string[] { + this.#requireLayer(id); + const exitingLayers = this.#layers + .filter( + (layer) => + layer.phase === 'active' && + (layer.id === id || this.#hasAncestor(layer, id)), + ) + .reverse(); + + for (const layer of exitingLayers) layer.phase = 'exiting'; + + return exitingLayers.map((layer) => layer.id); + } + + requestDismiss(): string | null { + const owner = this.#owner(); + if (!owner || owner.phase === 'exiting' || !owner.dismissible) return null; + return owner.id; + } + + unmount(id: string): ShadowOverlayFocusPlan { + const layer = this.#requireLayer(id); + const wasOwner = this.#owner()?.id === id; + const descendant = this.#layers.find((candidate) => this.#hasAncestor(candidate, id)); + if (descendant) { + throw new Error(`Cannot unmount overlay "${id}" before descendant "${descendant.id}"`); + } + + const parentIsExiting = this.#layers.some( + (candidate) => candidate.id === layer.parentId && candidate.phase === 'exiting', + ); + this.#layers.splice(this.#layers.indexOf(layer), 1); + if (parentIsExiting || !wasOwner) return []; + + const { modalOwner, owner } = this.#computeOwnership(); + const parentOwnsFocus = layer.parentId !== undefined && owner?.id === layer.parentId; + const outerScopeIsActive = layer.parentId === undefined && modalOwner === null; + const plan: ShadowOverlayFocusPlan = []; + if (layer.opener && (owner === null || parentOwnsFocus || outerScopeIsActive)) { + plan.push({ kind: 'element', element: layer.opener }); + } + if (owner) plan.push({ kind: 'layer', id: owner.id }); + return plan; + } + + snapshot(): ShadowOverlaySnapshot { + const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); + + return { + ownerId: owner?.id ?? null, + modalOwnerId: modalOwner?.id ?? null, + backgroundInert: modalOwner !== null, + layers: this.#layers.map((layer) => ({ + id: layer.id, + kind: layer.kind, + parentId: layer.parentId ?? null, + phase: layer.phase, + eligible: eligibleIds.has(layer.id), + })), + }; + } + + #hasAncestor(layer: ShadowOverlayLayer, ancestorId: string): boolean { + let parentId = layer.parentId; + const visited = new Set(); + while (parentId !== undefined && !visited.has(parentId)) { + if (parentId === ancestorId) return true; + visited.add(parentId); + parentId = this.#layers.find((candidate) => candidate.id === parentId)?.parentId; + } + return false; + } + + #moveSubtreeToTop(id: string): void { + const subtree = this.#layers.filter( + (layer) => layer.id === id || this.#hasAncestor(layer, id), + ); + const subtreeIds = new Set(subtree.map((layer) => layer.id)); + const remainingLayers = this.#layers.filter((layer) => !subtreeIds.has(layer.id)); + this.#layers.splice(0, this.#layers.length, ...remainingLayers, ...subtree); + } + + /** Topmost modal, and topmost layer eligible under it, in one backward pass each. */ + #computeOwnership() { + const modalOwner = this.#findLastLayer((layer) => layer.kind === 'modal'); + const eligibleIds = new Set( + this.#layers + .filter( + (layer) => + modalOwner === null || + layer.id === modalOwner.id || + this.#hasAncestor(layer, modalOwner.id), + ) + .map((layer) => layer.id), + ); + const owner = this.#findLastLayer((layer) => eligibleIds.has(layer.id)); + return { modalOwner, owner, eligibleIds }; + } + + #findLastLayer(predicate: (layer: ShadowOverlayLayer) => boolean): ShadowOverlayLayer | null { + for (let index = this.#layers.length - 1; index >= 0; index--) { + const layer = this.#layers[index]; + if (!layer) continue; + if (predicate(layer)) return layer; + } + return null; + } + + #owner(): ShadowOverlayLayer | null { + return this.#computeOwnership().owner; + } + + #requireLayer(id: string): ShadowOverlayLayer { + const layer = this.#layers.find((candidate) => candidate.id === id); + if (!layer) throw new Error(`Unknown overlay "${id}"`); + return layer; + } +}