From 39cfb7f5ee2dbea00a462285b3b306682ebe1432 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:24:16 -0500 Subject: [PATCH 01/23] docs(ui): define shadow overlay ownership (YPE-5355) --- ...06-prototype-shadow-dom-style-isolation.md | 12 +++--- .../0007-shadow-overlay-ownership-stack.md | 42 +++++++++++++++++++ docs/shadow-dom-isolation-plan.md | 16 ++++--- 3 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0007-shadow-overlay-ownership-stack.md diff --git a/docs/adr/0006-prototype-shadow-dom-style-isolation.md b/docs/adr/0006-prototype-shadow-dom-style-isolation.md index 76835e91..c73cce46 100644 --- a/docs/adr/0006-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0006-prototype-shadow-dom-style-isolation.md @@ -77,11 +77,13 @@ 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 -checks can also emit warnings for valid IDs inside a shadow root because those -checks query the document rather than the root. +[ADR 0007](0007-shadow-overlay-ownership-stack.md) selects a root-owned LIFO +stacking, focus, dismissal, inertness, and restoration 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. Only `YouVersionAuthButton` is automatically isolated by this prototype. `BibleVersionPicker` and other public exports do not gain automatic isolation 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..3018819f --- /dev/null +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -0,0 +1,42 @@ +# ADR 0007: Model shadow-root overlays as an ownership stack + +Status: Proposed (contract selected by YPE-5355; 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, parent overlay when nested, opener, and mounted phase. 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 mounted overlay owns interaction, focus, Escape, and outside-click +dismissal. A nested overlay remains inside its ancestor modal's focus scope; +lower concurrent overlays may remain mounted but are non-interactive. Shadow +content stays inert while any modal is mounted, including its exit animation. +Closing an ancestor closes its descendants first. Focus restores only after an +overlay unmounts: first to a connected opener in the remaining active scope, +otherwise to the remaining top layer, and finally to the outer opener after the +last modal leaves. A disconnected opener falls back rather than receiving +focus. + +## 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 active-ID sets prove lazy portal lifetime but do not implement this +contract. Production support requires a root-owned layer registry, descendant +close ordering, topmost interaction gating, modal-aware focus containment, and +direct browser evidence for nested and concurrent cases. Until that work lands, +nested or concurrent overlays in one shadow root remain unsupported and do not +block this decision from being revised by browser or assistive-technology +findings. diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 75862ce3..9bb8cd03 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -19,8 +19,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. +- [ADR 0007](adr/0007-shadow-overlay-ownership-stack.md) defines a root-owned + LIFO contract for concurrent and nested overlays. Runtime support remains + unimplemented, so those cases are still unsupported by the prototype. ## Validation matrix @@ -34,8 +35,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, and immediate reopen behavior. | Validated for shared primitives | Audit components that bypass the shared Popover wrapper. | | 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 pure state-model walkthrough exercises nested modal/popover ownership, concurrent siblings, ancestor-close cascading, exit-animation lifetime, and disconnected-opener fallback. | Contract selected in ADR 0007; runtime unsupported | Implement the layer registry and prove the contract through shared primitives in Chromium. | ## Blocking production-readiness decisions @@ -43,8 +45,9 @@ This is a working plan, not approval for package-wide rollout. 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 @@ -85,7 +88,8 @@ This is a working plan, not approval for package-wide rollout. 1. Complete YPE-5400's custom-property inventory and prevention guard, plus the direct-Radix-consumer audit. -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, From 4e825d3fccfa74ddf351ce74a0308b550cbe387c Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:28:20 -0500 Subject: [PATCH 02/23] docs(ui): simplify overlay ownership guidance --- .../0006-prototype-shadow-dom-style-isolation.md | 13 ++++++------- docs/adr/0007-shadow-overlay-ownership-stack.md | 7 +++---- docs/shadow-dom-isolation-plan.md | 6 +++--- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/adr/0006-prototype-shadow-dom-style-isolation.md b/docs/adr/0006-prototype-shadow-dom-style-isolation.md index c73cce46..d642fbf6 100644 --- a/docs/adr/0006-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0006-prototype-shadow-dom-style-isolation.md @@ -77,13 +77,12 @@ 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. -[ADR 0007](0007-shadow-overlay-ownership-stack.md) selects a root-owned LIFO -stacking, focus, dismissal, inertness, and restoration 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. +[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. Only `YouVersionAuthButton` is automatically isolated by this prototype. `BibleVersionPicker` and other public exports do not gain automatic isolation diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 3018819f..f076b085 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -15,10 +15,9 @@ dismissal. A nested overlay remains inside its ancestor modal's focus scope; lower concurrent overlays may remain mounted but are non-interactive. Shadow content stays inert while any modal is mounted, including its exit animation. Closing an ancestor closes its descendants first. Focus restores only after an -overlay unmounts: first to a connected opener in the remaining active scope, -otherwise to the remaining top layer, and finally to the outer opener after the -last modal leaves. A disconnected opener falls back rather than receiving -focus. +overlay unmounts, in this order: a connected opener in the remaining active +scope; otherwise the remaining top layer; otherwise the outer opener once the +last modal leaves. A disconnected opener is skipped in favor of the next tier. ## Considered options diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 9bb8cd03..ad6db741 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -19,9 +19,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. -- [ADR 0007](adr/0007-shadow-overlay-ownership-stack.md) defines a root-owned - LIFO contract for concurrent and nested overlays. Runtime support remains - unimplemented, so those cases are still unsupported by the prototype. +- 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 From fec6b1ad0281edf07cf157818b03c5ce293b5ac5 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:31:11 -0500 Subject: [PATCH 03/23] docs(ui): clarify overlay dismissal ordering --- docs/adr/0007-shadow-overlay-ownership-stack.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index f076b085..d3c9a2db 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -11,13 +11,16 @@ keeps stacking, focus, dismissal, inertness, and restoration behind the with its siblings. The topmost mounted overlay owns interaction, focus, Escape, and outside-click -dismissal. A nested overlay remains inside its ancestor modal's focus scope; -lower concurrent overlays may remain mounted but are non-interactive. Shadow -content stays inert while any modal is mounted, including its exit animation. -Closing an ancestor closes its descendants first. Focus restores only after an -overlay unmounts, in this order: a connected opener in the remaining active -scope; otherwise the remaining top layer; otherwise the outer opener once the -last modal leaves. A disconnected opener is skipped in favor of the next tier. +dismissal. If it is not dismissible, those dismissal attempts do not fall +through to a lower overlay. A nested overlay remains inside its ancestor modal's +focus scope; lower concurrent overlays may remain mounted but are +non-interactive. Shadow content stays inert while any modal is mounted, +including its exit animation. An ancestor close may start its own and its +descendants' exit phases together, but every descendant must unmount before the +ancestor. Focus restores only after an overlay unmounts, in this order: a +connected opener in the remaining active scope; otherwise the remaining top +layer; otherwise the outer opener once the last modal leaves. A disconnected +opener is skipped in favor of the next tier. ## Considered options From 0336397c2f18b3e311fa563f399ee02e3f5fcbcb Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:37:47 -0500 Subject: [PATCH 04/23] docs(ui): cover direct overlay consumers --- docs/shadow-dom-isolation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 434d1343..25c192d7 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -39,7 +39,7 @@ This is a working plan, not approval for package-wide rollout. | 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 | 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 pure state-model walkthrough exercises nested modal/popover ownership, concurrent siblings, ancestor-close cascading, exit-animation lifetime, and disconnected-opener fallback. | Contract selected in ADR 0007; runtime unsupported | Implement the layer registry and prove the contract through shared primitives in Chromium. | +| Nested and concurrent ownership | A pure state-model walkthrough exercises nested modal/popover ownership, concurrent siblings, ancestor-close cascading, exit-animation lifetime, and disconnected-opener fallback. | Contract selected in ADR 0007; runtime unsupported | Implement the layer registry and prove the contract through shared primitives and every inventoried direct overlay consumer in Chromium. | ## Direct overlay inventory From 9a9e3fc33c76fd5e8e3eebacf372384a2c32abcf Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:47:11 -0500 Subject: [PATCH 05/23] docs(ui): preserve active modal scope --- .../0007-shadow-overlay-ownership-stack.md | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index d3c9a2db..444a3808 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -10,17 +10,19 @@ keeps stacking, focus, dismissal, inertness, and restoration behind the `ShadowRootHost` interface instead of making each Dialog or Popover coordinate with its siblings. -The topmost mounted overlay owns interaction, focus, Escape, and outside-click -dismissal. If it is not dismissible, those dismissal attempts do not fall -through to a lower overlay. A nested overlay remains inside its ancestor modal's -focus scope; lower concurrent overlays may remain mounted but are -non-interactive. Shadow content stays inert while any modal is mounted, -including its exit animation. An ancestor close may start its own and its -descendants' exit phases together, but every descendant must unmount before the -ancestor. Focus restores only after an overlay unmounts, in this order: a +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. Shadow content stays inert while any modal is +mounted, including its exit animation. An ancestor close may start its own and +its descendants' exit phases together, but every descendant must unmount before +the ancestor. Focus restores only after an overlay unmounts, in this order: a connected opener in the remaining active scope; otherwise the remaining top -layer; otherwise the outer opener once the last modal leaves. A disconnected -opener is skipped in favor of the next tier. +eligible layer; otherwise the outer opener once the last modal leaves. A +disconnected opener is skipped in favor of the next tier. ## Considered options From 0c62006e8d3c4a08317320e8786b2c3617ab6109 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 11:52:58 -0500 Subject: [PATCH 06/23] chore: add empty changeset for YPE-5355 --- .changeset/define-shadow-overlay-ownership.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/define-shadow-overlay-ownership.md 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. From 6eca15c1fc71a5c358e8e3ff0a7e8a0ea26c5eec Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 12:28:25 -0500 Subject: [PATCH 07/23] feat(ui): prove shadow overlay ownership YPE-5355 --- .../0007-shadow-overlay-ownership-stack.md | 44 +- docs/shadow-dom-isolation-plan.md | 2 +- .../lib/shadow-overlay-ownership.stories.tsx | 404 ++++++++++++++++++ .../src/lib/shadow-overlay-ownership.test.ts | 180 ++++++++ .../ui/src/lib/shadow-overlay-ownership.ts | 153 +++++++ 5 files changed, 774 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/lib/shadow-overlay-ownership.stories.tsx create mode 100644 packages/ui/src/lib/shadow-overlay-ownership.test.ts create mode 100644 packages/ui/src/lib/shadow-overlay-ownership.ts diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 444a3808..7939a8d0 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -1,6 +1,7 @@ # ADR 0007: Model shadow-root overlays as an ownership stack -Status: Proposed (contract selected by YPE-5355; runtime support not implemented) +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 @@ -24,6 +25,31 @@ connected opener in the remaining active scope; otherwise the remaining top eligible layer; otherwise the outer opener once the last modal leaves. A disconnected opener is skipped in favor of the next tier. +## Scenario classification and proof + +The ownership contract supports these scenarios in the committed executable +proof: + +- a non-modal popover opening a modal dialog; +- 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 +disconnected-opener fallback. 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, 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 is deliberately not connected to +those primitives. Production support begins only after the root-owned registry +is integrated at the `ShadowRootHost` seam and the same browser evidence passes +through the shared primitives and every direct overlay consumer. + ## Considered options - A shared container without ownership was rejected because independent focus @@ -37,10 +63,12 @@ disconnected opener is skipped in favor of the next tier. ## Consequences -The current active-ID sets prove lazy portal lifetime but do not implement this -contract. Production support requires a root-owned layer registry, descendant -close ordering, topmost interaction gating, modal-aware focus containment, and -direct browser evidence for nested and concurrent cases. Until that work lands, -nested or concurrent overlays in one shadow root remain unsupported and do not -block this decision from being revised by browser or assistive-technology -findings. +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. 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 25c192d7..ed43857c 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -39,7 +39,7 @@ This is a working plan, not approval for package-wide rollout. | 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 | 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 pure state-model walkthrough exercises nested modal/popover ownership, concurrent siblings, ancestor-close cascading, exit-animation lifetime, and disconnected-opener fallback. | Contract selected in ADR 0007; runtime unsupported | Implement the layer registry and prove the contract through shared primitives and every inventoried direct overlay consumer in Chromium. | +| 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, and disconnected-opener 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 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..39f5ebda --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -0,0 +1,404 @@ +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 { requireShadowRoot } from '../test/dom-stubs'; +import { + ShadowOverlayOwnership, + type ShadowOverlayFocusTarget, + type ShadowOverlayKind, +} from './shadow-overlay-ownership'; +import { 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 pendingFocusTargetRef = useRef(null); + const [topLayer, setTopLayer] = useState(null); + 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 => { + exitTokensRef.current.set(id, (exitTokensRef.current.get(id) ?? 0) + 1); + ownershipRef.current.mount({ id, kind, opener, parentId }); + 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; + + const focusTarget = ownershipRef.current.unmount(id); + pendingFocusTargetRef.current = focusTarget; + 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?.getRootNode(); + if (!marker || !(root instanceof ShadowRoot)) 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', + 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 pendingFocusTarget = pendingFocusTargetRef.current; + pendingFocusTargetRef.current = null; + if (pendingFocusTarget?.kind === 'element') { + pendingFocusTarget.element.focus(); + return; + } + if (pendingFocusTarget?.kind === 'layer') { + topLayer + .querySelector(`[data-overlay-id="${pendingFocusTarget.id}"]`) + ?.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]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + dismissOwner(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [dismissOwner]); + + return ( + <> +
+
+ + + + + +
+ {topLayer + ? createPortal( + <> + + ) : null} + {layer.id === 'dialog-parent' ? ( + + ) : null} + {layer.id === 'rapid-dialog' ? ( + + ) : 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(), + ); +} + +export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { + play: async ({ canvasElement }) => { + const root = await waitFor(() => requireShadowRoot(canvasElement)); + const topLayer = await waitFor(() => + requireElement(root, '[data-testid="ownership-top-layer"]'), + ); + const background = requireElement(root, '[data-testid="proof-background"]'); + + const popoverParentOpener = requireElement( + root, + '[data-testid="open-popover-parent"]', + ); + await userEvent.click(popoverParentOpener); + const popoverParent = await expectOwner(root, 'popover-parent'); + await userEvent.click( + requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), + ); + await expectOwner(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 waitFor(() => + void expect(root.activeElement).toBe( + requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), + ), + ); + void expect(background.inert).toBe(false); + await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await expectUnmounted(root, 'popover-parent'); + await waitFor(() => void expect(root.activeElement).toBe(popoverParentOpener)); + + const dialogParentOpener = requireElement( + root, + '[data-testid="open-dialog-parent"]', + ); + await userEvent.click(dialogParentOpener); + const dialogParent = await expectOwner(root, 'dialog-parent'); + await userEvent.click( + requireElement(dialogParent, '[data-testid="dialog-opens-popover"]'), + ); + await expectOwner(root, 'dialog-child-popover'); + void expect(background.inert).toBe(true); + void expect(dialogParent.inert).toBe(false); + await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await expectUnmounted(root, 'dialog-child-popover'); + await expectOwner(root, 'dialog-parent'); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'dialog-parent'); + await waitFor(() => void expect(root.activeElement).toBe(dialogParentOpener)); + + const firstOpener = requireElement(root, '[data-testid="open-first"]'); + const secondOpener = requireElement(root, '[data-testid="open-second"]'); + await userEvent.click(firstOpener); + await userEvent.click(secondOpener); + await expectOwner(root, 'second'); + void expect( + Array.from(topLayer.querySelectorAll('[data-overlay-id]'), (element) => + element.getAttribute('data-overlay-id'), + ), + ).toEqual(['first', 'second']); + await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await expectUnmounted(root, 'second'); + await expectOwner(root, 'first'); + await waitFor(() => void expect(root.activeElement).toBe(secondOpener)); + await userEvent.keyboard('{Escape}'); + await expectUnmounted(root, 'first'); + await waitFor(() => void expect(root.activeElement).toBe(firstOpener)); + + const rapidOpener = requireElement( + root, + '[data-testid="open-rapid-dialog"]', + ); + await userEvent.click(rapidOpener); + const rapidDialog = await expectOwner(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); + 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 waitFor(() => void expect(root.activeElement).toBe(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..9e49c07c --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -0,0 +1,180 @@ +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 a popover opening a dialog', () => { + document.body.replaceChildren(); + const pageOpener = button('Open popover'); + const dialogOpener = button('Open dialog'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'popover', kind: 'nonmodal', opener: pageOpener }); + ownership.mount({ + id: 'dialog', + kind: 'modal', + opener: dialogOpener, + parentId: 'popover', + }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + modalOwnerId: 'dialog', + backgroundInert: true, + layers: [ + { id: 'popover', eligible: false, phase: 'active' }, + { id: 'dialog', eligible: true, phase: 'active' }, + ], + }); + expect(ownership.requestDismiss()).toBe('dialog'); + + ownership.beginExit('dialog'); + expect(ownership.snapshot().backgroundInert).toBe(true); + expect(ownership.requestDismiss()).toBeNull(); + expect(ownership.unmount('dialog')).toEqual({ kind: 'element', element: dialogOpener }); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'popover', + modalOwnerId: null, + backgroundInert: false, + }); + + ownership.beginExit('popover'); + expect(ownership.unmount('popover')).toEqual({ kind: 'element', element: pageOpener }); + }); + + 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 }); + 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 }); + 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 opener = button('Open dialog'); + const ownership = new ShadowOverlayOwnership(); + + ownership.mount({ id: 'dialog', kind: 'modal', opener }); + ownership.beginExit('dialog'); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + backgroundInert: true, + layers: [{ id: 'dialog', phase: 'exiting' }], + }); + + ownership.mount({ id: 'dialog', kind: 'modal', opener }); + + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'dialog', + backgroundInert: true, + layers: [{ id: 'dialog', phase: 'active' }], + }); + }); + + it('keeps an unrelated nonmodal overlay outside the active modal scope', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'dialog', kind: 'modal', opener: button('Open dialog') }); + 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 }, + ], + }); + }); + + it('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { + document.body.replaceChildren(); + const ownership = new ShadowOverlayOwnership(); + ownership.mount({ id: 'dialog', kind: 'modal', opener: button('Open dialog') }); + ownership.mount({ + id: 'popover', + kind: 'nonmodal', + opener: button('Open popover'), + parentId: 'dialog', + }); + + expect(ownership.beginExit('dialog')).toEqual(['popover', 'dialog']); + expect(ownership.requestDismiss()).toBeNull(); + expect(() => ownership.unmount('dialog')).toThrow( + 'Cannot unmount overlay "dialog" before descendant "popover"', + ); + ownership.unmount('popover'); + expect(ownership.snapshot().backgroundInert).toBe(true); + ownership.unmount('dialog'); + expect(ownership.snapshot().backgroundInert).toBe(false); + }); + + it('falls back to the remaining owner 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: '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..096d424b --- /dev/null +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -0,0 +1,153 @@ +export type ShadowOverlayKind = 'modal' | 'nonmodal'; +export type ShadowOverlayPhase = 'active' | 'exiting'; + +export interface ShadowOverlayRegistration { + id: string; + kind: ShadowOverlayKind; + opener: HTMLElement; + parentId?: string; + dismissible?: boolean; +} + +export type ShadowOverlayFocusTarget = + | { kind: 'element'; element: HTMLElement } + | { kind: 'layer'; id: string } + | null; + +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); + if (existing) { + existing.phase = 'active'; + return; + } + + if ( + registration.parentId !== undefined && + !this.#layers.some((layer) => layer.id === registration.parentId) + ) { + throw new Error( + `Cannot mount overlay "${registration.id}" with missing parent "${registration.parentId}"`, + ); + } + + this.#layers.push({ + ...registration, + dismissible: registration.dismissible ?? true, + phase: 'active', + }); + } + + beginExit(id: string): string[] { + this.#requireLayer(id); + const exitingIds = this.#layers + .filter((layer) => layer.id === id || this.#hasAncestor(layer, id)) + .map((layer) => layer.id) + .reverse(); + + for (const exitingId of exitingIds) { + this.#requireLayer(exitingId).phase = 'exiting'; + } + + return exitingIds; + } + + requestDismiss(): string | null { + const owner = this.#owner(); + if (!owner || owner.phase === 'exiting' || !owner.dismissible) return null; + return owner.id; + } + + unmount(id: string): ShadowOverlayFocusTarget { + const layer = this.#requireLayer(id); + const descendant = this.#layers.find((candidate) => this.#hasAncestor(candidate, id)); + if (descendant) { + throw new Error(`Cannot unmount overlay "${id}" before descendant "${descendant.id}"`); + } + + this.#layers.splice(this.#layers.indexOf(layer), 1); + const snapshot = this.snapshot(); + const parentIsEligible = + layer.parentId !== undefined && + snapshot.layers.some((candidate) => candidate.id === layer.parentId && candidate.eligible); + if ( + layer.opener.isConnected && + (snapshot.modalOwnerId === null || snapshot.ownerId === null || parentIsEligible) + ) { + return { kind: 'element', element: layer.opener }; + } + if (snapshot.ownerId) return { kind: 'layer', id: snapshot.ownerId }; + return layer.opener.isConnected ? { kind: 'element', element: layer.opener } : null; + } + + snapshot(): ShadowOverlaySnapshot { + const modalOwner = [...this.#layers].reverse().find((layer) => layer.kind === 'modal') ?? null; + 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.#layers].reverse().find((layer) => eligibleIds.has(layer.id)) ?? null; + + 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; + } + + #owner(): ShadowOverlayLayer | null { + const ownerId = this.snapshot().ownerId; + return ownerId ? (this.#layers.find((layer) => layer.id === ownerId) ?? null) : null; + } + + #requireLayer(id: string): ShadowOverlayLayer { + const layer = this.#layers.find((candidate) => candidate.id === id); + if (!layer) throw new Error(`Unknown overlay "${id}"`); + return layer; + } +} From 3c50f1b869338120d1b4179714b4f9778cf946dc Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 12:49:59 -0500 Subject: [PATCH 08/23] refactor(ui): simplify shadow overlay proof YPE-5355 --- .../0007-shadow-overlay-ownership-stack.md | 6 +- .../lib/shadow-overlay-ownership.stories.tsx | 34 +++++---- .../ui/src/lib/shadow-overlay-ownership.ts | 71 +++++++++++-------- 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 7939a8d0..286f91b5 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -45,10 +45,8 @@ dismissal ownership, modal inertness through exit, 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 is deliberately not connected to -those primitives. Production support begins only after the root-owned registry -is integrated at the `ShadowRootHost` seam and the same browser evidence passes -through the shared primitives and every direct overlay consumer. +callers; the proof module is internal and deliberately not connected to those +primitives. See Consequences below for what production integration requires. ## Considered options diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index 39f5ebda..1315372d 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -9,7 +9,7 @@ import { type ShadowOverlayFocusTarget, type ShadowOverlayKind, } from './shadow-overlay-ownership'; -import { ShadowRootHost } from './shadow-root-host'; +import { getOwnShadowRoot, ShadowRootHost } from './shadow-root-host'; const EXIT_DURATION_MS = 150; @@ -73,8 +73,8 @@ function OwnershipProof(): React.ReactNode { useLayoutEffect(() => { const marker = markerRef.current; - const root = marker?.getRootNode(); - if (!marker || !(root instanceof ShadowRoot)) return; + const root = marker ? getOwnShadowRoot(marker) : null; + if (!marker || !root) return; const container = marker.ownerDocument.createElement('div'); container.setAttribute('data-testid', 'ownership-top-layer'); @@ -307,12 +307,17 @@ async function expectUnmounted(root: ShadowRoot, id: string): Promise { ); } +async function expectActiveElement(root: ShadowRoot, element: Element): Promise { + await waitFor(() => void expect(root.activeElement).toBe(element)); +} + export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { play: async ({ canvasElement }) => { 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"]'); const background = requireElement(root, '[data-testid="proof-background"]'); const popoverParentOpener = requireElement( @@ -330,15 +335,14 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { await userEvent.keyboard('{Escape}'); await expectUnmounted(root, 'popover-child-dialog'); await expectOwner(root, 'popover-parent'); - await waitFor(() => - void expect(root.activeElement).toBe( - requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), - ), + await expectActiveElement( + root, + requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), ); void expect(background.inert).toBe(false); - await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await userEvent.click(outsideLayer); await expectUnmounted(root, 'popover-parent'); - await waitFor(() => void expect(root.activeElement).toBe(popoverParentOpener)); + await expectActiveElement(root, popoverParentOpener); const dialogParentOpener = requireElement( root, @@ -352,12 +356,12 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { await expectOwner(root, 'dialog-child-popover'); void expect(background.inert).toBe(true); void expect(dialogParent.inert).toBe(false); - await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await userEvent.click(outsideLayer); await expectUnmounted(root, 'dialog-child-popover'); await expectOwner(root, 'dialog-parent'); await userEvent.keyboard('{Escape}'); await expectUnmounted(root, 'dialog-parent'); - await waitFor(() => void expect(root.activeElement).toBe(dialogParentOpener)); + await expectActiveElement(root, dialogParentOpener); const firstOpener = requireElement(root, '[data-testid="open-first"]'); const secondOpener = requireElement(root, '[data-testid="open-second"]'); @@ -369,13 +373,13 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { element.getAttribute('data-overlay-id'), ), ).toEqual(['first', 'second']); - await userEvent.click(requireElement(topLayer, '[data-testid="outside-layer"]')); + await userEvent.click(outsideLayer); await expectUnmounted(root, 'second'); await expectOwner(root, 'first'); - await waitFor(() => void expect(root.activeElement).toBe(secondOpener)); + await expectActiveElement(root, secondOpener); await userEvent.keyboard('{Escape}'); await expectUnmounted(root, 'first'); - await waitFor(() => void expect(root.activeElement).toBe(firstOpener)); + await expectActiveElement(root, firstOpener); const rapidOpener = requireElement( root, @@ -398,7 +402,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { await userEvent.keyboard('{Escape}'); await expectUnmounted(root, 'rapid-dialog'); void expect(background.inert).toBe(false); - await waitFor(() => void expect(root.activeElement).toBe(rapidOpener)); + await expectActiveElement(root, rapidOpener); await waitFor(() => void expect(topLayer.matches(':popover-open')).toBe(false)); }, }; diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 096d424b..83c3e471 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -61,16 +61,13 @@ export class ShadowOverlayOwnership { beginExit(id: string): string[] { this.#requireLayer(id); - const exitingIds = this.#layers + const exitingLayers = this.#layers .filter((layer) => layer.id === id || this.#hasAncestor(layer, id)) - .map((layer) => layer.id) .reverse(); - for (const exitingId of exitingIds) { - this.#requireLayer(exitingId).phase = 'exiting'; - } + for (const layer of exitingLayers) layer.phase = 'exiting'; - return exitingIds; + return exitingLayers.map((layer) => layer.id); } requestDismiss(): string | null { @@ -87,33 +84,20 @@ export class ShadowOverlayOwnership { } this.#layers.splice(this.#layers.indexOf(layer), 1); - const snapshot = this.snapshot(); - const parentIsEligible = - layer.parentId !== undefined && - snapshot.layers.some((candidate) => candidate.id === layer.parentId && candidate.eligible); - if ( - layer.opener.isConnected && - (snapshot.modalOwnerId === null || snapshot.ownerId === null || parentIsEligible) - ) { - return { kind: 'element', element: layer.opener }; + const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); + const parentIsEligible = layer.parentId !== undefined && eligibleIds.has(layer.parentId); + const openerTarget: ShadowOverlayFocusTarget = layer.opener.isConnected + ? { kind: 'element', element: layer.opener } + : null; + + if (openerTarget && (modalOwner === null || owner === null || parentIsEligible)) { + return openerTarget; } - if (snapshot.ownerId) return { kind: 'layer', id: snapshot.ownerId }; - return layer.opener.isConnected ? { kind: 'element', element: layer.opener } : null; + return owner ? { kind: 'layer', id: owner.id } : openerTarget; } snapshot(): ShadowOverlaySnapshot { - const modalOwner = [...this.#layers].reverse().find((layer) => layer.kind === 'modal') ?? null; - 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.#layers].reverse().find((layer) => eligibleIds.has(layer.id)) ?? null; + const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); return { ownerId: owner?.id ?? null, @@ -140,9 +124,34 @@ export class ShadowOverlayOwnership { return false; } + /** 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 { - const ownerId = this.snapshot().ownerId; - return ownerId ? (this.#layers.find((layer) => layer.id === ownerId) ?? null) : null; + return this.#computeOwnership().owner; } #requireLayer(id: string): ShadowOverlayLayer { From 73532013d9d142c5eebe98b4e5bed1e7919c625f Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 12:56:22 -0500 Subject: [PATCH 09/23] test(ui): close overlay proof gaps YPE-5355 --- .../lib/shadow-overlay-ownership.stories.tsx | 22 +++++++++++---- .../src/lib/shadow-overlay-ownership.test.ts | 28 ++++++++++++++++++- .../ui/src/lib/shadow-overlay-ownership.ts | 5 ++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index 1315372d..427c5bd2 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -311,6 +311,12 @@ 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 root = await waitFor(() => requireShadowRoot(canvasElement)); @@ -325,11 +331,11 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { '[data-testid="open-popover-parent"]', ); await userEvent.click(popoverParentOpener); - const popoverParent = await expectOwner(root, 'popover-parent'); + const popoverParent = await expectFocusedOwner(root, 'popover-parent'); await userEvent.click( requireElement(popoverParent, '[data-testid="popover-opens-dialog"]'), ); - await expectOwner(root, 'popover-child-dialog'); + await expectFocusedOwner(root, 'popover-child-dialog'); void expect(background.inert).toBe(true); void expect(popoverParent.inert).toBe(true); await userEvent.keyboard('{Escape}'); @@ -349,16 +355,20 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { '[data-testid="open-dialog-parent"]', ); await userEvent.click(dialogParentOpener); - const dialogParent = await expectOwner(root, 'dialog-parent'); + const dialogParent = await expectFocusedOwner(root, 'dialog-parent'); await userEvent.click( requireElement(dialogParent, '[data-testid="dialog-opens-popover"]'), ); - await expectOwner(root, 'dialog-child-popover'); + await expectFocusedOwner(root, 'dialog-child-popover'); void expect(background.inert).toBe(true); void expect(dialogParent.inert).toBe(false); 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); @@ -367,7 +377,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { const secondOpener = requireElement(root, '[data-testid="open-second"]'); await userEvent.click(firstOpener); await userEvent.click(secondOpener); - await expectOwner(root, 'second'); + await expectFocusedOwner(root, 'second'); void expect( Array.from(topLayer.querySelectorAll('[data-overlay-id]'), (element) => element.getAttribute('data-overlay-id'), @@ -386,7 +396,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { '[data-testid="open-rapid-dialog"]', ); await userEvent.click(rapidOpener); - const rapidDialog = await expectOwner(root, 'rapid-dialog'); + const rapidDialog = await expectFocusedOwner(root, 'rapid-dialog'); await userEvent.click( requireElement(rapidDialog, '[data-testid="close-rapid-dialog"]'), ); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index 9e49c07c..58ac06e7 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -132,7 +132,8 @@ describe('ShadowOverlayOwnership', () => { it('keeps an unrelated nonmodal overlay outside the active modal scope', () => { document.body.replaceChildren(); const ownership = new ShadowOverlayOwnership(); - ownership.mount({ id: 'dialog', kind: 'modal', opener: button('Open dialog') }); + 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({ @@ -142,6 +143,31 @@ describe('ShadowOverlayOwnership', () => { { id: 'unrelated', eligible: false }, ], }); + + ownership.beginExit('dialog'); + expect(ownership.unmount('dialog')).toEqual({ kind: 'element', element: dialogOpener }); + expect(ownership.snapshot()).toMatchObject({ + ownerId: 'unrelated', + modalOwnerId: null, + backgroundInert: false, + layers: [{ id: 'unrelated', eligible: true }], + }); + }); + + 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('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 83c3e471..4b592a28 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -1,6 +1,9 @@ +/** @internal */ export type ShadowOverlayKind = 'modal' | 'nonmodal'; +/** @internal */ export type ShadowOverlayPhase = 'active' | 'exiting'; +/** @internal */ export interface ShadowOverlayRegistration { id: string; kind: ShadowOverlayKind; @@ -9,11 +12,13 @@ export interface ShadowOverlayRegistration { dismissible?: boolean; } +/** @internal */ export type ShadowOverlayFocusTarget = | { kind: 'element'; element: HTMLElement } | { kind: 'layer'; id: string } | null; +/** @internal */ export interface ShadowOverlaySnapshot { ownerId: string | null; modalOwnerId: string | null; From 0481b000594b5e0c1ad8115522e53673a487ef47 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 13:08:02 -0500 Subject: [PATCH 10/23] fix(ui): enforce overlay interaction ownership --- .../0007-shadow-overlay-ownership-stack.md | 11 ++++--- .../lib/shadow-overlay-ownership.stories.tsx | 12 +++++--- .../src/lib/shadow-overlay-ownership.test.ts | 23 ++++++++++++-- .../ui/src/lib/shadow-overlay-ownership.ts | 30 ++++++++++++------- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 286f91b5..fede4677 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -20,10 +20,13 @@ do not fall through to a lower overlay. A nested overlay remains inside its ancestor modal's focus scope. Shadow content stays inert while any modal is mounted, including its exit animation. An ancestor close may start its own and its descendants' exit phases together, but every descendant must unmount before -the ancestor. Focus restores only after an overlay unmounts, in this order: a -connected opener in the remaining active scope; otherwise the remaining top -eligible layer; otherwise the outer opener once the last modal leaves. A -disconnected opener is skipped in favor of the next tier. +the ancestor; a new child cannot mount beneath an exiting parent. Reopening a +stable overlay ID during exit cancels that exit and refreshes its registration, +including its opener, parent, kind, and dismissal policy. Focus restores only +after an overlay unmounts, in this order: a connected opener in the remaining +active scope; otherwise the remaining top eligible layer; otherwise the outer +opener once the last modal leaves. A disconnected opener is skipped in favor of +the next tier. ## Scenario classification and proof diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index 427c5bd2..63041048 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -191,11 +191,11 @@ function OwnershipProof(): React.ReactNode { key={layer.id} role={layer.kind === 'modal' ? 'dialog' : 'region'} aria-label={layer.id} - aria-hidden={!layer.eligible} + aria-hidden={layer.id !== snapshot.ownerId} data-overlay-id={layer.id} data-owner={layer.id === snapshot.ownerId ? '' : undefined} data-phase={layer.phase} - inert={!layer.eligible} + inert={layer.id !== snapshot.ownerId} tabIndex={-1} style={{ position: 'fixed', @@ -361,7 +361,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { ); await expectFocusedOwner(root, 'dialog-child-popover'); void expect(background.inert).toBe(true); - void expect(dialogParent.inert).toBe(false); + void expect(dialogParent.inert).toBe(true); await userEvent.click(outsideLayer); await expectUnmounted(root, 'dialog-child-popover'); await expectOwner(root, 'dialog-parent'); @@ -377,7 +377,10 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { const secondOpener = requireElement(root, '[data-testid="open-second"]'); await userEvent.click(firstOpener); await userEvent.click(secondOpener); - await expectFocusedOwner(root, 'second'); + 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'), @@ -386,6 +389,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { 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'); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index 58ac06e7..aaebc3d5 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -109,10 +109,11 @@ describe('ShadowOverlayOwnership', () => { it('reopens the same overlay during its exit without duplicating ownership', () => { document.body.replaceChildren(); - const opener = button('Open dialog'); + const originalOpener = button('Open dialog'); + const reopenedOpener = button('Reopen dialog'); const ownership = new ShadowOverlayOwnership(); - ownership.mount({ id: 'dialog', kind: 'modal', opener }); + ownership.mount({ id: 'dialog', kind: 'modal', opener: originalOpener }); ownership.beginExit('dialog'); expect(ownership.snapshot()).toMatchObject({ ownerId: 'dialog', @@ -120,13 +121,21 @@ describe('ShadowOverlayOwnership', () => { layers: [{ id: 'dialog', phase: 'exiting' }], }); - ownership.mount({ id: 'dialog', kind: 'modal', opener }); + 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', () => { @@ -183,6 +192,14 @@ describe('ShadowOverlayOwnership', () => { expect(ownership.beginExit('dialog')).toEqual(['popover', 'dialog']); 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"', ); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 4b592a28..8f7fe2eb 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -42,20 +42,30 @@ export class ShadowOverlayOwnership { readonly #layers: ShadowOverlayLayer[] = []; mount(registration: ShadowOverlayRegistration): void { - const existing = this.#layers.find((layer) => layer.id === registration.id); - if (existing) { - existing.phase = 'active'; - return; - } - - if ( - registration.parentId !== undefined && - !this.#layers.some((layer) => layer.id === registration.parentId) - ) { + 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 (parent?.phase === 'exiting') { + throw new Error( + `Cannot mount overlay "${registration.id}" under exiting parent "${registration.parentId}"`, + ); + } + + const existing = this.#layers.find((layer) => layer.id === registration.id); + if (existing) { + Object.assign(existing, registration, { + dismissible: registration.dismissible ?? true, + parentId: registration.parentId, + phase: 'active' satisfies ShadowOverlayPhase, + }); + return; + } this.#layers.push({ ...registration, From 57fb02fc0d1106b1fec3fb5301333b72013c0149 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 13:12:55 -0500 Subject: [PATCH 11/23] fix(ui): defer cascade focus restoration --- docs/adr/0007-shadow-overlay-ownership-stack.md | 3 ++- packages/ui/src/lib/shadow-overlay-ownership.test.ts | 10 +++++++--- packages/ui/src/lib/shadow-overlay-ownership.ts | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index fede4677..d4fea39e 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -26,7 +26,8 @@ including its opener, parent, kind, and dismissal policy. Focus restores only after an overlay unmounts, in this order: a connected opener in the remaining active scope; otherwise the remaining top eligible layer; otherwise the outer opener once the last modal leaves. A disconnected opener is skipped in favor of -the next tier. +the next tier. Descendant unmounts during an ancestor-close cascade suppress +focus restoration; the ancestor's final unmount performs the single restore. ## Scenario classification and proof diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index aaebc3d5..1cce2d1c 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -182,7 +182,8 @@ describe('ShadowOverlayOwnership', () => { it('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { document.body.replaceChildren(); const ownership = new ShadowOverlayOwnership(); - ownership.mount({ id: 'dialog', kind: 'modal', opener: button('Open dialog') }); + const dialogOpener = button('Open dialog'); + ownership.mount({ id: 'dialog', kind: 'modal', opener: dialogOpener }); ownership.mount({ id: 'popover', kind: 'nonmodal', @@ -203,9 +204,12 @@ describe('ShadowOverlayOwnership', () => { expect(() => ownership.unmount('dialog')).toThrow( 'Cannot unmount overlay "dialog" before descendant "popover"', ); - ownership.unmount('popover'); + expect(ownership.unmount('popover')).toBeNull(); expect(ownership.snapshot().backgroundInert).toBe(true); - ownership.unmount('dialog'); + expect(ownership.unmount('dialog')).toEqual({ + kind: 'element', + element: dialogOpener, + }); expect(ownership.snapshot().backgroundInert).toBe(false); }); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 8f7fe2eb..b5d1f702 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -98,7 +98,12 @@ export class ShadowOverlayOwnership { 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) return null; + const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); const parentIsEligible = layer.parentId !== undefined && eligibleIds.has(layer.parentId); const openerTarget: ShadowOverlayFocusTarget = layer.opener.isConnected From 6270cd81a73a887d9354cfc78f1311e280116379 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 13:23:45 -0500 Subject: [PATCH 12/23] fix(ui): harden overlay ownership edges --- .../0007-shadow-overlay-ownership-stack.md | 13 ++++++++----- .../lib/shadow-overlay-ownership.stories.tsx | 2 ++ .../src/lib/shadow-overlay-ownership.test.ts | 19 +++++++++++++++++++ .../ui/src/lib/shadow-overlay-ownership.ts | 13 +++++++++++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index d4fea39e..a939e58e 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -23,11 +23,14 @@ its descendants' exit phases together, but every descendant must unmount before the ancestor; a new child cannot mount beneath an exiting parent. Reopening a stable overlay ID during exit cancels that exit and refreshes its registration, including its opener, parent, kind, and dismissal policy. Focus restores only -after an overlay unmounts, in this order: a connected opener in the remaining -active scope; otherwise the remaining top eligible layer; otherwise the outer -opener once the last modal leaves. A disconnected opener is skipped in favor of -the next tier. Descendant unmounts during an ancestor-close cascade suppress -focus restoration; the ancestor's final unmount performs the single restore. +when the current owner unmounts, in this order: a connected opener in the +remaining active scope; otherwise the remaining top eligible layer; otherwise +the outer opener once the last modal leaves. Removing a lower layer does not +steal focus from its owner. A disconnected opener is skipped in favor of the +next tier. 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. ## Scenario classification and proof diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index 63041048..f716b226 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -184,6 +184,7 @@ function OwnershipProof(): React.ReactNode { aria-label="Outside active overlay" data-testid="outside-layer" onClick={dismissOwner} + tabIndex={-1} style={{ position: 'fixed', inset: 0, border: 0, background: 'transparent' }} /> {snapshot.layers.map((layer, index) => ( @@ -324,6 +325,7 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { 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 popoverParentOpener = requireElement( diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index 1cce2d1c..d0fb6a04 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -177,6 +177,8 @@ describe('ShadowOverlayOwnership', () => { expect(ownership.snapshot().ownerId).toBe('second'); expect(ownership.requestDismiss()).toBeNull(); expect(ownership.snapshot().layers).toHaveLength(2); + expect(ownership.unmount('first')).toBeNull(); + expect(ownership.snapshot().ownerId).toBe('second'); }); it('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { @@ -191,6 +193,23 @@ describe('ShadowOverlayOwnership', () => { 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"'); + expect(ownership.beginExit('dialog')).toEqual(['popover', 'dialog']); expect(ownership.requestDismiss()).toBeNull(); expect(() => diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index b5d1f702..92282db0 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -42,6 +42,7 @@ 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 @@ -51,13 +52,20 @@ export class ShadowOverlayOwnership { `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}"`, ); } - const existing = this.#layers.find((layer) => layer.id === registration.id); if (existing) { Object.assign(existing, registration, { dismissible: registration.dismissible ?? true, @@ -93,6 +101,7 @@ export class ShadowOverlayOwnership { unmount(id: string): ShadowOverlayFocusTarget { 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}"`); @@ -102,7 +111,7 @@ export class ShadowOverlayOwnership { (candidate) => candidate.id === layer.parentId && candidate.phase === 'exiting', ); this.#layers.splice(this.#layers.indexOf(layer), 1); - if (parentIsExiting) return null; + if (parentIsExiting || !wasOwner) return null; const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); const parentIsEligible = layer.parentId !== undefined && eligibleIds.has(layer.parentId); From 0323bce5730fdac0f2f86b299f9dead351362f17 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 13:26:36 -0500 Subject: [PATCH 13/23] test(ui): name overlay edge contracts --- .../src/lib/shadow-overlay-ownership.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index d0fb6a04..4b7ec77b 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -177,11 +177,19 @@ describe('ShadowOverlayOwnership', () => { 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')).toBeNull(); expect(ownership.snapshot().ownerId).toBe('second'); }); - it('blocks dismissal fallthrough and ancestor unmount while descendants exit', () => { + it('rejects cyclic parent updates', () => { document.body.replaceChildren(); const ownership = new ShadowOverlayOwnership(); const dialogOpener = button('Open dialog'); @@ -209,6 +217,19 @@ describe('ShadowOverlayOwnership', () => { parentId: 'popover', }), ).toThrow('Cannot mount overlay "dialog" under descendant "popover"'); + }); + + 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.requestDismiss()).toBeNull(); From 4ce1652fa410fd8c295ed484cdda34fbddfc576b Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 14:09:41 -0500 Subject: [PATCH 14/23] fix(ui): preserve overlay subtree order --- .../0007-shadow-overlay-ownership-stack.md | 3 ++- .../src/lib/shadow-overlay-ownership.test.ts | 24 +++++++++++++++++++ .../ui/src/lib/shadow-overlay-ownership.ts | 10 ++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index a939e58e..55cbf95d 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -30,7 +30,8 @@ steal focus from its owner. A disconnected opener is skipped in favor of the next tier. 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. +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 diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index 4b7ec77b..f8cb2035 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -219,6 +219,30 @@ describe('ShadowOverlayOwnership', () => { ).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')).toBeNull(); + 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(); diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 92282db0..4407b883 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -72,6 +72,7 @@ export class ShadowOverlayOwnership { parentId: registration.parentId, phase: 'active' satisfies ShadowOverlayPhase, }); + this.#moveSubtreeToTop(existing.id); return; } @@ -153,6 +154,15 @@ export class ShadowOverlayOwnership { 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'); From 48f40d5bdfb87cd4a78a37309bada72ba9dbf1de Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 14:45:16 -0500 Subject: [PATCH 15/23] test(ui): prove modal focus containment --- .../0007-shadow-overlay-ownership-stack.md | 10 ++- .../lib/shadow-overlay-ownership.stories.tsx | 82 +++++++++++++++++-- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 55cbf95d..6aaa6e6e 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -17,10 +17,12 @@ 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. Shadow content stays inert while any modal is -mounted, including its 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. Reopening a +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. Reopening a stable overlay ID during exit cancels that exit and refreshes its registration, including its opener, parent, kind, and dismissal policy. Focus restores only when the current owner unmounts, in this order: a connected opener in the diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index f716b226..bacd5d7c 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -3,13 +3,18 @@ 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 ShadowOverlayFocusTarget, type ShadowOverlayKind, } from './shadow-overlay-ownership'; -import { getOwnShadowRoot, ShadowRootHost } from './shadow-root-host'; +import { + getOwnShadowRoot, + isElementFromOwnerDocument, + ShadowRootHost, +} from './shadow-root-host'; const EXIT_DURATION_MS = 150; @@ -125,6 +130,49 @@ function OwnershipProof(): React.ReactNode { } }, [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 handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) return; @@ -261,9 +309,14 @@ function OwnershipProof(): React.ReactNode { function OwnershipProofStory(): React.ReactNode { return ( - - - + <> + + + + + ); } @@ -320,6 +373,9 @@ async function expectFocusedOwner(root: ShadowRoot, id: string): Promise { + 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"]'), @@ -358,9 +414,23 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { ); await userEvent.click(dialogParentOpener); const dialogParent = await expectFocusedOwner(root, 'dialog-parent'); - await userEvent.click( - requireElement(dialogParent, '[data-testid="dialog-opens-popover"]'), + 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); From 5dd0b8923d4e83463816cc31aa0db14cfcac9bdf Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 14:55:43 -0500 Subject: [PATCH 16/23] test(ui): scope overlay escape ownership --- .../src/lib/shadow-overlay-ownership.stories.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index bacd5d7c..b841c444 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -176,6 +176,7 @@ function OwnershipProof(): React.ReactNode { useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) return; + if (ownershipRef.current.snapshot().ownerId === null) return; event.preventDefault(); dismissOwner(); }; @@ -383,6 +384,13 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { 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, @@ -478,6 +486,13 @@ export const ExercisesNestedConcurrentAndRapidReopenOwnership: Story = { ); 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"]'), ); From 03333e1bf910ada1eb2921f267ee71668f156f21 Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 15:19:07 -0500 Subject: [PATCH 17/23] fix(ui): clarify shadow overlay launch and restoration contract --- .../0007-shadow-overlay-ownership-stack.md | 44 ++++++++++------- docs/shadow-dom-isolation-plan.md | 2 +- .../src/lib/shadow-overlay-ownership.test.ts | 49 +++++++++++++------ .../ui/src/lib/shadow-overlay-ownership.ts | 6 ++- 4 files changed, 65 insertions(+), 36 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index 6aaa6e6e..fca3091a 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -6,10 +6,13 @@ 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, parent overlay when nested, opener, and mounted phase. This -keeps stacking, focus, dismissal, inertness, and restoration behind the -`ShadowRootHost` interface instead of making each Dialog or Popover coordinate -with its siblings. +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 @@ -24,12 +27,13 @@ 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. Reopening a stable overlay ID during exit cancels that exit and refreshes its registration, -including its opener, parent, kind, and dismissal policy. Focus restores only -when the current owner unmounts, in this order: a connected opener in the -remaining active scope; otherwise the remaining top eligible layer; otherwise -the outer opener once the last modal leaves. Removing a lower layer does not -steal focus from its owner. A disconnected opener is skipped in favor of the -next tier. Descendant unmounts during an ancestor-close cascade suppress focus +including its focus restoration target, parent, kind, and dismissal policy. +Focus restores only when the current owner unmounts, in this order: a connected +focus restoration target in the remaining active scope; otherwise the remaining +top eligible layer; otherwise the outer focus restoration target once the last +modal leaves. Removing a lower layer does not steal focus from its owner. An +absent or disconnected focus restoration target is skipped in favor of the next +tier. 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 @@ -40,7 +44,9 @@ so every parent remains before its children in registration order. The ownership contract supports these scenarios in the committed executable proof: -- a non-modal popover opening a modal dialog; +- 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. @@ -48,11 +54,11 @@ proof: `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 -disconnected-opener fallback. 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, restoration, and stable -identity during a rapid reopen. +absent or disconnected focus restoration fallback. 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, +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 @@ -76,7 +82,11 @@ 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. Until that work lands, nested or +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, and it must 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 ed43857c..3223fde1 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -39,7 +39,7 @@ This is a working plan, not approval for package-wide rollout. | 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 | 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, and disconnected-opener 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. | +| 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 diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index f8cb2035..78f8c422 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -9,43 +9,50 @@ function button(label: string): HTMLButtonElement { } describe('ShadowOverlayOwnership', () => { - it('coordinates a popover opening a dialog', () => { + it('coordinates the verse action popover opening the highlights permission dialog', () => { document.body.replaceChildren(); - const pageOpener = button('Open popover'); - const dialogOpener = button('Open dialog'); + const dialogOpener = button('Choose highlight color'); const ownership = new ShadowOverlayOwnership(); - ownership.mount({ id: 'popover', kind: 'nonmodal', opener: pageOpener }); + ownership.mount({ id: 'verse-action-popover', kind: 'nonmodal', opener: null }); ownership.mount({ - id: 'dialog', + id: 'highlights-permission-dialog', kind: 'modal', opener: dialogOpener, - parentId: 'popover', + parentId: 'verse-action-popover', }); expect(ownership.snapshot()).toMatchObject({ - ownerId: 'dialog', - modalOwnerId: 'dialog', + ownerId: 'highlights-permission-dialog', + modalOwnerId: 'highlights-permission-dialog', backgroundInert: true, layers: [ - { id: 'popover', eligible: false, phase: 'active' }, - { id: 'dialog', eligible: true, phase: 'active' }, + { id: 'verse-action-popover', eligible: false, phase: 'active' }, + { + id: 'highlights-permission-dialog', + parentId: 'verse-action-popover', + eligible: true, + phase: 'active', + }, ], }); - expect(ownership.requestDismiss()).toBe('dialog'); + expect(ownership.requestDismiss()).toBe('highlights-permission-dialog'); - ownership.beginExit('dialog'); + ownership.beginExit('highlights-permission-dialog'); expect(ownership.snapshot().backgroundInert).toBe(true); expect(ownership.requestDismiss()).toBeNull(); - expect(ownership.unmount('dialog')).toEqual({ kind: 'element', element: dialogOpener }); + expect(ownership.unmount('highlights-permission-dialog')).toEqual({ + kind: 'element', + element: dialogOpener, + }); expect(ownership.snapshot()).toMatchObject({ - ownerId: 'popover', + ownerId: 'verse-action-popover', modalOwnerId: null, backgroundInert: false, }); - ownership.beginExit('popover'); - expect(ownership.unmount('popover')).toEqual({ kind: 'element', element: pageOpener }); + ownership.beginExit('verse-action-popover'); + expect(ownership.unmount('verse-action-popover')).toBeNull(); }); it('coordinates a dialog containing a popover', () => { @@ -288,4 +295,14 @@ describe('ShadowOverlayOwnership', () => { ownership.beginExit('second'); expect(ownership.unmount('second')).toEqual({ 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 index 4407b883..0af19ee6 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -7,7 +7,9 @@ export type ShadowOverlayPhase = 'active' | 'exiting'; export interface ShadowOverlayRegistration { id: string; kind: ShadowOverlayKind; - opener: HTMLElement; + /** 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; } @@ -116,7 +118,7 @@ export class ShadowOverlayOwnership { const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); const parentIsEligible = layer.parentId !== undefined && eligibleIds.has(layer.parentId); - const openerTarget: ShadowOverlayFocusTarget = layer.opener.isConnected + const openerTarget: ShadowOverlayFocusTarget = layer.opener?.isConnected ? { kind: 'element', element: layer.opener } : null; From 017360f50f3d23030b6419233a9912d35b6b1a2a Mon Sep 17 00:00:00 2001 From: abharms Date: Tue, 1 Sep 2026 15:28:51 -0500 Subject: [PATCH 18/23] fix(ui): preserve overlay dismissal ownership --- docs/adr/0007-shadow-overlay-ownership-stack.md | 5 +++-- .../lib/shadow-overlay-ownership.stories.tsx | 9 ++++++--- .../ui/src/lib/shadow-overlay-ownership.test.ts | 17 +++++++++++++++++ packages/ui/src/lib/shadow-overlay-ownership.ts | 13 +++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/adr/0007-shadow-overlay-ownership-stack.md b/docs/adr/0007-shadow-overlay-ownership-stack.md index fca3091a..d51e9689 100644 --- a/docs/adr/0007-shadow-overlay-ownership-stack.md +++ b/docs/adr/0007-shadow-overlay-ownership-stack.md @@ -25,8 +25,9 @@ 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. Reopening a -stable overlay ID during exit cancels that exit and refreshes its registration, +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 restores only when the current owner unmounts, in this order: a connected focus restoration target in the remaining active scope; otherwise the remaining diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index b841c444..f4ca6589 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -174,15 +174,18 @@ function OwnershipProof(): React.ReactNode { }, [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(); }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [dismissOwner]); + ownerDocument.addEventListener('keydown', handleKeyDown); + return () => ownerDocument.removeEventListener('keydown', handleKeyDown); + }, [dismissOwner, topLayer]); return ( <> diff --git a/packages/ui/src/lib/shadow-overlay-ownership.test.ts b/packages/ui/src/lib/shadow-overlay-ownership.test.ts index 78f8c422..e248cee7 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.test.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.test.ts @@ -170,6 +170,22 @@ describe('ShadowOverlayOwnership', () => { }); }); + it('restores to a newly eligible owner instead of an inert 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(); @@ -263,6 +279,7 @@ describe('ShadowOverlayOwnership', () => { }); expect(ownership.beginExit('dialog')).toEqual(['popover', 'dialog']); + expect(ownership.beginExit('popover')).toEqual([]); expect(ownership.requestDismiss()).toBeNull(); expect(() => ownership.mount({ diff --git a/packages/ui/src/lib/shadow-overlay-ownership.ts b/packages/ui/src/lib/shadow-overlay-ownership.ts index 0af19ee6..f4928d7b 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.ts +++ b/packages/ui/src/lib/shadow-overlay-ownership.ts @@ -88,7 +88,11 @@ export class ShadowOverlayOwnership { beginExit(id: string): string[] { this.#requireLayer(id); const exitingLayers = this.#layers - .filter((layer) => layer.id === id || this.#hasAncestor(layer, id)) + .filter( + (layer) => + layer.phase === 'active' && + (layer.id === id || this.#hasAncestor(layer, id)), + ) .reverse(); for (const layer of exitingLayers) layer.phase = 'exiting'; @@ -116,13 +120,14 @@ export class ShadowOverlayOwnership { this.#layers.splice(this.#layers.indexOf(layer), 1); if (parentIsExiting || !wasOwner) return null; - const { modalOwner, owner, eligibleIds } = this.#computeOwnership(); - const parentIsEligible = layer.parentId !== undefined && eligibleIds.has(layer.parentId); + const { modalOwner, owner } = this.#computeOwnership(); + const parentOwnsFocus = layer.parentId !== undefined && owner?.id === layer.parentId; + const outerScopeIsActive = layer.parentId === undefined && modalOwner === null; const openerTarget: ShadowOverlayFocusTarget = layer.opener?.isConnected ? { kind: 'element', element: layer.opener } : null; - if (openerTarget && (modalOwner === null || owner === null || parentIsEligible)) { + if (openerTarget && (owner === null || parentOwnsFocus || outerScopeIsActive)) { return openerTarget; } return owner ? { kind: 'layer', id: owner.id } : openerTarget; From 8fd4650e6d170130ab670e740af533ebe8d94174 Mon Sep 17 00:00:00 2001 From: abharms Date: Wed, 2 Sep 2026 13:53:18 -0500 Subject: [PATCH 19/23] fix(ui): keep concurrent overlay launchers clickable --- .../lib/shadow-overlay-ownership.stories.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index f4ca6589..409ca2e9 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -93,6 +93,7 @@ function OwnershipProof(): React.ReactNode { maxHeight: 'none', maxWidth: 'none', padding: '0', + pointerEvents: 'none', width: '100dvw', }); root.append(container); @@ -237,8 +238,18 @@ function OwnershipProof(): React.ReactNode { data-testid="outside-layer" onClick={dismissOwner} tabIndex={-1} - style={{ position: 'fixed', inset: 0, border: 0, background: 'transparent' }} - /> + style={{ + position: 'fixed', + insetBlockEnd: '16px', + insetInlineEnd: '16px', + border: '2px solid black', + background: 'white', + padding: '8px', + pointerEvents: 'auto', + }} + > + Outside active overlay + {snapshot.layers.map((layer, index) => (
(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"]'); From 1ef3dee951c163b0d71030373faf5f878e4ce74d Mon Sep 17 00:00:00 2001 From: abharms Date: Wed, 2 Sep 2026 14:03:58 -0500 Subject: [PATCH 20/23] fix(ui): preserve rejected overlay exit deadlines --- .../lib/shadow-overlay-ownership.stories.tsx | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx index 409ca2e9..65db2c57 100644 --- a/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx +++ b/packages/ui/src/lib/shadow-overlay-ownership.stories.tsx @@ -26,6 +26,7 @@ function OwnershipProof(): React.ReactNode { const exitTokensRef = useRef(new Map()); const pendingFocusTargetRef = useRef(null); const [topLayer, setTopLayer] = useState(null); + const [reopenRejected, setReopenRejected] = useState(false); const [revision, setRevision] = useState(0); const snapshot = ownershipRef.current.snapshot(); @@ -38,8 +39,8 @@ function OwnershipProof(): React.ReactNode { opener: HTMLElement, parentId?: string, ): void => { - exitTokensRef.current.set(id, (exitTokensRef.current.get(id) ?? 0) + 1); ownershipRef.current.mount({ id, kind, opener, parentId }); + exitTokensRef.current.set(id, (exitTokensRef.current.get(id) ?? 0) + 1); refresh(); }, [refresh], @@ -306,6 +307,34 @@ function OwnershipProof(): React.ReactNode { Reopen during exit ) : null} + {layer.id === 'dialog-child-popover' ? ( + + ) : null}