From 08bffa98747db1bcaf631a50be00faef3cff73f1 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sun, 14 Jun 2026 20:03:34 +0300 Subject: [PATCH 01/35] feat(core): add shared floating tree foundation --- .../core/__tests__/floating-tree.spec.ts | 371 ++++++++++++++++++ packages/primitives/core/index.ts | 7 + .../core/src/floating/floating-events.ts | 60 +++ .../core/src/floating/floating-lifecycle.ts | 37 ++ .../src/floating/floating-root-context.ts | 92 +++++ .../core/src/floating/floating-tree.ts | 369 +++++++++++++++++ .../src/floating/provide-floating-tree.ts | 58 +++ .../core/src/floating/trigger-registry.ts | 60 +++ 8 files changed, 1054 insertions(+) create mode 100644 packages/primitives/core/__tests__/floating-tree.spec.ts create mode 100644 packages/primitives/core/src/floating/floating-events.ts create mode 100644 packages/primitives/core/src/floating/floating-lifecycle.ts create mode 100644 packages/primitives/core/src/floating/floating-root-context.ts create mode 100644 packages/primitives/core/src/floating/floating-tree.ts create mode 100644 packages/primitives/core/src/floating/provide-floating-tree.ts create mode 100644 packages/primitives/core/src/floating/trigger-registry.ts diff --git a/packages/primitives/core/__tests__/floating-tree.spec.ts b/packages/primitives/core/__tests__/floating-tree.spec.ts new file mode 100644 index 00000000..78e69390 --- /dev/null +++ b/packages/primitives/core/__tests__/floating-tree.spec.ts @@ -0,0 +1,371 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest'; +import { createFloatingRootContext, RdxFloatingRootContext } from '../src/floating/floating-root-context'; +import { RdxFloatingNode, RdxFloatingTree } from '../src/floating/floating-tree'; + +function context(open = false, ownerDocument: Document = document): RdxFloatingRootContext { + return new RdxFloatingRootContext({ ownerDocument, open: () => open }); +} + +/** Registers a node wrapping a root context with a constant open-state. */ +function register(tree: RdxFloatingTree, id: string, parent: RdxFloatingNode | null, open = false): RdxFloatingNode { + return tree.register({ id, parent, context: context(open) }); +} + +describe('RdxFloatingTree', () => { + let tree: RdxFloatingTree; + + beforeEach(() => { + tree = new RdxFloatingTree(); + }); + + it('unregisters a node without removing its children, and traversal ignores the ghost parent', () => { + const root = register(tree, 'root', null, true); + const child = register(tree, 'child', root, true); + + expect(tree.all).toHaveLength(2); + + tree.unregister(root); + + // child stays registered and keeps the raw parent identity... + expect(tree.all).toEqual([child]); + expect(child.parent).toBe(root); + // ...but the unregistered parent is no longer a traversable ancestor (Base UI parity) + expect(tree.ancestors(child)).toEqual([]); + }); + + it('truncates ancestry at an unregistered middle node (does not skip to the grandparent)', () => { + const grandparent = register(tree, 'grandparent', null, true); + const parent = register(tree, 'parent', grandparent, true); + const child = register(tree, 'child', parent, true); + + expect(tree.ancestors(child)).toEqual([parent, grandparent]); + + tree.unregister(parent); + + // chain breaks at the removed parent — the grandparent is NOT reached + expect(tree.ancestors(child)).toEqual([]); + }); + + describe('children()', () => { + it('returns transitive children in registration order', () => { + const root = register(tree, 'root', null, true); + const a = register(tree, 'a', root, true); + const b = register(tree, 'b', root, true); + const aa = register(tree, 'aa', a, true); + + expect(tree.children(root)).toEqual([a, aa, b]); + }); + + it('with onlyOpen:true excludes closed nodes from the result', () => { + const root = register(tree, 'root', null, true); + register(tree, 'closed', root, false); + const open = register(tree, 'open', root, true); + + expect(tree.children(root, { onlyOpen: true })).toEqual([open]); + }); + + it('never aborts recursion at a closed node — a closed parent does not hide an open grandchild', () => { + const root = register(tree, 'root', null, true); + const closedParent = register(tree, 'closed-parent', root, false); + const openGrandchild = register(tree, 'open-grandchild', closedParent, true); + + // onlyOpen filters the *result* but still descends into the closed node + expect(tree.children(root, { onlyOpen: true })).toEqual([openGrandchild]); + // onlyOpen:false keeps both + expect(tree.children(root, { onlyOpen: false })).toEqual([closedParent, openGrandchild]); + }); + + it('a contextless node (context === null) is treated as closed by onlyOpen:true', () => { + const root = register(tree, 'root', null, true); + const contextless = tree.register({ id: 'contextless', parent: root, context: null }); + + expect(tree.children(root, { onlyOpen: true })).toEqual([]); + expect(tree.children(root, { onlyOpen: false })).toEqual([contextless]); + }); + }); + + describe('ancestors()', () => { + it('walks the logical parent chain nearest-first', () => { + const root = register(tree, 'root', null); + const a = register(tree, 'a', root); + const aa = register(tree, 'aa', a); + + expect(tree.ancestors(aa)).toEqual([a, root]); + expect(tree.ancestors(root)).toEqual([]); + }); + }); + + describe('deepestOpen()', () => { + it('returns the deepest open descendant (topmost within the tree)', () => { + const root = register(tree, 'root', null, true); + const a = register(tree, 'a', root, true); + const aa = register(tree, 'aa', a, true); + + expect(tree.deepestOpen(root)).toBe(aa); + }); + + it('ignores closed branches when choosing the deepest', () => { + const root = register(tree, 'root', null, true); + const shallowOpen = register(tree, 'shallow', root, true); + const deepClosedParent = register(tree, 'deep-closed', root, false); + register(tree, 'deep-open', deepClosedParent, true); + + // the deep branch is gated behind a closed node, so the shallow open node wins + expect(tree.deepestOpen(root)).toBe(shallowOpen); + }); + + it('returns null when there is no open descendant', () => { + const root = register(tree, 'root', null, true); + register(tree, 'closed', root, false); + + expect(tree.deepestOpen(root)).toBeNull(); + }); + }); + + describe('open() lives on the context and drives traversal', () => { + it('reflects a live open-state accessor on the context', () => { + const root = register(tree, 'root', null, true); + let childOpen = false; + const child = tree.register({ + id: 'child', + parent: root, + context: new RdxFloatingRootContext({ ownerDocument: document, open: () => childOpen }) + }); + + expect(tree.children(root, { onlyOpen: true })).toEqual([]); + + childOpen = true; + expect(tree.children(root, { onlyOpen: true })).toEqual([child]); + expect(tree.deepestOpen(root)).toBe(child); + }); + }); + + describe('setContext()', () => { + it('associates and clears a context after registration (null → context → null)', () => { + const root = register(tree, 'root', null, true); + const node = tree.register({ id: 'late', parent: root, context: null }); + + expect(tree.children(root, { onlyOpen: true })).toEqual([]); + + tree.setContext(node, context(true)); + expect(tree.children(root, { onlyOpen: true })).toEqual([node]); + + tree.setContext(node, null); + expect(tree.children(root, { onlyOpen: true })).toEqual([]); + }); + + it('rejects a context whose document differs from a context-bearing ancestor', () => { + const root = register(tree, 'root', null, true); + const node = tree.register({ id: 'late', parent: root, context: null }); + const otherDoc = document.implementation.createHTMLDocument('other'); + + expect(() => tree.setContext(node, context(true, otherDoc))).toThrow(/ownerDocument/i); + }); + + it('validates owner-document against context-bearing descendants, not just ancestors', () => { + const otherDoc = document.implementation.createHTMLDocument('other'); + // a contextless root can temporarily bridge two documents at registration time... + const root = tree.register({ id: 'root', parent: null, context: null }); + register(tree, 'a', root, true); // document + tree.register({ id: 'b', parent: root, context: context(true, otherDoc) }); // otherDoc + + // ...but giving that root a context surfaces the conflict with the `otherDoc` descendant. + expect(() => tree.setContext(root, context(true, document))).toThrow(/ownerDocument/i); + }); + }); + + describe('setParent()', () => { + it('reparents a node', () => { + const root = register(tree, 'root', null); + const a = register(tree, 'a', root); + const detached = register(tree, 'detached', null); + + tree.setParent(detached, a); + + expect(detached.parent).toBe(a); + expect(tree.children(a, { onlyOpen: false })).toContain(detached); + }); + + it('rejects an ancestry cycle', () => { + const root = register(tree, 'root', null); + const a = register(tree, 'a', root); + + expect(() => tree.setParent(root, a)).toThrow(/cycle/i); + }); + + it('validates the WHOLE subtree against the new ancestor, not just the first descendant', () => { + const otherDoc = document.implementation.createHTMLDocument('other'); + const newParent = register(tree, 'new-parent', null, true); // document + + // a detached contextless node bridging two documents in its subtree + const detached = tree.register({ id: 'detached', parent: null, context: null }); + register(tree, 'd1', detached, true); // document (first descendant — matches newParent) + tree.register({ id: 'd2', parent: detached, context: context(true, otherDoc) }); // otherDoc + + // first descendant matches, but the second conflicts — must still be rejected + expect(() => tree.setParent(detached, newParent)).toThrow(/ownerDocument/i); + }); + }); + + describe('encapsulation', () => { + it('exposes parent/context as read-only getters — mutate only through the tree', () => { + const root = register(tree, 'root', null, true); + const node = register(tree, 'child', root, true); + + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), 'parent'); + expect(typeof descriptor?.get).toBe('function'); + expect(descriptor?.set).toBeUndefined(); + + const other = register(tree, 'other', null, true); + tree.setParent(node, other); + expect(node.parent).toBe(other); + }); + + it('cannot be constructed directly — only via tree.register', () => { + // bypass the compile-time construction-key requirement to prove the runtime guard + const Node = RdxFloatingNode as unknown as new (...args: unknown[]) => RdxFloatingNode; + expect(() => new Node('fake', 'id', tree, null, null)).toThrow(/register/i); + }); + }); + + describe('node ownership', () => { + it('rejects mutating or traversing a node from another tree', () => { + const otherTree = new RdxFloatingTree(); + const foreign = otherTree.register({ id: 'foreign', parent: null, context: context(true) }); + + expect(() => tree.setParent(foreign, null)).toThrow(/belong/i); + expect(() => tree.setContext(foreign, context())).toThrow(/belong/i); + expect(() => tree.children(foreign)).toThrow(/belong/i); + expect(() => tree.ancestors(foreign)).toThrow(/belong/i); + expect(() => tree.deepestOpen(foreign)).toThrow(/belong/i); + }); + + it('rejects operating on an already-unregistered node', () => { + const node = register(tree, 'node', null, true); + tree.unregister(node); + + expect(() => tree.children(node)).toThrow(/unregistered|belong/i); + expect(() => tree.unregister(node)).toThrow(/unregistered|belong/i); + }); + + it('rejects registering or reparenting under an unregistered parent', () => { + const parent = register(tree, 'parent', null, true); + const child = register(tree, 'child', null, true); + tree.unregister(parent); + + expect(() => tree.register({ id: 'late', parent, context: context() })).toThrow(/registered/i); + expect(() => tree.setParent(child, parent)).toThrow(/registered/i); + }); + }); + + describe('invariants', () => { + it('rejects a parent from a different tree', () => { + const otherTree = new RdxFloatingTree(); + const foreignParent = otherTree.register({ id: 'foreign', parent: null, context: context() }); + + expect(() => tree.register({ id: 'child', parent: foreignParent, context: context() })).toThrow( + /same tree/i + ); + }); + + it('rejects a parent whose context is in a different ownerDocument', () => { + const parent = register(tree, 'root', null); + const otherDoc = document.implementation.createHTMLDocument('other'); + + expect(() => tree.register({ id: 'child', parent, context: context(false, otherDoc) })).toThrow( + /ownerDocument/i + ); + }); + }); +}); + +describe('RdxFloatingRootContext', () => { + it('exposes elements read-only behind validated setters', () => { + const ctx = new RdxFloatingRootContext({ ownerDocument: document, open: () => true }); + const floating = document.createElement('div'); + const reference = document.createElement('button'); + + ctx.setFloatingElement(floating); + ctx.setReferenceElement(reference); + + expect(ctx.floatingElement).toBe(floating); + expect(ctx.referenceElement).toBe(reference); + }); + + it('rejects a floating element from a different document', () => { + const ctx = new RdxFloatingRootContext({ ownerDocument: document }); + const foreign = document.implementation.createHTMLDocument('other').createElement('div'); + + expect(() => ctx.setFloatingElement(foreign)).toThrow(/ownerDocument/i); + }); + + it('createFloatingRootContext builds a node-optional context (getEmptyRootContext analog)', () => { + const ctx = createFloatingRootContext({ ownerDocument: document }); + + // usable without ever registering a tree node + expect(ctx.open()).toBe(false); + expect(ctx.floatingElement).toBeNull(); + + const trigger = document.createElement('button'); + ctx.triggers.add(trigger); + expect(ctx.triggers.contains(trigger)).toBe(true); + }); + + describe('per-context triggers, not tree-wide', () => { + it('each context owns its own trigger registry', () => { + const a = createFloatingRootContext({ ownerDocument: document }); + const b = createFloatingRootContext({ ownerDocument: document }); + const trigger = document.createElement('button'); + + a.triggers.add(trigger); + + expect(a.triggers.contains(trigger)).toBe(true); + expect(b.triggers.contains(trigger)).toBe(false); + }); + + it('matches a trigger exactly and by ancestor', () => { + const ctx = createFloatingRootContext({ ownerDocument: document }); + const trigger = document.createElement('button'); + const inner = document.createElement('span'); + trigger.appendChild(inner); + + ctx.triggers.add(trigger); + + expect(ctx.triggers.hasElement(trigger)).toBe(true); + expect(ctx.triggers.hasElement(inner)).toBe(false); + expect(ctx.triggers.hasMatchingElement(inner)).toBe(true); + expect(ctx.triggers.contains(inner)).toBe(true); + }); + + it('matches a trigger element from another document by reference (cross-realm-safe)', () => { + // Membership is by reference, not `instanceof Element`, so a trigger registered from a + // different document/realm (iframe) still matches. (Full cross-realm `instanceof` divergence + // only reproduces in a real browser; jsdom shares one realm.) + const otherDoc = document.implementation.createHTMLDocument('iframe'); + const foreignTrigger = otherDoc.createElement('button'); + const ctx = createFloatingRootContext({ ownerDocument: otherDoc }); + + ctx.triggers.add(foreignTrigger); + + expect(ctx.triggers.hasElement(foreignTrigger)).toBe(true); + expect(ctx.triggers.contains(foreignTrigger)).toBe(true); + expect(ctx.triggers.hasElement(document.createElement('button'))).toBe(false); + }); + }); +}); + +describe('RdxFloatingTree events', () => { + it('exposes a typed event channel private to the tree', () => { + const tree = new RdxFloatingTree(); + const received: { open: boolean }[] = []; + const listener = (data: { open: boolean }) => received.push(data); + + tree.events.on('openchange', listener); + tree.events.emit('openchange', { open: true }); + tree.events.off('openchange', listener); + tree.events.emit('openchange', { open: false }); + + expect(received).toEqual([{ open: true }]); + }); +}); diff --git a/packages/primitives/core/index.ts b/packages/primitives/core/index.ts index 7b231bcd..e12ff32a 100644 --- a/packages/primitives/core/index.ts +++ b/packages/primitives/core/index.ts @@ -14,6 +14,13 @@ export * from './src/provide-token'; export * from './src/signal-forms/form-control'; export * from './src/types'; +export * from './src/floating/floating-events'; +export * from './src/floating/floating-lifecycle'; +export * from './src/floating/floating-root-context'; +export * from './src/floating/floating-tree'; +export * from './src/floating/provide-floating-tree'; +export * from './src/floating/trigger-registry'; + export * from './src/dom/document'; export * from './src/dom/element-size'; export * from './src/dom/get-active-element'; diff --git a/packages/primitives/core/src/floating/floating-events.ts b/packages/primitives/core/src/floating/floating-events.ts new file mode 100644 index 00000000..2011f5a8 --- /dev/null +++ b/packages/primitives/core/src/floating/floating-events.ts @@ -0,0 +1,60 @@ +/** + * The typed event map for the shared floating channel. Neutral by design: it ships only the base + * `openchange` event, and each capability (hover-close, virtual focus, menu coordination, list + * navigation) **augments** this interface via module augmentation rather than emitting untyped strings: + * + * ```ts + * declare module '@radix-ng/primitives/core' { + * interface RdxFloatingEventMap { + * 'virtualfocus': { id: string; element: HTMLElement | null }; + * } + * } + * ``` + * + * Pinning the map up front (ADR 0015 §1, pillar 4) is what lets later consumers extend the channel + * type-safely instead of changing the fundamental tree API once `any` payloads have spread. + */ +export interface RdxFloatingEventMap { + /** + * The popup's open-state changed. Neutral, matching Base UI's tree events — the tree is + * scoped-by-default (one per coordinating root, not application-wide), so events do not leak + * across unrelated popups and an event need not carry node identity. `reason` mirrors Base UI's + * open-change reason strings. + */ + openchange: { open: boolean; reason?: string; event?: Event }; +} + +/** + * Neutral typed event channel shared by every floating capability — the Angular counterpart of Base + * UI's `FloatingTreeStore.events` (`createEventEmitter`), keyed by {@link RdxFloatingEventMap}. + */ +export interface RdxFloatingEvents { + emit(event: K, data: RdxFloatingEventMap[K]): void; + on(event: K, listener: (data: RdxFloatingEventMap[K]) => void): void; + off(event: K, listener: (data: RdxFloatingEventMap[K]) => void): void; +} + +/** + * Creates a {@link RdxFloatingEvents} emitter backed by a `Map>`, mirroring Base + * UI's implementation exactly: synchronous dispatch, set-deduplicated listeners, no replay. + */ +export function createFloatingEvents(): RdxFloatingEvents { + const listeners = new Map void>>(); + + return { + emit(event, data) { + listeners.get(event as string)?.forEach((listener) => listener(data as never)); + }, + on(event, listener) { + let set = listeners.get(event as string); + if (!set) { + set = new Set(); + listeners.set(event as string, set); + } + set.add(listener as (data: never) => void); + }, + off(event, listener) { + listeners.get(event as string)?.delete(listener as (data: never) => void); + } + }; +} diff --git a/packages/primitives/core/src/floating/floating-lifecycle.ts b/packages/primitives/core/src/floating/floating-lifecycle.ts new file mode 100644 index 00000000..fcaa422d --- /dev/null +++ b/packages/primitives/core/src/floating/floating-lifecycle.ts @@ -0,0 +1,37 @@ +/** + * The shared **mounted / open** lifecycle contract every floating capability reads (ADR 0015 §1, + * cross-cutting across ADRs 0015/0016/0017). It separates *presence* from *activeness*, which Base + * UI keeps distinct (`DialogStore` / `popupStoreUtils`): + * + * - **`mounted()`** — the node/popup is rendered. Stays `true` through an animated exit (Base UI's + * `FloatingFocusManager` is `disabled={!mounted}`, so the focus trap survives the exit). + * - **`open()`** — the popup is logically open. Flips to `false` **immediately** on close, *before* + * unmount. + * + * The interesting middle state is **closed-but-mounted** (`mounted() && !open()`): an animated exit, + * or a popup deliberately kept mounted after close. Its effects are **per-effect**, matching Base UI + * (it is *not* "everything off"): + * + * | concern | on `open() === false` (still mounted) | + * | -------------------------------- | ------------------------------------- | + * | dismissal (Escape/outside-press) | **inactive** — capability reads `open()` | + * | `markOthers` marker / aria-hidden| **released** | + * | internal backdrop | **`inert`** | + * | scroll lock | **unlocked** (predicate keys off `open()`) | + * | focus trap + return-focus | **persist until unmount** (read `mounted()`) | + * + * So every consumer reads `open()` for behavior and `mounted()` for presence — never conflating them. + * + * @remarks + * `preventUnmountOnClose` (Base UI's per-close one-shot that holds a popup *mounted after close*) is a + * **close-transaction** concern, not a standing boolean — modeling it as a persistent + * `signal` would pin the popup mounted forever after the first prevented close. Whether Radix + * exposes it publicly or maps it onto `RdxPortalPresence` (keep-mounted) is pinned in ADR 0015/0017 + * Phase 0; it is intentionally **not** baked into this contract. + */ +export interface RdxFloatingLifecycle { + /** Presence: the node/popup is rendered (stays `true` during an animated exit). */ + readonly mounted: () => boolean; + /** Activeness: the popup is open (flips `false` immediately on close, before unmount). */ + readonly open: () => boolean; +} diff --git a/packages/primitives/core/src/floating/floating-root-context.ts b/packages/primitives/core/src/floating/floating-root-context.ts new file mode 100644 index 00000000..46895729 --- /dev/null +++ b/packages/primitives/core/src/floating/floating-root-context.ts @@ -0,0 +1,92 @@ +import { isDevMode } from '@angular/core'; +import { rdxDevError } from '../dev/diagnostics'; +import { RdxTriggerRegistry } from './trigger-registry'; + +const DOCS = 'utils/floating-tree'; + +/** Initialization for a {@link RdxFloatingRootContext}. */ +export interface RdxFloatingRootContextInit { + ownerDocument: Document; + /** Popup open-state lifecycle. Defaults to `() => false`. */ + open?: () => boolean; + floatingElement?: HTMLElement | null; + referenceElement?: Element | null; +} + +/** + * The per-popup **root context / store** — the Angular counterpart of Base UI's `FloatingRootStore` + * (`FloatingRootContext`). It is a **separate entity from {@link RdxFloatingNode}** (which is only + * `id` + `parent` + a context ref), mirroring Base UI: the node carries tree membership, the root + * context carries the popup's `open`, elements, and trigger registry. + * + * Crucially it can exist **without** a node — the `getEmptyRootContext()` analog ({@link + * createFloatingRootContext}) — which is what lets a **node-optional** capability (Navigation Menu, + * ADR 0015 §1 / ADR 0017 #12) still read `open()`, `triggers`, and the elements while its tree node is + * temporarily absent. A dismissal/focus capability therefore references a root context **mandatorily** + * and a node **optionally**. + * + * `floatingElement` / `referenceElement` are exposed read-only and mutated **only** through the + * validated setters, so a consumer cannot bypass the owner-`Document` invariant with a raw assignment. + */ +export class RdxFloatingRootContext { + readonly ownerDocument: Document; + /** Neutral popup open-state lifecycle (singular). Tree traversal's `onlyOpen` filter reads this. */ + readonly open: () => boolean; + /** Per-popup trigger registry (Base UI `triggerElements`), read by both dismissal and focus. */ + readonly triggers = new RdxTriggerRegistry(); + + private floatingElementRef: HTMLElement | null = null; + private referenceElementRef: Element | null = null; + + constructor(init: RdxFloatingRootContextInit) { + this.ownerDocument = init.ownerDocument; + this.open = init.open ?? (() => false); + if (init.floatingElement !== undefined) { + this.setFloatingElement(init.floatingElement); + } + if (init.referenceElement !== undefined) { + this.setReferenceElement(init.referenceElement); + } + } + + /** The floating (popup) element, once it renders. `null` while mounted-but-not-yet-rendered. */ + get floatingElement(): HTMLElement | null { + return this.floatingElementRef; + } + + /** The reference (anchor / trigger) element the popup is positioned against. */ + get referenceElement(): Element | null { + return this.referenceElementRef; + } + + /** Assigns the floating element, validating it shares this context's `ownerDocument`. */ + setFloatingElement(element: HTMLElement | null): void { + this.assertSameDocument(element); + this.floatingElementRef = element; + } + + /** Assigns the reference element, validating it shares this context's `ownerDocument`. */ + setReferenceElement(element: Element | null): void { + this.assertSameDocument(element); + this.referenceElementRef = element; + } + + private assertSameDocument(element: Element | null): void { + if (isDevMode() && element !== null && element.ownerDocument !== this.ownerDocument) { + rdxDevError( + 'floating/cross-document-element', + "A floating element must share its root context's ownerDocument.", + DOCS + ); + } + } +} + +/** + * Creates a standalone {@link RdxFloatingRootContext} **without** a tree node — the Angular counterpart + * of Base UI's `getEmptyRootContext()`. Use it for a node-optional capability that needs a root context + * before (or without) registering a floating node. + */ +export function createFloatingRootContext(init: RdxFloatingRootContextInit): RdxFloatingRootContext { + return new RdxFloatingRootContext(init); +} diff --git a/packages/primitives/core/src/floating/floating-tree.ts b/packages/primitives/core/src/floating/floating-tree.ts new file mode 100644 index 00000000..d4cebb66 --- /dev/null +++ b/packages/primitives/core/src/floating/floating-tree.ts @@ -0,0 +1,369 @@ +import { isDevMode } from '@angular/core'; +import { rdxDevError } from '../dev/diagnostics'; +import { createFloatingEvents, RdxFloatingEvents } from './floating-events'; +import { RdxFloatingRootContext } from './floating-root-context'; + +/** Module-private mutable state for {@link RdxFloatingNode}, reachable only by {@link RdxFloatingTree}. */ +interface RdxFloatingNodeInternals { + parent: RdxFloatingNode | null; + context: RdxFloatingRootContext | null; +} + +/** Not exported — the only handle to a node's mutable state, so consumers cannot bypass the tree. */ +const nodeInternals = new WeakMap(); + +/** + * Module-private construction key. The {@link RdxFloatingNode} constructor requires it, and its type is + * a non-exported `unique symbol`, so consumers can neither name it (compile error) nor produce it + * (runtime guard) — a node can only be created through {@link RdxFloatingTree.register}, never a loose + * `new RdxFloatingNode(...)` that would exist outside `tree.all`. + */ +const NODE_CONSTRUCT_KEY = Symbol('RdxFloatingNode'); + +/** + * A neutral node in the shared floating tree — the Angular counterpart of Base UI's `FloatingNode` + * (`{ id, parentId, context? }`). It is deliberately **lightweight**: tree membership only. The + * popup's `open` state, trigger registry, and elements live on the separate {@link + * RdxFloatingRootContext}, exactly as Base UI splits `FloatingNode` from `FloatingRootStore`. + * + * `parent` and `context` are exposed **read-only**; they are mutated **only** through {@link + * RdxFloatingTree.setParent} / {@link RdxFloatingTree.setContext} (which enforce the cycle and + * owner-`Document` invariants). The backing state is held in a module-private `WeakMap`, so a consumer + * cannot reach around the tree with `node.parent = …` / `node.context = …`. + * + * `context` may be `null` (a contextless intermediate node). Open-ness is read from the context — there + * is no `open` on the node — and tree traversal's `onlyOpen` filter reads `node.context?.open()`, + * mirroring Base UI's `getNodeChildren` filtering on `child.context?.open`. Presence (`mounted`) is + * implicit: a node is mounted **iff** it is registered in the tree. + */ +export class RdxFloatingNode { + /** Which tree (store) this node belongs to — Base UI's `externalTree`. */ + readonly tree: RdxFloatingTree; + + /** @internal — constructed only by {@link RdxFloatingTree.register} (guarded by a module-private key). */ + constructor( + construct: typeof NODE_CONSTRUCT_KEY, + readonly id: string, + tree: RdxFloatingTree, + parent: RdxFloatingNode | null, + context: RdxFloatingRootContext | null + ) { + if (construct !== NODE_CONSTRUCT_KEY) { + rdxDevError( + 'floating/direct-node-construction', + 'RdxFloatingNode is created only by RdxFloatingTree.register().', + DOCS + ); + } + this.tree = tree; + nodeInternals.set(this, { parent, context }); + } + + /** Resolved **logical** parent (DI-derived). Reassign via {@link RdxFloatingTree.setParent}. */ + get parent(): RdxFloatingNode | null { + return nodeInternals.get(this)!.parent; + } + + /** The per-popup root context/store. `null` for a contextless node. Reassign via `tree.setContext`. */ + get context(): RdxFloatingRootContext | null { + return nodeInternals.get(this)!.context; + } +} + +/** + * Discriminated parent-assignment override (ADR 0015 §1). A nullable optional would collapse + * `undefined`/`null` in Angular signals, so "no override" (`inherit`) and "explicit independent root" + * (`root`) must be **distinct** values — they must not both reduce to `parent == null`. + */ +export type RdxFloatingParentOverride = + | { kind: 'inherit' } // default: resolve from the nearest DI floating context + | { kind: 'root' } // independent root: ignore DI ancestry, node.parent = null + | { kind: 'node'; parent: RdxFloatingNode }; // explicit parent (detached injector-subtree composition) + +/** Initialization for {@link RdxFloatingTree.register}. The caller resolves `inherit` before calling. */ +export interface RdxFloatingNodeInit { + id: string; + /** Already-resolved logical parent (`null` for a root). */ + parent: RdxFloatingNode | null; + /** The per-popup root context. `null` for a contextless intermediate node. */ + context: RdxFloatingRootContext | null; +} + +const DOCS = 'utils/floating-tree'; + +/** A node's open-state — read from its context (no `open` on the node itself). */ +function nodeIsOpen(node: RdxFloatingNode): boolean { + return node.context?.open() ?? false; +} + +/** + * The shared floating tree (node store) — the Angular counterpart of Base UI's `FloatingTreeStore`. + * + * It owns a flat list of {@link RdxFloatingNode | nodes} linked by `parent` and a neutral typed + * {@link RdxFloatingEvents | event channel}. It owns **neither** trigger registries **nor** `open` + * state — those live per-popup on each {@link RdxFloatingRootContext} (Base UI keeps them on the root + * store, not the tree store). Dismissal (ADR 0015) and the focus manager (ADR 0017) read the **same** + * nodes, traversal, and events; neither owns the tree. + * + * Ancestry is **logical** (DI-derived), not DOM-derived, so portal relocation never changes ownership + * (ADR 0015 §1). "Topmost within a tree" is the deepest open descendant — resolved here, never from + * DOM or construction order. Independent roots are **not** coordinated against each other (Base UI + * parity): the tree only answers questions *within* itself. + */ +export class RdxFloatingTree { + /** + * Neutral typed event channel (hover-close, virtual focus, menu coordination, list nav). Private to + * this tree, which is scoped-by-default (one per coordinating root via `provideFloatingTree()`), so + * events never leak across unrelated popups — matching Base UI's per-`FloatingTree` events. + */ + readonly events: RdxFloatingEvents = createFloatingEvents(); + + private readonly nodes: RdxFloatingNode[] = []; + + /** Registers a new node. `init.parent` must already be resolved (DI layer handles `inherit`). */ + register(init: RdxFloatingNodeInit): RdxFloatingNode { + // Structural integrity — ALWAYS (a foreign/unregistered parent corrupts the tree, not just dev misuse). + this.assertRegisterableParent(init.parent); + if (isDevMode()) { + // Validate the new context against the nearest context-bearing ancestor (through any + // contextless intermediates). A fresh node has no descendants yet. (dev-only — cheap check + // of correct usage, not a structural invariant.) + this.assertContextDocument(init.context, this.nearestContext(init.parent)); + } + + const node = new RdxFloatingNode(NODE_CONSTRUCT_KEY, init.id, this, init.parent, init.context); + this.nodes.push(node); + return node; + } + + /** Removes a node from the tree. Children are **not** removed — they keep their `parent` reference. */ + unregister(node: RdxFloatingNode): void { + if (isDevMode()) { + this.assertOwnedNode(node); + } + const index = this.nodes.indexOf(node); + if (index !== -1) { + this.nodes.splice(index, 1); + } + } + + /** + * Associates / re-associates / clears a node's root context after registration (Base UI attaches + * the context once the floating element resolves). Validates the new context's owner-`Document` + * against the nearest context-bearing **ancestor** (through contextless intermediates) **and** + * every context-bearing **descendant**, so a contextless intermediate can never bridge two + * documents. Allows the `null → context → null` lifecycle. + */ + setContext(node: RdxFloatingNode, context: RdxFloatingRootContext | null): void { + // Structural integrity — ALWAYS (mutating a foreign node corrupts another tree). + this.assertOwnedNode(node); + if (isDevMode() && context !== null) { + // dev-only: expensive ancestry/subtree document validation. + this.assertContextDocument(context, this.nearestContext(node.parent)); + for (const descendantContext of this.descendantContexts(node)) { + this.assertContextDocument(context, descendantContext); + } + } + + nodeInternals.get(node)!.context = context; + } + + /** Reparents an existing node (detached composition / explicit `node` override), with cycle guard. */ + setParent(node: RdxFloatingNode, parent: RdxFloatingNode | null): void { + // Structural integrity — ALWAYS (a foreign node, or a foreign/unregistered parent, corrupts the tree). + this.assertOwnedNode(node); + this.assertRegisterableParent(parent); + + // The cycle check is ALSO structural — a cycle would make traversal (children / deepestOpen / + // ancestors / nearestContext) recurse/loop forever — so it runs in production too. Walk the + // prospective parent chain (stopping at an unregistered node, like `ancestors`); reaching `node` + // means an ancestry cycle. O(depth). + for (let ancestor = parent; ancestor !== null && this.isRegistered(ancestor); ancestor = ancestor.parent) { + if (ancestor === node) { + rdxDevError( + 'floating/parent-cycle', + `Reparenting node "${node.id}" under "${parent?.id}" creates an ancestry cycle.`, + DOCS + ); + } + } + + if (isDevMode()) { + // dev-only: expensive full-subtree owner-document validation. The node's ENTIRE subtree must + // stay document-consistent with the new ancestry — check the node's own context AND every + // descendant context (a contextless subtree may hold several documents until a context + // bridges them). + const ancestorContext = this.nearestContext(parent); + this.assertContextDocument(node.context, ancestorContext); + for (const descendantContext of this.descendantContexts(node)) { + this.assertContextDocument(descendantContext, ancestorContext); + } + } + + nodeInternals.get(node)!.parent = parent; + } + + /** + * Direct + transitive children, in registration order. The `onlyOpen` filter (default `true`) + * filters the **result** by each node's `context?.open()` lifecycle but **never** aborts recursion + * at a closed node, so a keep-mounted/closed parent never hides an open grandchild (Base UI + * `getNodeChildren`, ADR 0015 §1 traversal contract). + * + * Dismissal and focus-out **containment** pass `onlyOpen: true` (Base UI `movedToUnrelatedNode` + * walks `getNodeChildren` with the default `onlyOpen=true`); only the focus-return / unmount path + * passes `onlyOpen: false`, so focus inside a mounted-but-closed descendant still counts as inside + * the tree (ADR 0017 #4 / #8, Base UI `FloatingFocusManager.tsx:842`). + */ + children(node: RdxFloatingNode, options: { onlyOpen?: boolean } = {}): RdxFloatingNode[] { + if (isDevMode()) { + this.assertOwnedNode(node); + } + const onlyOpen = options.onlyOpen ?? true; + const result: RdxFloatingNode[] = []; + + const collect = (parent: RdxFloatingNode): void => { + for (const candidate of this.directChildren(parent)) { + if (!onlyOpen || nodeIsOpen(candidate)) { + result.push(candidate); + } + // Recurse regardless of the candidate's open-ness (never abort at a closed node). + collect(candidate); + } + }; + + collect(node); + return result; + } + + /** + * Logical ancestors of `node`, nearest first (Base UI `getNodeAncestors`). The walk **stops at an + * unregistered node**: Base UI resolves ancestry by `parentId` lookup in the live nodes array, so + * unregistering a parent breaks the chain (a removed middle node truncates ancestry — its children + * keep the raw `parent` identity but it no longer appears as an ancestor). This avoids a "ghost" + * ancestor lingering in DI-ownership / document / dismissal traversal when Angular destroys a parent + * before its child. + */ + ancestors(node: RdxFloatingNode): RdxFloatingNode[] { + if (isDevMode()) { + this.assertOwnedNode(node); + } + const result: RdxFloatingNode[] = []; + for (let current = node.parent; current !== null && this.isRegistered(current); current = current.parent) { + result.push(current); + } + return result; + } + + /** + * The deepest **open** descendant of `node` — "topmost within the tree" for Escape/outside-press + * ownership (Base UI `getDeepestNode`). Returns `null` when `node` has no open descendant. + */ + deepestOpen(node: RdxFloatingNode): RdxFloatingNode | null { + if (isDevMode()) { + this.assertOwnedNode(node); + } + let deepest: RdxFloatingNode | null = null; + let maxDepth = -1; + + const visit = (current: RdxFloatingNode, depth: number): void => { + if (depth > maxDepth) { + maxDepth = depth; + deepest = current; + } + for (const child of this.directChildren(current)) { + if (nodeIsOpen(child)) { + visit(child, depth + 1); + } + } + }; + + // Start below `node`: only its own open descendants qualify as "topmost". + for (const child of this.directChildren(node)) { + if (nodeIsOpen(child)) { + visit(child, 0); + } + } + + return deepest; + } + + /** Snapshot of all registered nodes (debugging / diagnostics). */ + get all(): readonly RdxFloatingNode[] { + return this.nodes; + } + + /** Direct children of `node`, in registration order. */ + private directChildren(node: RdxFloatingNode): RdxFloatingNode[] { + return this.nodes.filter((candidate) => candidate.parent === node); + } + + /** + * Nearest context-bearing node walking up from `node` (inclusive), skipping contextless ancestors. + * Stops at an unregistered node (same ghost-ancestry rule as {@link ancestors}). + */ + private nearestContext(node: RdxFloatingNode | null): RdxFloatingRootContext | null { + for (let current = node; current !== null && this.isRegistered(current); current = current.parent) { + if (current.context !== null) { + return current.context; + } + } + return null; + } + + /** All contexts among `node`'s transitive descendants (skips `node` itself). */ + private descendantContexts(node: RdxFloatingNode): RdxFloatingRootContext[] { + return this.children(node, { onlyOpen: false }) + .map((descendant) => descendant.context) + .filter((context): context is RdxFloatingRootContext => context !== null); + } + + /** + * Guards that `node` actually belongs to **this** tree and is still registered — so a tree can + * never mutate/traverse a node owned by another tree (which would leave `node.tree` pointing + * elsewhere while its ancestry leads here) or one that was already unregistered. + */ + /** Whether `node` is currently registered in this tree. */ + private isRegistered(node: RdxFloatingNode): boolean { + return this.nodes.includes(node); + } + + private assertOwnedNode(node: RdxFloatingNode): void { + if (node.tree !== this || !this.isRegistered(node)) { + rdxDevError( + 'floating/foreign-node', + 'This node does not belong to this tree (or was already unregistered).', + DOCS + ); + } + } + + /** A parent (when given) must belong to this tree **and** still be registered. */ + private assertRegisterableParent(parent: RdxFloatingNode | null): void { + if (parent !== null) { + if (parent.tree !== this) { + rdxDevError('floating/cross-tree-parent', 'A floating node parent must belong to the same tree.', DOCS); + } + if (!this.isRegistered(parent)) { + rdxDevError( + 'floating/unregistered-parent', + 'A floating node parent must be currently registered in the tree.', + DOCS + ); + } + } + } + + /** Owner-`Document` consistency between a node's context and a related (ancestor/descendant) context. */ + private assertContextDocument( + context: RdxFloatingRootContext | null, + relatedContext: RdxFloatingRootContext | null + ): void { + if (context !== null && relatedContext !== null && context.ownerDocument !== relatedContext.ownerDocument) { + rdxDevError( + 'floating/cross-document-parent', + 'A floating node must share the same ownerDocument as its ancestry/subtree.', + DOCS + ); + } + } +} diff --git a/packages/primitives/core/src/floating/provide-floating-tree.ts b/packages/primitives/core/src/floating/provide-floating-tree.ts new file mode 100644 index 00000000..a145b54d --- /dev/null +++ b/packages/primitives/core/src/floating/provide-floating-tree.ts @@ -0,0 +1,58 @@ +import { inject, InjectionToken, Provider } from '@angular/core'; +import { RdxFloatingNode, RdxFloatingParentOverride, RdxFloatingTree } from './floating-tree'; + +/** + * The nearest shared {@link RdxFloatingTree}. **Scoped-by-default, sharing explicit** — strict Base UI + * parity: Base UI creates a `FloatingTree` only where nested floating elements must coordinate. There is + * deliberately **no** application-root provider, so the token resolves only under a root that opts in + * with {@link provideFloatingTree}; elsewhere injecting it optionally yields `null` and the primitive is + * its own independent root (`parent === null`). This keeps each tree's `events` channel private to its + * coordinating subtree (no app-wide leak) without forcing node identity onto every event. + * + * A nesting-capable root (Menu/Menubar/nested Dialog) provides one tree for its descendants; a + * standalone popup needs no shared tree at all. + */ +export const RDX_FLOATING_TREE = new InjectionToken('RdxFloatingTree'); + +/** + * Provides a {@link RdxFloatingTree} for a subtree — the Angular `FloatingTree` analogue. A + * nesting-capable root puts this in its `providers` so descendants join the same tree. + */ +export function provideFloatingTree(): Provider { + return { provide: RDX_FLOATING_TREE, useFactory: () => new RdxFloatingTree() }; +} + +/** + * The nearest enclosing {@link RdxFloatingNode}, used to resolve a child's **logical** parent + * (`inherit`). A floating directive provides itself under this token so descendants reparent to it + * regardless of where the portal relocates them in the DOM — the Angular analogue of Base UI's + * `FloatingNodeContext` (`parentId`). + * + * This is **tree selection / parent assignment only**: a detached *trigger* is never provided here + * (it has no node, ADR 0015 §1/§2). + */ +export const RDX_FLOATING_NODE = new InjectionToken('RdxFloatingNode'); + +/** + * Resolves a {@link RdxFloatingParentOverride} to a concrete parent node, in an injection context: + * + * - `inherit` (default) → the nearest enclosing {@link RDX_FLOATING_NODE} (DI ancestry), or `null`; + * - `root` → `null` (independent root — DI ancestry ignored); + * - `node` → the explicitly supplied parent (detached injector-subtree composition). + * + * Keeping `inherit` and `root` distinct is the whole point of the discriminated override: "no + * override" and "explicit independent root" must not both collapse to `parent == null` by accident. + */ +export function resolveFloatingParent( + override: RdxFloatingParentOverride = { kind: 'inherit' } +): RdxFloatingNode | null { + switch (override.kind) { + case 'root': + return null; + case 'node': + return override.parent; + case 'inherit': + default: + return inject(RDX_FLOATING_NODE, { optional: true, skipSelf: true }); + } +} diff --git a/packages/primitives/core/src/floating/trigger-registry.ts b/packages/primitives/core/src/floating/trigger-registry.ts new file mode 100644 index 00000000..e25a1f34 --- /dev/null +++ b/packages/primitives/core/src/floating/trigger-registry.ts @@ -0,0 +1,60 @@ +/** + * Per-popup store of active **trigger** elements — the Angular counterpart of Base UI's + * `triggerElements` (`PopupTriggerMap`) on each `FloatingRootStore`. One registry lives on each + * {@link RdxFloatingRootContext} (NOT on the shared tree, and NOT on the node — the context can exist + * without a node, e.g. the node-optional Navigation Menu case). Scoping it per-context is what keeps + * one independent popup's trigger from counting as inside-content for an unrelated popup. + * + * Within its context it is read by **both** the dismissal engine (ADR 0015 — outside-press / focus-out + * containment) and the focus manager (ADR 0017 — inside-element checks), so the two never drift into + * different inside-element sets (ADR 0015 §1 pillar 3, §2). A trigger is plain inside-content: it has + * **no** floating node and **no** parent — only its membership is stored here. + * + * Matching mirrors Base UI's `isTargetInsideEnabledTrigger`: a target counts when it is exactly a + * registered element ({@link hasElement}) **or** a descendant of one ({@link hasMatchingElement}). + * Membership is by **reference** (`Set.has` / `Node.contains`), so it stays correct **cross-realm** for + * elements from another `Window` / iframe — where `target instanceof Element` against the local realm + * would wrongly return `false`. + */ +export class RdxTriggerRegistry { + private readonly elements = new Set(); + + /** Registers `element` as a trigger. Idempotent. */ + add(element: Element): void { + this.elements.add(element); + } + + /** Removes `element` from the registry. */ + delete(element: Element): void { + this.elements.delete(element); + } + + /** + * Exact membership — Base UI `triggerElements.hasElement(target)`. Uses reference identity + * (`Set.has`), **not** `instanceof Element`, so a trigger from another realm/iframe still matches. + */ + hasElement(target: EventTarget | Node | null): boolean { + return target !== null && this.elements.has(target as Element); + } + + /** + * Ancestor match — Base UI `hasMatchingElement((t) => contains(t, target))`: `true` when any + * registered trigger contains `target`. Catches a press/focus landing on a child of the trigger. + */ + hasMatchingElement(target: Node | null): boolean { + if (!target) { + return false; + } + for (const element of this.elements) { + if (element.contains(target)) { + return true; + } + } + return false; + } + + /** `true` when `target` is a registered trigger or lives inside one. */ + contains(target: EventTarget | Node | null): boolean { + return this.hasElement(target) || this.hasMatchingElement(target as Node | null); + } +} From 0246f971ecfddf5acaa5c4faa0cd961404387ab2 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sun, 14 Jun 2026 22:26:32 +0300 Subject: [PATCH 02/35] fix: tri-state, DI and etc --- .../0015-base-ui-aligned-dismissal-engine.md | 340 ++++++++++---- docs/adr/0017-floating-focus-manager.md | 436 ++++++++++++++---- .../__tests__/floating-registration.spec.ts | 266 +++++++++++ .../core/__tests__/floating-tree.spec.ts | 175 +++++-- .../core/__tests__/use-scroll-lock.spec.ts | 104 +++++ packages/primitives/core/index.ts | 1 + .../core/src/dom/use-scroll-lock.ts | 74 ++- .../core/src/floating/floating-events.ts | 64 ++- .../src/floating/floating-registration.ts | 116 +++++ .../src/floating/floating-root-context.ts | 8 + .../core/src/floating/floating-tree.ts | 186 ++++---- .../src/floating/provide-floating-tree.ts | 96 ++-- ...dismissable-layer-characterization.spec.ts | 129 ++++++ 13 files changed, 1629 insertions(+), 366 deletions(-) create mode 100644 packages/primitives/core/__tests__/floating-registration.spec.ts create mode 100644 packages/primitives/core/__tests__/use-scroll-lock.spec.ts create mode 100644 packages/primitives/core/src/floating/floating-registration.ts create mode 100644 packages/primitives/dismissable-layer/__tests__/dismissable-layer-characterization.spec.ts diff --git a/docs/adr/0015-base-ui-aligned-dismissal-engine.md b/docs/adr/0015-base-ui-aligned-dismissal-engine.md index bd68d7e3..6be8af65 100644 --- a/docs/adr/0015-base-ui-aligned-dismissal-engine.md +++ b/docs/adr/0015-base-ui-aligned-dismissal-engine.md @@ -102,34 +102,53 @@ Each mounted floating element registers a **neutral** node in a **shared floatin ```ts // Shared floating infrastructure (a core/floating package) — see "Shared infrastructure" below. +// Base UI splits the lightweight tree NODE from the per-popup ROOT STORE; we mirror that exactly. interface RdxFloatingTree { /* node store; register/unregister, query children/ancestors (traversal below) */ // Typed event channel — Base UI's `FloatingTreeStore.events`, used for hover-close, virtual // focus, menu open/close coordination, and list navigation. Neutral, not dismissal-specific. events: RdxFloatingEvents; - triggers: RdxTriggerRegistry; // shared trigger registry (§2) — read by dismissal AND focus + // NOTE: neither `triggers` nor `open` live on the tree — both are per-popup on RdxFloatingRootContext. } +// Lightweight node = tree membership only (Base UI `FloatingNode`: id, parentId, context?). interface RdxFloatingNode { id: string; tree: RdxFloatingTree; // which store this node belongs to (Base UI `externalTree`) parent: RdxFloatingNode | null; // resolved logical parent (resolution rules below) - element: HTMLElement | null; + context: RdxFloatingRootContext | null; // per-popup store; `null` for a contextless intermediate. + // Associated AFTER registration and re-settable (Base UI attaches the context once the element + // resolves) via tree.setContext(node, ctx) — lifecycle `null → context → null`, owner-`Document` + // validated across ancestry AND subtree (so a contextless node can't bridge two documents). +} + +// Per-popup root store (Base UI `FloatingRootStore` / `FloatingRootContext`). CAN EXIST WITHOUT A NODE +// (`getEmptyRootContext()` analog) — this is what makes the node-optional NavMenu case work. +interface RdxFloatingRootContext { ownerDocument: Document; - // NOTE: no `open` / `active` field — open-ness is a per-capability property (below). + open: () => boolean; // ONE neutral popup open-state; traversal's `onlyOpen` reads `node.context?.open()` + triggers: RdxTriggerRegistry; // per-popup (§2) — read by both dismissal and focus of THIS popup + floatingElement: HTMLElement | null; // read-only; assigned via validated setter (owner-Document checked) + referenceElement: Element | null; // read-only; assigned via validated setter } ``` -**The node is mounted-state; "open/active" is a per-capability property — they are not the same.** Base -UI keeps `open` on a node's `context` (capability), not the node, so a popup can be **mounted but closed** -(keep-mounted / animated exit) or a node can be a contextless intermediate. Each capability exposes its -own `open`/`active`: +**Node vs root context — they are distinct, exactly as in Base UI.** A node is **mounted** iff it is +registered (so a popup can be **mounted but closed** — keep-mounted / animated exit — or be a contextless +intermediate). Open-ness, the trigger registry, and the elements all live on the **root context**, not the +node; tree traversal's `onlyOpen` filter reads **`node.context?.open()`**, **never** an OR over attached +capabilities (that conflates independent capabilities — an early foundation bug, fixed). A capability's +**own** active-ness (does _this_ capability handle events) is a separate `active()` it owns, distinct from +the popup's `open()`. The split is what keeps the **node-optional** case sound: a capability references a +**root context mandatorily** and a **node optionally**, so Navigation Menu can read `open()`/`triggers` +from a standalone context while its tree node is temporarily absent. ```ts -// 0015-owned capability attached to an RdxFloatingNode. +// 0015-owned capability. References a ROOT CONTEXT (mandatory) + a NODE (optional, #8/NavMenu). interface RdxDismissableCapability { - node: RdxFloatingNode | null; // node-OPTIONAL: may be absent in a contextless/transient state (#8, NavMenu) - open: () => boolean; // active-ness lives on the capability, not the node + context: RdxFloatingRootContext; // mandatory — open/triggers/elements live here, node-or-not + node: RdxFloatingNode | null; // node-OPTIONAL: absent in a contextless/transient state (#8, NavMenu) + active: () => boolean; // this capability's active-ness (≈ context.open() && enabled) layer: RdxDismissableLayer; branches: Set; policy: RdxDismissableLayerPolicy; @@ -203,15 +222,35 @@ _result_ yet the walk **still descends into its children** (so a keep-mounted/cl open grandchild). Mirroring Base UI's `getNodeChildren(nodes, id, onlyOpenChildren)`: ```ts -// filters the *result* by open-capability; recursion continues regardless. +// filters the *result* by node.context?.open(); recursion continues regardless. children(node, { onlyOpen?: boolean }): RdxFloatingNode[]; ancestors(node): RdxFloatingNode[]; -deepestOpen(node): RdxFloatingNode | null; // topmost-within-tree = deepest open descendant (§1) ``` +**Dismissal ownership is per-node, not a global selector (verified `useDismiss.ts:170,214`).** Base UI +does **not** pick one deepest/"topmost" node. Every open node's `useDismiss` handler runs and closes +**unless** `!escapeKeyBubbles && hasBlockingChild('__escapeKeyBubbles')` — i.e. it defers only when it +has its **own** open descendant that does not bubble. Two open **siblings** in one tree **both** respond +to Escape / outside-press. `hasBlockingChild` is a **local function inside `useDismiss`** (`useDismiss.ts:170`), +not a tree API — in Angular it lives in `RdxDismissableCapability` as: +`tree.children(node, { onlyOpen: true }).some(isBlocking)`. A global `deepestOpen` selector would +reintroduce the old single-active-layer stack under a new name and must not be added. (`getDeepestNode` +exists in Base UI's `nodes.ts:18` but is deliberately **not** called by `useDismiss` — confirmed.) + +**Unregister lifecycle — no ghost ancestry (verified against `getNodeAncestors`).** Base UI resolves +ancestry by `parentId` lookup in the **live** nodes array (`nodes.find(n => n.id === currentParentId)`, +`nodes.ts:45`), so unregistering a node **breaks the chain**: a removed middle node truncates its +descendants' ancestry (they keep the raw `parent` identity but it is no longer a traversable ancestor — +the walk **stops**, it does not skip to the grandparent). We match this: `ancestors()` / `nearestContext()` +**stop at an unregistered node**, so a parent that Angular destroys before its child cannot linger as a +ghost ancestor influencing DI-ownership, document context, or dismissal/focus traversal. Correspondingly, +`register()` / `setParent()` **reject an unregistered (or foreign) parent** — you cannot attach under a node +that has already left the tree. + **Focus-return uses `onlyOpen: false` — it must include closed-but-mounted descendants (#4, verified).** -The `onlyOpen` filter is **not** one global default. Dismissal "topmost"/children queries use -`onlyOpen: true`, but the focus manager's **focus-inside-tree** check on unmount/close walks +`tree.children()` defaults `onlyOpen` to `true` (the dismissal default), but the focus manager's +**focus-inside-tree** check +on unmount/close walks `getNodeChildren(tree, nodeId, false)` (`FloatingFocusManager.tsx:842`) — `onlyOpen=false`, so focus living inside a **mounted-but-closed** descendant still counts as "inside the floating tree" and can govern whether return-focus runs. The shared traversal must therefore expose **both** filters and the ADR 0017 focus-return @@ -251,19 +290,39 @@ type RdxFloatingParentOverride = The point is that "no override" (`inherit`) and "explicit independent root" (`root`) are **distinct** — they must not both reduce to `parent == null`. +**`{ kind: 'root' }` is NOT tree isolation — it is `parent = null` _within the current tree_.** Tree +selection is a **separate** contract: `resolveFloatingTree(externalTree?)` = `externalTree ?? nearest +injected RDX_FLOATING_TREE` (Base UI `externalTree ?? contextTree`). The parent override **never** selects +the tree. Two consequences pinned for the implementation: (a) a node made `{ kind: 'root' }` is still a node +of the **same** tree (it just has no parent there) — to put a node in a genuinely separate store, supply an +explicit `externalTree`; (b) for a detached `{ kind: 'node', parent }` from a **sibling injector**, the +nearest injected tree may be absent or a different tree than `parent.tree`, so the registrant **must** pass +`externalTree = parent.tree` (so the node joins its parent's tree and the cross-tree invariant holds) — +relying on the nearest injected tree would throw `cross-tree-parent` or mis-place the node. + This override applies to **floating nodes only**. A detached _trigger_ is a different mechanism: it has no node and no parent — it registers as an inside-element with the layer it controls through the scoped registrar of §2, so pressing or focusing it does not dismiss its popup. Do not register a trigger as a node parent; the two paths must stay distinct in the implementation. -**Tree/document invariants (dev-mode diagnostics).** Detached-composition mistakes must surface as -**early diagnostics**, not as wrong dismissal/focus ownership later. The registration API must reject (or -`rdxDevError`): - -- a `parent` that belongs to a **different `tree`**; -- a `parent` in a **different `ownerDocument`**; -- an **ancestry cycle** (the new parent chain reaching back to the node); -- registering one node in **more than one tree** at a time. +**Tree/document invariants — split by whether a violation corrupts structure.** Detached-composition +mistakes must surface as **early diagnostics**, not as wrong dismissal/focus ownership later. But the +checks are **not** uniformly dev-only: a check whose violation would **corrupt the tree's internal +structure** runs in **every** build (production included), because skipping it leaves a broken tree, not +just an undiagnosed misuse. Only the **expensive** correctness checks are gated behind `isDevMode()`. + +- **Always-on (structural integrity)** — a node passed to a mutator (`setParent`/`setContext`) must + belong to **this** tree and still be **registered**; a `parent` (`register`/`setParent`) must belong to + this tree and be registered; and `setParent` must **reject an ancestry cycle**. Violating any of these + corrupts the tree: a foreign/ghost parent, a mutation of another tree's node, or — for a cycle — a + `children`/`ancestors`/`nearestContext` traversal that **recurses/loops forever**. The cycle walk is + O(depth), cheap enough to keep on in production. +- **Dev-only (`isDevMode`)** — the **owner-`Document`** consistency check across the ancestry **and** + subtree (O(subtree)), and registering one node in more than one tree. These catch misuse without being + load-bearing for structural integrity. + +(Read-only traversal — `children`/`ancestors` — keeps its ownership check dev-only: a foreign node +there yields a wrong result, not corruption.) **Owner-`Document` relocation rule (single normative decision).** Moving a portal's nodes **within the same `Document`** is allowed (that is the normal portal case, and DI ancestry is unaffected). @@ -289,20 +348,25 @@ exit. Our `RdxPopperContentWrapper.autoUpdate` already lives for the directive's **parity, kept as-is**: no `active` input and no freeze-on-`open=false`. If a future primitive needs a frozen exit, that becomes a separate Popper capability, not a dismissal concern. -**The portal registry is DOM-inside-checks only — it does not define ancestry.** The scoped portal registry -(ADR 0017 §6a) exists so a parent can read its descendant portals' DOM roots for keep-sets / outside-press -containment. It does **not** establish logical floating ancestry: `RdxFloatingNode.parent` (DI-derived) -remains the **sole** source of dismissal ownership, independent of where nodes are appended in the DOM. +**The portal registry is for `markOthers` keep-sets only — never for dismissal.** The scoped portal registry +(ADR 0017 §6a) exists so a parent can read its descendant portals' DOM roots for **`markOthers` / aria-hidden +keep-sets** (ADR 0017). It is **not** a dismissal-inside source and **not** used for outside-press containment +(§4: "inside" is the floating tree + trigger/branch/marker, **never** portal-registry membership — Base UI +`useDismiss.ts` reads no `PortalContext`), and it does **not** establish logical floating ancestry: +`RdxFloatingNode.parent` (DI-derived) remains the **sole** source of dismissal ownership, independent of +where nodes are appended in the DOM. -`RdxFloatingNode.parent` (logical ancestry) drives all dismissal ownership. Within a logical -tree, "topmost" is the deepest active descendant, resolved from the tree (ancestry + blocking-child) — -never from DOM or construction order. +`RdxFloatingNode.parent` (logical ancestry) drives all dismissal ownership. Each open node handles +its own Escape and outside-press; a node defers **only** when it has a non-bubbling open descendant +(`hasBlockingChild` pattern, implemented in the capability — **not** a tree API and **not** a global +"deepest open" selector). **Independent roots are not coordinated by the engine — strict Base UI parity.** Base UI's `useDismiss` does not coordinate independent floating trees against each other: each open independent root handles its own Escape and outside-press. We deliberately match that and add **no** document-scoped activation -order across unrelated roots. The engine resolves "topmost" only **within** a tree; ordering between -independent roots is the concern of the owning primitive or the application, not the dismissal engine. +order across unrelated roots. Each open node within a tree handles its own event independently — there +is no global "topmost" selector; ordering between independent roots is the concern of the owning +primitive or the application, not the dismissal engine. This is an intentional change from the current `RdxDismissableLayer`, whose shared `layersRoot` makes Escape close only the last-registered layer across **all** roots. Dropping that shared order means: with @@ -313,8 +377,7 @@ primitive layer), exactly as Base UI leaves it to the consumer. The engine must answer these questions without querying all `[data-dismissable-layer]` elements: -- Is this layer the topmost active layer **within its tree**? (logical ancestry, not cross-root order) -- Does this layer have an active blocking descendant? +- Does this node have an open, non-bubbling descendant (`hasBlockingChild` pattern in the capability) that should handle the event instead? - Is an event inside this layer, one of its branches, its trigger, or an active descendant? - Should Escape or outside press propagate to an ancestor? @@ -329,10 +392,15 @@ node store: 1. **Neutral nodes** — `RdxFloatingTree` / `RdxFloatingNode` (no dismissal name, no baked-in `layer: RdxDismissableLayer`). -2. **Typed capabilities** bound to a node — dismissal (`RdxDismissableCapability`, this ADR), focus (ADR - 0017), and future hover/list-navigation each attach their own; each owns its `open`/`active`. -3. **A shared trigger registry** on the tree/root (Base UI `context.triggerElements`, §2) — read by - **both** dismissal and the focus manager, so they never keep divergent inside-element lists. +2. **Typed capabilities** that reference a node — dismissal (`RdxDismissableCapability`, this ADR), focus + (ADR 0017), and future hover/list-navigation each own their `active()`, distinct from the node's single + `open()` lifecycle. +3. **A per-popup trigger registry** (Base UI `triggerElements` on each `FloatingRootStore`, §2) — one per + **root context** (`RdxFloatingRootContext`, which can exist without a node), read by **both** that + popup's dismissal and focus, so they never keep divergent inside-element lists. It is **not** tree-wide + (that would leak one popup's trigger into an unrelated popup's inside-set) and **not** on the node + (the context outlives / precedes the node). Membership matching is **cross-realm-safe** (reference + identity, not `instanceof`), for triggers in another `Window`/iframe. 4. **Typed event channels** — Base UI's `FloatingTreeStore.events` (hover-close, virtual focus, menu open/close coordination, list navigation). Pin a neutral typed emitter on the tree now, rather than bolting one on later (which would change the fundamental tree API). @@ -433,8 +501,9 @@ cancelable while silently ignoring `preventDefault()`. Defaults preserve intended current public behavior, except where this ADR explicitly fixes observable bugs: -- Within a tree, Escape dismisses only the topmost blocking layer. Independent roots are not - coordinated by the engine (§1) — each handles its own Escape. +- Within a tree, each open node handles Escape unless it has a non-bubbling open descendant + (`hasBlockingChild` pattern, capability-owned). Independent roots are not coordinated by the engine + (§1) — each handles its own Escape. - Pointer interaction inside a child layer does not dismiss its ancestors. This holds today only for modal children (via pointer-events layering); the tree makes it hold for non-modal children too, fixing the latent bug captured by the Phase 0 known-bug target. @@ -786,16 +855,17 @@ non-parity transitional behavior. ## Implementation Plan -### Phase -1: Document-scope `core/useScrollLock` (prerequisite) - -- Convert `packages/primitives/core/src/dom/use-scroll-lock.ts` from module-level `original` / - `scrollLockCount` to `WeakMap` with a browser guard. **All** mutable state - (the saved original, the count — and, once ADR 0016 lands the behavioral port, snapshots/timers/frames/ - restore) lives on the per-`Document` state, never at module scope (ADR 0016 §1). -- Verify scroll locking is isolated per document and SSR-safe for **every** `useScrollLock` caller: - Dialog, Menu, Popover, Select, Combobox, and Autocomplete. -- This may land as its own small change before Phase 1; the dismissal engine's per-`Document` isolation - is incomplete without it. +### Phase -1: Document-scope `core/useScrollLock` (prerequisite) — ✅ LANDED + +- ✅ **Done.** `packages/primitives/core/src/dom/use-scroll-lock.ts` converted from module-level `original` / + `scrollLockCount` to `WeakMap` (`{ original, count }` per `Document`) with an + `isPlatformBrowser(PLATFORM_ID)` guard (no-op on the server). **All** mutable state lives on the + per-`Document` state, never at module scope (ADR 0016 §1); the behavioral-parity additions + (snapshots/timers/frames/restore) land on the same state in ADR 0016. +- ✅ Tested (`core/__tests__/use-scroll-lock.spec.ts`): lock/restore, per-`Document` isolation (an iframe + document lock does not touch the main document), shared per-document count composition (nested overlays), + and server no-op. Covers all `useScrollLock` callers (Dialog, Menu, Popover, Select, Combobox, + Autocomplete) since they share the one utility. - Scope: **per-`Document` correctness only.** Base UI scroll-lock behavioral parity (scroll-position, gutter, resize, pinch-zoom, owner element) is ADR 0016 (§6/§9), not this phase. @@ -804,14 +874,16 @@ non-parity transitional behavior. Split the tests by whether they describe behavior that currently passes or a known bug. Do not assert a single blanket "nested child does not dismiss the parent" — that holds today only for some variants. -**Characterization (must pass against current code, must keep passing):** +**Characterization (must pass against current code, must keep passing) — ✅ baseline LANDED:** -- topmost Escape dismissal; -- branch pointer and focus interaction; -- associated trigger interaction does not dismiss its popup; -- nested **modal** child: pointer interaction does not dismiss the parent (protected today by - pointer-events layering); -- stacked `disableOutsidePointerEvents` restoration. +- ✅ topmost Escape dismissal (`dismissable-layer-stack.spec.ts`); +- ✅ branch pointer and focus interaction (`dismissable-layer-characterization.spec.ts`); +- ✅ associated trigger interaction does not dismiss its popup (registered as a branch today, same spec); +- ✅ nested **modal** child: pointer interaction does not dismiss the parent (same spec; protected today by + pointer-events layering + DOM nesting); +- ✅ stacked `disableOutsidePointerEvents` restoration (`dismissable-layer-stack.spec.ts`). + +These now lock the pre-refactor behavior so Phase 1 can be proven behavior-preserving. **Known-bug targets — the suite must stay green, so these are NOT committed as failing tests:** @@ -835,15 +907,99 @@ commit, with the corrected behavior asserted the moment the fix lands. Nested portal, scrollbar, and real pointer-gesture cases belong in Playwright behavior tests because jsdom does not provide trustworthy layout or pointer behavior. -### Phase 1: Explicit tree and document registry - -- Add the layer-node/context and document-scoped registry. -- Replace DOM-order `isLayerExist`. -- Derive ancestry and within-tree "topmost" from the logical tree. Add **no** cross-root activation - order — independent roots stay independent (Base UI parity). -- Add the explicit parent-override registration handle for detached composition. -- Scope branches to their owner. -- Replace the global body pointer-events variable with the document registry. +### Phase 1: Dismissal capability on the shared tree + document registry + +> The neutral shared floating **foundation already exists** in `@radix-ng/primitives/core` +> (`core/src/floating`): `RdxFloatingTree` / `RdxFloatingNode` / `RdxFloatingRootContext`, traversal +> `children`/`ancestors`, `RdxTriggerRegistry`, `RdxFloatingEvents`, the DI seams +> (`RDX_FLOATING_TREE`, `RDX_FLOATING_ROOT_CONTEXT`, `resolveFloatingTree`, `injectFloatingRootContext`, +> `RdxFloatingRegistrationContext`, `RDX_FLOATING_REGISTRATION`, `provideFloatingRegistration`), and the +> structural/dev invariants. Phase 1 **consumes** it — do **not** re-implement the +> node/context/tree/ancestry/parent-override or the handle propagation contract. + +**Root-context ownership (decided — Base UI parity, `useDismiss.ts:117`).** `useDismiss` **receives** an +existing `FloatingRootContext`; it never creates one. So a **primitive root** (Dialog/Popover/Menu/…) +creates **one** `RdxFloatingRootContext` and provides it via `provideFloatingRootContext`; the dismissal +capability **and** the focus manager (ADR 0017) read that **same** context (one `open` / `triggers` / +elements). A **standalone** `rdxDismissableLayer` creates a fallback context **only when none is provided** +(`injectFloatingRootContext(fallback)`). `RDX_FLOATING_TREE` is **optional**: with no enclosing tree the +capability runs **node-optional** (`node === null`), reading its context directly. + +**Standalone fallback-context lifecycle (decided — must not be inert).** `createFloatingRootContext()` +defaults `open: () => false`, so the fallback **must** be configured or a bare `rdxDismissableLayer` would +never dismiss. The fallback is built with: **`open: () => true` for the directive's lifetime** (a standalone +layer has no separate open-state — it is active whenever mounted; if a consumer needs to toggle it, that is +an explicit `enabled`/`open` input, not the default), **`ownerDocument` = the host element's +`ownerDocument`**, and **`floatingElement` = the host element** (via `setFloatingElement`). So a standalone +layer is active-while-mounted with its own element as the inside-surface. + +**Provider migration vs the legacy stack (decided — no broken intermediate).** Replacing `layersRoot` is +**unsafe until** each primitive root provides `provideFloatingTree()` + `provideFloatingRootContext()` — +otherwise every layer resolves `node === null`, becomes an independent root, and **loses nested ownership** +(parent Dialog/Menu would no longer stop responding while a nested child is open). A **per-primitive +incremental** switchover would also create unsound mixed states — legacy-parent + migrated-child, +migrated-parent + legacy-child, or a branch registered only in the new capability while the **legacy** engine +still reads events — so "no broken intermediate" would not hold automatically. + +**Decision: ATOMIC cutover — no dual-run, no compatibility bridge.** Phases 1–3 build the new tree/capability +engine **in parallel**, unit-tested standalone, **without altering the live legacy path**: the legacy +`RdxDismissableLayer` + `layersRoot` + global `context.branches` stay authoritative and untouched, and the +new capability uses its **own** branch store / event handling that is **not yet wired** to behavior. **Phase +4 performs a single atomic cutover** — every primitive root gains its providers (`provideFloatingTree()` +inherit-or-create + `provideFloatingRootContext()`), branch registration moves to the capabilities, event +handling switches to the tree, and `layersRoot` / `isLayerExist` / the global branch array are removed — **in +one change**. Before the flip the tree drives nothing; after it, the tree drives everything. There is never a +mix of legacy and migrated consumers, so the mixed-state failure modes cannot occur. + +- Build the **dismissal capability** (`RdxDismissableCapability`, §1) that references the root context + (mandatory) + node (optional), with `active()` tied to `context.open()`. +- Build the **registration directive** for `rdxDismissableLayer`. It accepts two options: + `externalTree?: RdxFloatingTree` (explicit tree for detached sibling composition; same as Base UI's + `externalTree`) and `parentOverride?: RdxFloatingParentOverride` (defaults to `{ kind: 'inherit' }`). + + **Angular DI propagation — handle pattern (decided; do not use dynamic token replacement).** + Angular injectors are sealed at creation time: a directive cannot change what `RDX_FLOATING_TREE` + resolves to for descendants after it processes runtime inputs. Instead, the directive provides a + `RdxFloatingRegistrationContext` (a stable DI handle with a **single atomic state signal** — not two + independent `WritableSignal` fields) via `provideFloatingRegistration()` **in its `providers` array** + (at injector creation). After resolving inputs in an `effect()`, the directive calls + `selfReg.register(resolvedTree, registeredNode)`. Descendants inject the handle with + `{ optional: true, skipSelf: true }` and read `parentReg.tree()` / `parentReg.node()` reactively. + + Concrete sequence in `providers` + `constructor`: + 1. `providers: [provideFloatingRegistration()]` — seals the stable handle at injector creation. + 2. Inject at construction time (injection context): + `selfReg = inject(RDX_FLOATING_REGISTRATION)`, + `parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true })`, + `ambientTree = inject(RDX_FLOATING_TREE, { optional: true })`. + 3. In `effect((onCleanup) => { … })`: read `this.externalTreeInput()` (an `input()` signal), then + resolve `tree = externalTree ?? parentReg?.tree() ?? ambientTree`. For `inherit`, resolve + `parentNode = parentReg?.node() ?? null`; for `root` override, `parentNode = null`; for `node` + override, `parentNode = override.parent`. + 4. If tree is non-null: `const node = tree.register(…)`, then `selfReg.register(tree, node)`; + cleanup on `onCleanup(() => { tree.unregister(node); selfReg.clear(); })`. + 5. If tree is null: node-optional mode — capability runs without a tree node; only root context is + injected. `selfReg.tree()` stays `null`. + + `inject()` is **not** available inside `effect()` (no injection context there), so all DI resolution + (`inject(RDX_FLOATING_REGISTRATION, …)`, `inject(RDX_FLOATING_TREE, …)`) happens in the constructor. + The handle's `parentReg.node()` is the single mechanism for parent resolution — there is no separate + `resolveFloatingParent` / `RDX_FLOATING_NODE` API (those were removed from the foundation; the handle + subsumes them). + +- Build the dismissal-ownership resolution within the capability using + `tree.children(node, { onlyOpen: true }).some(isBlocking)` — the `hasBlockingChild` pattern lives in + `RdxDismissableCapability` (Base UI `useDismiss.ts:170` is a local function, **not** a tree method); + the tree stays neutral. Add **no** cross-root activation order — independent roots stay independent + (Base UI parity). **Do not remove DOM-order `isLayerExist` / the `layersRoot` stack here** — it stays + authoritative until the per-primitive Phase 4 switchover (see "Provider migration" above), so nested + ownership is never broken mid-migration. +- Give the new capability its **own** branch store (`branches: Set`), scoped to it — **leave the + legacy global `context.branches` array in place** (the legacy engine still reads it); the move is part of + the Phase 4 atomic cutover. +- Build the per-`Document` body `pointer-events` registry (`WeakMap`, replacing the + module-global variable) on the new engine, building on the Phase -1 `useScrollLock` document-scope + precedent — wired in at the Phase 4 cutover. - Preserve owner-document and SSR-safe listener behavior. ### Phase 2: Event policy, propagation, and focus ownership @@ -852,8 +1008,9 @@ jsdom does not provide trustworthy layout or pointer behavior. - Block parent dismissal while a non-bubbling child is active. - Migrate Menu's existing `closeParentOnEsc` (submenu re-emit) onto `escapeKeyBubbles`; keep `menu.spec.ts` green and the observable submenu-Escape behavior unchanged. -- Move focus-out detection and closing into each owning primitive; remove focus-out from the shared - dismissal engine while preserving primitive behavior and compatibility outputs. +- **Focus-out is NOT removed in this phase** — only Escape/outside policy + propagation land here. The + shared engine keeps driving focus-out (unchanged) until the **coordinated migration (Phase 4)**, so there + is never a window with no focus-out close. Removal is sequenced after the replacement exists (see Phase 4). ### Phase 3: Press and IME hardening @@ -867,6 +1024,18 @@ jsdom does not provide trustworthy layout or pointer behavior. ### Phase 4: Directive API cleanup and internal consumer migration +> **Cross-ADR ordering (hard dependency):** the focus-out **removal + rewiring** below requires the +> replacement to already exist — i.e. **ADR 0017 Phase 3** (close-on-focus-out) must be landed for the FFM +> primitives, and the package-internal `useFocusOutside` must exist for the three non-FFM primitives. Until +> then the shared engine keeps driving focus-out (ADR 0015 Phase 2). This is the single coordinated phase +> where focus-out moves; it is **not** removed earlier. + +- **Atomic cutover to the tree (decided above — done in ONE change, all consumers at once).** Every + primitive root gains `provideFloatingTree()` (**inherit-or-create**, so a top Menu/Dialog creates the tree + and a nested one inherits it — `MenuRoot.tsx:533` parity) + `provideFloatingRootContext()`; branch + registration moves to the capabilities; event handling switches from the legacy stack to the tree; and the + `layersRoot` stack + DOM-order `isLayerExist` + the global branch array are removed — **simultaneously**. + No mixed legacy/migrated state ever exists (no per-primitive interim). - Introduce the discriminated `dismissRequest`. - Remove raw helper directives and implementation-detail tokens from the public barrel. - Migrate Dialog, Popover, Menu, Select, Combobox, Autocomplete, Preview Card, Tooltip, Navigation @@ -967,18 +1136,27 @@ portaled child's logical parent or configurable propagation through nested float This ADR can move to Accepted when: -1. Logical ancestry and within-tree "topmost" never depend on `[data-dismissable-layer]` DOM order or - directive construction order. The engine adds no cross-root activation order — independent roots each - handle their own Escape/outside-press, matching Base UI. +1. Logical ancestry and per-node blocking-child ownership never depend on `[data-dismissable-layer]` DOM + order or directive construction order. The engine adds no cross-root activation order — independent + roots each handle their own Escape/outside-press, matching Base UI. 2. A layer node can declare an explicit logical parent, so a detached/cross-injector-subtree popup resolves to the correct owner; detached triggers resolve as scoped inside-elements (§2), not layer parents. -3. The shared floating infrastructure (§1) ships all **four** pillars from the start — neutral nodes, - typed per-capability state (with `open`/`active` on the capability, not the node), a **shared trigger - registry** (`hasElement`/`hasMatchingElement`), and **typed event channels** — read by both this ADR - and ADR 0017. Tree traversal **filters** by open-capability but **does not abort recursion** at a - closed node (a closed parent never hides an open descendant). Tree/document invariants are enforced as - dev diagnostics (§1). +3. The shared floating infrastructure (§1) ships all **four** pillars from the start — a lightweight + neutral **node** (`id`/`parent`/`context`) distinct from the per-popup **root context** + (`RdxFloatingRootContext`: `open()`, `triggers`, elements) which can exist **without** a node + (`getEmptyRootContext` analog, for node-optional NavMenu); typed capabilities that **reference** a root + context (mandatory) + node (optional) and own their `active()`; a **per-popup trigger registry** + (`hasElement`/`hasMatchingElement`, on the root context, **not** tree-wide); and **typed event + channels** (neutral, private per tree) — all read by both this ADR and ADR 0017. The tree is + **scoped-by-default** (one per coordinating root via `provideFloatingTree()`, no application-root + singleton — Base UI parity), so its events never leak across unrelated popups. Tree traversal + **filters** by `node.context?.open()` (never an OR over capabilities) but **does not abort recursion** + at a closed node (a closed parent never hides an open descendant). Tree invariants are enforced by + severity (§1): the **structural** checks — node/parent belongs to this tree and is registered, and **no + ancestry cycle** — run in **every build** (a violation corrupts the tree or hangs traversal); the + **expensive** correctness checks — owner-`Document` consistency across the ancestry **and subtree** + (through contextless intermediates) — are **dev-only** (`isDevMode`). 4. Parent layers remain open while interacting with active child layers or scoped branches. 5. Escape closes the correct layer and does not dismiss during IME composition. 6. Outside press implements the **full `RdxOutsidePressStrategy` contract** (`PressType | { mouse, touch } | @@ -1008,9 +1186,13 @@ lazy fn`, resolved per-pointer per-press), with the per-primitive `outsidePressE one. 10. Standalone `RdxDismissableLayer` performs no focus-out dismissal. Focus-out ownership splits: FFM primitives → **ADR 0017** (which owns the parity table — its acceptance gate, not 0015's); the three - non-FFM primitives (Tooltip, Preview Card, Navigation Menu) **re-wire their own focus-out via - `useFocusOutside`** so their current behavior is preserved (§3, Phase 4). Every retained `focusOutside` - output has documented cancellation semantics (§3) instead of a no-op `preventDefault()`. + non-FFM primitives (Tooltip, Preview Card, Navigation Menu) **re-wire their own focus-out to match + Base UI's own close behavior** via `useFocusOutside` **only where Base UI installs a focus-out path** + (§3, Phase 4). This is a **normative breaking change, not preservation**: the Radix-only divergences are + **deleted** (e.g. Preview Card's blanket `focusOutside` close → Base UI hover-interaction close; Navigation + Menu's shared-layer close → its own `useDismiss`/`REASONS.focusOut`; Tooltip's manual `preventDefault` + workaround disappears). Every retained `focusOutside` output has documented cancellation semantics (§3) + instead of a no-op `preventDefault()`. 11. Editable no longer depends on the removed `RdxFocusOutside` / `RdxPointerDownOutside` directives and retains its current click-outside / focus-out commit behavior. 12. `RdxDismissableLayersContextToken`, raw helper directives, and global branch mutation are no longer diff --git a/docs/adr/0017-floating-focus-manager.md b/docs/adr/0017-floating-focus-manager.md index efcc8604..2c71f9ee 100644 --- a/docs/adr/0017-floating-focus-manager.md +++ b/docs/adr/0017-floating-focus-manager.md @@ -335,8 +335,9 @@ the typeable-combobox ancestor, and focus-out ownership. So `RdxFloatingFocusMan infrastructure"), attaching its own focus capability to a node — **not** a second focus-only tree, and **not** the dismissal capability. Specifically it reads, from the shared infra: -- the **traversal** API (`ancestors`, `children({ onlyOpen })`, `deepestOpen`) — same open-filtering / - recurse-through-closed semantics as dismissal (ADR 0015 §1); +- the **traversal** API (`ancestors`, `children({ onlyOpen })` — the focus-return path uses + `onlyOpen: false`) — same open-filtering / recurse-through-closed semantics as dismissal (ADR 0015 §1); + it does **not** use the dismissal-only `hasBlockingChild`; - the **shared trigger registry** (`triggerElements`, ADR 0015 §2) for its inside-element checks — so focus and dismissal never compute divergent inside-sets; - the **typed event channels** (`tree.events`) for cross-mechanism coordination (hover, list-navigation). @@ -407,11 +408,32 @@ enough for strict parity: | **menubar child** | yes iff `parent.context.modal` (menubar's flag) | | **context menu** | yes if `modal` | -| Primitive | what intercepts a background press | internal backdrop when none provided? | trigger/input cutout | lifecycle (mounted vs open) | interactive predicate (`inert` when `!open`) | state during animated exit | behavior with **no** backdrop | ⇒ drop the body toggle? | -| ------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------- | -------------------- | --------------------------- | -------------------------------------------- | -------------------------- | ----------------------------- | ----------------------- | -| Dialog / Popover (click) / Select / Combobox / Autocomplete | | | | | | | | | -| Menu (submenu / root / hover-root / menubar-child / context) — **one row each** | | | | | | | | | -| _(Phase 0 fills each row from Base UI source + Chromium)_ | | | | | | | | | +**Filled (source-derived against `mui/base-ui` master; paths relative to `packages/react/src/`).** The +single decisive finding: **Base UI uses no `body { pointer-events }` toggle anywhere.** Modal popups block +the background **only** via a full-viewport `InternalBackdrop` (clipPath cutout around the anchor); non-modal +/ hover popups **never block** the background — outside-press only _dismisses_ (floating-ui `useDismiss`), +it does not intercept. So the Radix body toggle can be dropped for **every** row, conditional on porting +`RdxInternalBackdrop`-with-cutout for the modal cases. + +| Primitive | what intercepts a background press | internal backdrop when none provided? | trigger/input cutout | lifecycle (mounted vs open) | interactive predicate (`inert` when `!open`) | state during animated exit | behavior with **no** backdrop | ⇒ drop the body toggle? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------- | -------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- | +| Dialog (modal) | `InternalBackdrop` `position:fixed; inset:0`, rendered in the **portal** when `mounted && modal===true` (`dialog/portal/DialogPortal.tsx:35`) | yes — always for modal, independent of user `Backdrop` | **none** — full-viewport, no `cutout` passed (`DialogPortal.tsx:36`) | rendered while `mounted` (thru exit) | yes — `inert={inertValue(!open)}` (`:36`) | mounted but `inert` | non-modal Dialog → no backdrop; outside-press via `useDismiss` | **yes** — port `InternalBackdrop` | +| Popover (click / modal) | `InternalBackdrop` from the **positioner** when `mounted && modal===true && reason!==triggerHover` (`popover/positioner/PopoverPositioner.tsx:157`) | yes — independent of user `Backdrop` | **trigger** — `cutout={triggerElement}` (`:161`) ⟶ clipPath hole (`utils/InternalBackdrop.tsx:12`) | while `mounted` | yes (`:160`) | mounted but `inert` | non-modal → none; `useDismiss` outside-press | **yes** (with trigger cutout) | +| Popover (hover-open) | **nothing** — backdrop excluded (`reason!==triggerHover`, `:157`); user `Backdrop` forced `pointer-events:none` (`popover/backdrop/PopoverBackdrop.tsx:49`); dismissal via `useDismiss` | no | n/a | n/a | n/a | n/a | nothing blocks the background by design | **yes / N/A** — hover never blocked | +| Select | `InternalBackdrop` from the positioner when `mounted && modal` (`select/positioner/SelectPositioner.tsx:245`); `modal` default `true` (`select/root/SelectRoot.tsx:70`) | yes — independent of user `Backdrop` | **trigger** — `cutout={triggerElement}` (`:245`) | while `mounted` | yes (`:245`) | mounted but `inert` | non-modal → none | **yes** (with trigger cutout) | +| Combobox / Autocomplete | `InternalBackdrop` **only if `modal`** — default **`false`** (`combobox/positioner/ComboboxPositioner.tsx:129`; default `combobox/root/AriaCombobox.tsx:115`); else `useDismiss` (`AriaCombobox.tsx:1030`) | only when `modal` set `true` | **input/group** — `cutout={inputGroupElement ?? inputElement ?? triggerElement}` (`ComboboxPositioner.tsx:132`) | while `mounted` | yes (`:131`) | mounted but `inert` | default non-modal: no backdrop, background interactive | **conditional** — yes when modal; default needs no toggle | +| Menu — root | `InternalBackdrop` when `parent.type===undefined && modal && reason!==triggerHover` (`menu/positioner/MenuPositioner.tsx:283`); `modal` default `true` (`menu/root/MenuRoot.tsx:567`) | yes | **trigger** — `backdropCutout=triggerElement` (`:294,307`) | while `mounted` | yes (`:306`) | mounted but `inert` | non-modal root → none | **yes** (with trigger cutout) | +| Menu — submenu | **nothing** — backdrop never rendered (`parent.type==='menu'`, `:285`); outside-press via the tree / `useDismiss` | no | n/a | n/a | n/a | n/a | submenu never blocks the background | **yes** (no backdrop needed) | +| Menubar — child | `InternalBackdrop` when `parent.type==='menubar' && parent.context.modal` (`MenuPositioner.tsx:286`) | yes (when menubar modal) | **menubar content** — `backdropCutout=parent.context.contentElement` (`:292`) so all bar triggers stay hoverable | while `mounted` | yes (`:306`) | mounted but `inert` | non-modal menubar → none | **yes** (with content-element cutout) | +| Context Menu | `InternalBackdrop` when `modal`, ref wired to `parent.context.internalBackdropRef` (`MenuPositioner.tsx:301`) | yes | **none** — `backdropCutout` stays `null` for context-menu (`:290`); anchored at the cursor | while `mounted` | yes (`:306`) | mounted but `inert` | n/a (modal by default) | **yes** (full-viewport, no cutout) | + +> **Source-derived; browser-verify pending (jsdom cannot resolve clipPath / `getBoundingClientRect` / +> `inert` hit-testing):** that the clipPath cutout actually passes the pointer through to the +> trigger/input while blocking the rest (`InternalBackdrop.tsx:12`); that the menubar content-element +> cutout keeps every bar trigger hoverable for menu-switching (`:292`); that the input-group cutout keeps a +> modal Combobox typeable; that `inert` truly disables the backdrop during the animated exit; and that the +> cutout (computed once from `getBoundingClientRect`) does not go stale as the anchor moves/scrolls. These +> are the `RdxInternalBackdrop` acceptance checks for the Phase 5 Chromium matrix. **The backdrop primitive is infra, but the _decision to render it_ is per-primitive policy (#7, verified).** `RdxInternalBackdrop` is shared infrastructure (above), but **who renders it, where, and with what cutout @@ -660,88 +682,275 @@ while a nested popup is open leaves dismissal **and** focus ownership unchanged ### Phase 0: Parity characterization (mandatory — gates later phases) -Fill the source-derived + Chromium-verified tables/audit below. **No implementation starts until they are -complete.** - -0. **Low-level primitive parity audit (§6).** Audit `RdxFocusScope`, `RdxPortal` / `RdxPortalPresence`, - and `RdxFocusGuards` against Base UI's `FloatingFocusManager` + `FloatingPortal` + `FocusGuard`. Record - per primitive: owner-`Document` vs global `document`, module-global state, `contains()` vs - `composedPath`/Shadow DOM, `setTimeout` vs queued focus, portal guard/tabbability/`aria-owns`, and the - needed rework (focus-scope rework, the portal-focus bridge, owner-document guards). **Gate:** the - coordination contract and rework scope are decided before Phase 1. -1. **Pointer-interaction parity table** (§5) — per primitive: what intercepts a background press, internal - vs user backdrop, trigger/input cutout, backdrop **lifecycle (mounted vs open) / inert-when-`!open` / - animated-exit**, no-backdrop behavior. **It may conclude — and is expected to — that a shared - `RdxInternalBackdrop` primitive is required, with an assigned owner.** **Gate:** - `disableOutsidePointerEvents` is removed only for rows that prove parity (incl. a working - `RdxInternalBackdrop` where needed; §5 acceptance gate). -2. **Focus-out parity table** — per primitive **and modal/non-modal**, the **resulting observable focus - transition**, not the `closeOnFocusOut` input. It must also cover the `restoreFocus` edge cases Base UI - handles separately (critical for Presence / animated unmount and dynamic Menu/Combobox items): - - | Primitive / mode | focus → trigger/reference | focus → child popup | focus → outside | pointer-induced focus move | focused element **removed** | popup **kept-mounted while closed** | close during **queued initial-focus** frame | closes? | - | ----------------------------------------------------- | ------------------------- | ------------------- | --------------- | -------------------------- | --------------------------- | ----------------------------------- | ------------------------------------------- | ------- | - | _(Phase 0 fills each from Base UI source + Chromium)_ | | | | | | | | | - - **Gate:** each primitive's focus policy must match its row; the known Base-UI-Select-closes-on-focus-out - vs Radix-prevents divergence is recorded with its resolution. - -3. **`aria-owns` / content-roots audit (§6a, #4) — multi-IDREF is an Angular _adaptation_, not literal - parity.** Base UI emits a **single** `` pointing at the **one wrapper** - that owns the whole portal subtree (`FloatingPortal.tsx:267`). We have **no wrapper**, so listing - several `contentRootElements` IDREFs is a _reasonable adaptation_ — but **not proven equivalent**. - Phase 0 must validate in a real browser/AT, not assume: (a) do multiple IDREFs reproduce the intended - reading/tab order; (b) are descendants that live under a **non-content** root (e.g. inside the - positioner) lost; (c) is an **invisible semantic portal anchor** (one element wrapping the content roots, - mirroring Base UI's single wrapper) better than enumerating roots. Also record **who mints/owns the - stable IDs** (IDREF targets need SSR-stable `injectId` ids) and the `aria-owns` behavior on a - **container move**. A backdrop is a DOM root but is **never** `aria-owns`'d. **Gate:** - `contentRootElements` is defined separately from `ownRootElements`, and the IDREF-vs-anchor decision is - made from browser/AT evidence, before Phase 2; the backdrop is proven absent from any `aria-owns` set. -4. **Positioner lifecycle during animated exit (§6a, #6 — follow-up to ADR 0012).** Confirm Base UI's - positioning lifecycle on close (`useAnchorPositioning` runs `autoUpdate` while `mounted`, gated - `open: mounted` — `useAnchorPositioning.ts:441,507`) and decide our parity: **keep `autoUpdate` running - until unmount** (current `RdxPopperContentWrapper` behavior = parity, no `active` input) vs freeze on - `open=false`. If kept, characterize whether a late `flip`/`shift` can visibly jump the popup mid-exit and - whether any primitive needs to **pin the placement/transform for the exit** (a Popper capability, **not** - a dismissal/focus concern). **Gate:** the positioner's exit-time behavior is decided and, if "keep - running", a test asserts the exit animation is not broken by a placement change. -5. **Portal-ancestry vs custom-container audit (§6a, #1).** Characterize, per portaled primitive, the - resolved `portalParent`: implicit nesting (falls back to the enclosing portal context) vs an explicit - custom `container`. Verify against Base UI's `container ?? parentPortalNode ?? body` that a - custom-container child is **not** physically inside the parent portal subtree, and confirm our - `resolveRegisteredContainerParent(container)` returns the container's registration (or `null`), so the - parent's keep-sets exclude it. **Gate:** the resolved-parent algorithm is implemented and a child with - an explicit body-level container is **not** a keep-set descendant of its logical parent. -6. **Dismissal-inside vs portal-inside audit (§6a, #2).** Confirm `useDismiss` computes outside-press - "inside" from floating element + reference + **floating-tree children** + **trigger registry** + - **markers** (`useDismiss.ts:173,345,388,393`), **not** `PortalContext` descendants. **Gate:** the - dismissal engine (ADR 0015) treats portal-registry membership as **non-authoritative** for outside-press - — a contextless portal kept for `markOthers` is dismissal-inside **only** via floating descendant or - trigger/branch/inside-element registration. -7. **Focus-host resolution audit (§3, #1).** Pin the focusable marker (`FOCUSABLE_ATTRIBUTE` analog) and - the `getFloatingFocusElement(floating)` algorithm per primitive: where `floatingElement` === - `floatingFocusElement` (popup carries handlers) and where they **diverge** (positioner-is-floating, - Select item-aligned, wrapper compositions). **Gate:** the manager resolves the focus host explicitly - (trap / `initialFocus` / `returnFocus` / `tabIndex` operate on `floatingFocusElement`; tree/dismissal on - `floatingElement`); a divergent case (focus host is a child of the floating element) is covered by a test. -8. **Focus-return traversal filter (#4).** Confirm the focus-inside-tree check on unmount/close walks - descendants with `onlyOpen=false` (`FloatingFocusManager.tsx:842`), so focus inside a closed-but-mounted - descendant still counts as inside the tree. **Gate:** the return-focus path calls the shared traversal - with `onlyOpen: false` (ADR 0015 §1), never the dismissal default `onlyOpen: true`; tested with a - keep-mounted closed child holding focus. -9. **`insideReactTree`-analog capture audit (#5).** Base UI keeps an `insideReactTree` capture marker - (`useDismiss.ts:167,234,367`) separate from DOM/floating tree, guarding document-capture timing and - logical-tree interactions. Our Angular bubbling after DOM relocation does **not** follow the declaration - tree. **Gate:** prove (with tests) that the shared floating tree + owner-`Document` host listeners - **replace** this mechanism — pointer inside a portaled child while a document-capture listener is armed, - child handler `preventDefault`s, parent must **not** dismiss, including when dispatch moves/removes the - target. If they do not fully cover it, define the explicit capture-marker analog before Phase 1. +Fill the tables/audit below. **Two gate tiers** (they are sequenced differently, so do not conflate them): + +- **Tier A — source-derived decisions.** Every item's `Resolution` is decided from `~/git/base-ui` source + + an audit of our code. **These gate Phase 1**: no implementation starts until Tier A is complete (it now + is). Phase 1 proceeds on the Tier-A decision and its documented **fallback**. +- **Tier B — browser/AT verification** (the `‡` / "browser-pending" items: #3 `aria-owns`, #9 capture-race, + #10 WebKit blur). A real browser/AT/Safari is not available at characterization time, so these are + **pre-Acceptance gates verified in the Phase 5 matrix**, **not** Phase 1/2 blockers. Implementation builds + the Tier-A choice; the documented fallback is applied **only if** Phase 5 verification fails. The Tier-B + gate sentences below therefore read "**before Acceptance**" (decision + contingency recorded now; browser/AT + _confirmation_ in the Phase 5 matrix gates Acceptance) — they are **not** Phase 1/2 blockers. + +**Status (all 12 items resolved — each has a `Resolution` below, source-derived against `~/git/base-ui` + +an audit of our code).** Classification: + +- **Source-resolved / satisfied by the foundation** — #1, #2 (filled tables), #4 (positioner exit = parity, + no change), #6 (dismissal-inside = tree/registry, not portal), #8 (`children({onlyOpen:false})` exists), + #12 (`createFloatingRootContext` + node-optional capability exist). +- **Decided, needs net-new infra (Phase 1/4)** — #0 (rework `RdxFocusScope`; build the portal-focus bridge + / `RdxFocusGuards`), #5 (`resolveRegisteredContainerParent` + portal registry), #7 (pin the focus-host + marker), #11 (migrate the two Combobox layouts). +- **Source-derived, decision/behavior browser-or-AT-pending (pre-Acceptance gate, verified in Phase 5)** — + #3 (single `aria-owns` anchor vs multi-IDREF — AT), #9 (capture-timing race — Playwright; fallback = + explicit capture marker), #10 (WebKit blur-before-unmount — Safari matrix). + +Audit facts behind these: `RdxFocusScope` uses global `document` / module-global stack / `contains()` / +`setTimeout`; `RdxFocusGuards`, the portal-focus bridge, `resolveRegisteredContainerParent`, +`RdxInternalBackdrop`, `markOthers`, and the `contentRootElements`/`ownRootElements`/`descendantPortalRoots` +roles are **ADR-only (not yet implemented)**; the foundation already ships the floating tree, per-root-context +trigger registry, `children({onlyOpen})`, and `createFloatingRootContext`. + +0. **Low-level primitive parity audit (§6).** Audit `RdxFocusScope`, `RdxPortal` / `RdxPortalPresence`, + and `RdxFocusGuards` against Base UI's `FloatingFocusManager` + `FloatingPortal` + `FocusGuard`. Record + per primitive: owner-`Document` vs global `document`, module-global state, `contains()` vs + `composedPath`/Shadow DOM, `setTimeout` vs queued focus, portal guard/tabbability/`aria-owns`, and the + needed rework (focus-scope rework, the portal-focus bridge, owner-document guards). **Gate:** the + coordination contract and rework scope are decided before Phase 1. + + **Resolution (source-derived; audited our code + Base UI).** Base UI's `FloatingPortal` **is an active + focus participant** — it renders visually-hidden `tabindex=0` `FocusGuard` spans (marked + `data-base-ui-focus-guard`, `utils/FocusGuard.tsx:33`) **only when non-modal + open** + (`FloatingPortal.tsx:189`), toggles inside-tabbability via capture-phase `focusin`/`focusout` + (`:195–223`), and emits the single `aria-owns` span (`:266`). `FloatingFocusManager` uses the + **owner document** (`ownerDocument(floating)`, `FFM:338…893`), **queued** focus (`queueMicrotask` + + `enqueueFocus`→`requestAnimationFrame`), shadow-aware `getTarget`/`contains`, and a module-global + `previouslyFocusedElements` WeakRef list. **Our audit:** `RdxFocusScope` uses **global `document`** + (`focus-scope.ts:179`), a **module-global stack** (`stack.ts:14`), plain `container.contains()` (no + `composedPath`/shadow), and `setTimeout` return-focus (`:233`); **`RdxFocusGuards` does not exist**; + `RdxPortal`/`RdxPortalPresence` are **pure DOM movers** (no guards, no `aria-owns`, no focus + participation). **Decisions / rework scope:** (a) **rework `RdxFocusScope`** to owner-`Document`, + shadow/`composedPath`-aware containment, and queued (rAF/`afterRenderEffect`) focus — the module-global + stack is acceptable but its return-focus must be owner-document-scoped; (b) **build a new portal-focus + bridge** (`RdxFocusGuards` analog: leading/trailing guard spans + capture-phase tabbability toggle + + the `aria-owns` anchor) tied to `RdxPortal`/`RdxPortalPresence` — it does **not** exist today; (c) the + manager **composes** these three (trap + portal-focus bridge + owner-document) rather than extending + `RdxFocusScope`. Tab-order/focus behavior is **browser-verify pending** (Phase 5). + +1. **Pointer-interaction parity table** (§5) — per primitive: what intercepts a background press, internal + vs user backdrop, trigger/input cutout, backdrop **lifecycle (mounted vs open) / inert-when-`!open` / + animated-exit**, no-backdrop behavior. **It may conclude — and is expected to — that a shared + `RdxInternalBackdrop` primitive is required, with an assigned owner.** **Gate:** + `disableOutsidePointerEvents` is removed only for rows that prove parity (incl. a working + `RdxInternalBackdrop` where needed; §5 acceptance gate). +2. **Focus-out parity table** — per primitive **and modal/non-modal**, the **resulting observable focus + transition**, not the `closeOnFocusOut` input. It must also cover the `restoreFocus` edge cases Base UI + handles separately (critical for Presence / animated unmount and dynamic Menu/Combobox items): + + **Filled (source-derived against `mui/base-ui` master).** Legend: **FFM** = + `floating-ui-react/components/FloatingFocusManager.tsx`; the close decision is the `!modal` branch at + `FFM:531–547` (requires a set `relatedTarget`, a move to an _unrelated_ node, and `!isPointerDownRef`), + focus-out is **not** a `useDismiss` concern. ‡ = restore/timing cell that is source-correct but + **browser-verify pending** (needs real layout/focus, see below). + + **Two distinct tree walks — do not conflate (drives the Phase 3 traversal choice).** Focus-out + **containment** ("did focus move to an _unrelated_ node?", `movedToUnrelatedNode`) walks + `getNodeChildren` with the **default `onlyOpen=true`** + `getNodeAncestors` (`FFM:454–478`), so focus + moving into an **open** child popup is "related" and the parent stays open. The separate `onlyOpen=false` + walk (`FFM:842`) is **only** the focus-return / unmount path (it must also count a closed-but-mounted + descendant as inside, ADR 0015 §1). So the "focus → child popup" column cites **`FFM:466`** (containment), + **not** `FFM:842`. (`triggerElements` here is `store.context.triggerElements`, `FFM:439` — per-root, not + the shared tree, confirming the per-root-context trigger registry.) + + | Primitive / mode | focus → trigger/reference | focus → child popup | focus → outside | pointer-induced focus move | focused element **removed** | popup **kept-mounted while closed** | close during **queued initial-focus** frame | closes? | + | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------- | + | Dialog (modal) | trapped — guards block it (`modal=true`, `FFM:935`) | stays — containment walk `onlyOpen=true` (`FFM:466`) | suppressed (`!modal` false, `FFM:532`) | suppressed (`FFM:535`) | `restoreFocus="popup"` re-focuses popup ‡ (`FFM:499`) | manager **enabled** (`disabled={!mounted}`); trap + return-focus persist; close moot | `restoreFocusFrame.request` ‡ (`FFM:504`) | **No** | + | Dialog (non-modal / `disablePointerDismissal`) | `closeOnFocusOut=!disablePointerDismissal`; if disabled, listener never attaches (`FFM:411`) | stays (`FFM:466`) | plain non-modal → closes (`FFM:531`); `disablePointerDismissal` → no close (`FFM:411`) | suppressed (`FFM:535`) | `restoreFocus="popup"` ‡ | manager enabled; return-focus persists; close moot | `restoreFocusFrame` ‡ | **only** if `closeOnFocusOut` & focus left tree | + | Popover (click) | modal → trapped; non-modal → trigger counts inside (`FFM:454`) | stays (`FFM:466`) | modal suppressed (`FFM:532`); non-modal closes (`FFM:531`) | suppressed (`FFM:535`) | `restoreFocus="popup"` ‡ (`PopoverPopup:138`) | manager enabled (`disabled={!mounted‖hover}`); trap (if modal) + return-focus persist; close moot | `restoreFocusFrame` ‡ | non-modal+left-tree **Yes**; modal **No** | + | Popover (hover-open) | manager fully `disabled` (`reason===triggerHover`) — no focus-out path | n/a (manager off) | no focus-manager close (hover/`useDismiss` drives close) | n/a | no restore (manager off) | n/a | n/a | **No** (via FFM; hover-driven) | + | Menu root | trigger counts inside (`previousFocusableElement=activeTrigger`, `FFM:462`) | stays — open submenu via containment walk (`FFM:466`) | closes (`!modal`, `FFM:531`) | suppressed (`FFM:535`) | `restoreFocus=true` → prev/last tabbable/popup ‡ (`FFM:511`) | manager enabled; return-focus persists; close moot | re-focus popup ‡ (`FFM:518`) | **Yes** to unrelated | + | Menu submenu | parent menu is an ancestor → focus→parent is inside (`FFM:471`) | stays (`FFM:466`) | closes (`FFM:531`) | suppressed | `restoreFocus=true` ‡ | manager enabled; return-focus persists; close moot | re-focus popup ‡ | **Yes** to unrelated; **No** to ancestor | + | Context Menu | trapped (`modal=true`, `MenuPopup:139`) | stays (`FFM:466`) | suppressed (modal, `FFM:532`) | suppressed | `restoreFocus=true` ‡ | manager enabled; trap + return-focus persist; close moot | re-focus popup ‡ | **No** (modal) | + | Menubar child | trigger registered inside → focus→trigger inside (`FFM:462`) | stays (`FFM:466`) | closes (`FFM:531`) | suppressed | `restoreFocus=true` ‡ | manager enabled; return-focus persists; close moot | re-focus popup ‡ | **Yes** to unrelated | + | **Select** | `modal=false`, `closeOnFocusOut` default **true** (`FFM:260`); trigger is `domReference`, inside (`FFM:454`) | n/a | **closes** — non-modal + unrelated + `relatedTarget` set (`FFM:531`) | suppressed (`FFM:535`) | `restoreFocus=true` ‡ (`SelectPopup:525`) | manager enabled; return-focus persists; close moot | re-focus popup ‡ | **Yes** — Base-UI-vs-Radix divergence (below) | + | Combobox input-inside | `inputInsidePopup=true` → `focusManagerModal=modal` (default **`false`**) → **non-modal, untrapped**; typeable ⇒ `isUntrappedTypeableCombobox` (`ComboboxPopup:117`, `FFM:284`); input is `domReference`, inside (`FFM:454`) | stays (`FFM:466`) | **closes** — untrapped typeable forces close to unrelated (`FFM:543`) | suppressed (`FFM:535`) | `restoreFocus` default `false` → none | manager enabled; return-focus persists; close moot | n/a | **Yes** to unrelated | + | Combobox input-outside | `inputInsidePopup=false` → `focusManagerModal=true` **even when `modal=false`** (`ComboboxPopup:117`); role `presentation`, `resolvedFinalFocus=false` (`:114`) → **modal, trapped** | stays (`FFM:466` + dismiss buttons inside, `ComboboxPopup:127`) | suppressed — modal (`FFM:532`) | suppressed | `restoreFocus=false` → none (focus stays in external input) | manager enabled; trap persists; close moot | n/a | **No** (modal) — closes via outside-press, not focus-out | + | Autocomplete | = Combobox per its `inputInsidePopup` (config-dependent ‡): input-inside ⇒ non-modal/untrapped; input-outside ⇒ modal/trapped | stays (`FFM:466`) | input-inside ⇒ **closes** (`FFM:543`); input-outside ⇒ suppressed (`FFM:532`) | suppressed | `restoreFocus=false` → none | manager enabled; close moot | n/a | input-inside **Yes**; input-outside **No** ‡ | + + **Resolution — Select focus-out divergence (gate).** Base UI Select **closes** on focus-out + (`modal=false`, `closeOnFocusOut` defaults `true`, `FFM:260` → the `!modal` close branch `FFM:531`); + Radix Select currently `preventDefault()`s focus-out and stays open. **Decision: adopt Base UI parity — + `RdxFloatingFocusManager` for Select uses `closeOnFocusOut` default `true` and closes on focus-out to an + unrelated node.** This is a deliberate breaking change for Radix Select, recorded here; the Phase 3 + focus-out implementation drops the Radix `preventDefault()`. (Focus returning to the trigger, staying in + the popup, a pointer-induced move, or a null `relatedTarget` still do **not** close — `FFM:454/535`.) + + **Browser-verify pending (‡ cells):** every `restoreFocus` / `restoreFocusFrame` final-landing outcome + depends on real layout (`isElementVisible`, `activeElement===body`, `FFM:488`) and animation-frame + timing; the WebKit blur-before-unmount (`FFM:889`) is Safari-only; "trigger counts inside" for + non-modal Popover / Menu / Menubar relies on runtime trigger-registration + `previousFocusableElement` + wiring; and **Autocomplete's layout** (`inputInsidePopup`) is set by the root config, not the popup, so + which row it follows (and thus whether it closes on focus-out) must be confirmed per usage. Source-derived + above; confirmed in the Phase 5 Chromium/WebKit matrix. + + **Gate:** each primitive's focus policy must match its row; the known Base-UI-Select-closes-on-focus-out + vs Radix-prevents divergence is recorded with its resolution (above). + +3. **`aria-owns` / content-roots audit (§6a, #4) — multi-IDREF is an Angular _adaptation_, not literal + parity.** Base UI emits a **single** `` pointing at the **one wrapper** + that owns the whole portal subtree (`FloatingPortal.tsx:267`). We have **no wrapper**, so listing + several `contentRootElements` IDREFs is a _reasonable adaptation_ — but **not proven equivalent**. + Phase 0 must validate in a real browser/AT, not assume: (a) do multiple IDREFs reproduce the intended + reading/tab order; (b) are descendants that live under a **non-content** root (e.g. inside the + positioner) lost; (c) is an **invisible semantic portal anchor** (one element wrapping the content roots, + mirroring Base UI's single wrapper) better than enumerating roots. Also record **who mints/owns the + stable IDs** (IDREF targets need SSR-stable `injectId` ids) and the `aria-owns` behavior on a + **container move**. A backdrop is a DOM root but is **never** `aria-owns`'d. **Gate:** + `contentRootElements` is defined separately from `ownRootElements`, and the IDREF-vs-anchor decision is + made from browser/AT evidence (Tier B — recorded now, AT-confirmed before Acceptance in the Phase 5 + matrix); the backdrop is proven absent from any `aria-owns` set. + + **Resolution (source-derived; AT decision browser-pending).** Confirmed: Base UI emits a **single** + `` (`FloatingPortal.tsx:266`) pointing at **one** wrapper + `
` (`:131`), and only in the **non-modal-open** state. We have **no + wrapper** today (`RdxPortalPresence` relocates root nodes with none). **Decision (preferred, source- + derived): adopt the single invisible semantic anchor** (option c) — mint **one** stable-id anchor + (`injectId`, SSR-stable) that owns the content roots, mirroring Base UI's single wrapper, **rather than** + enumerating multiple `contentRootElements` IDREFs (unproven for reading/tab order, and loses descendants + under non-content roots). `contentRootElements` is defined **separately** from `ownRootElements` + (footprint), and a **backdrop is never in any `aria-owns` set**. **Browser/AT-verify pending:** the + single-anchor-vs-multi-IDREF reading/tab-order equivalence and container-move behavior must be confirmed + against a real screen reader **before Acceptance** (Tier B, Phase 5 AT matrix); if multi-IDREF proves + necessary, that is the fallback. + +4. **Positioner lifecycle during animated exit (§6a, #6 — follow-up to ADR 0012).** Confirm Base UI's + positioning lifecycle on close (`useAnchorPositioning` runs `autoUpdate` while `mounted`, gated + `open: mounted` — `useAnchorPositioning.ts:441,507`) and decide our parity: **keep `autoUpdate` running + until unmount** (current `RdxPopperContentWrapper` behavior = parity, no `active` input) vs freeze on + `open=false`. If kept, characterize whether a late `flip`/`shift` can visibly jump the popup mid-exit and + whether any primitive needs to **pin the placement/transform for the exit** (a Popper capability, **not** + a dismissal/focus concern). **Gate:** the positioner's exit-time behavior is decided and, if "keep + running", a test asserts the exit animation is not broken by a placement change. + + **Resolution (source-derived; DECIDED).** Confirmed: Base UI keys positioning on **`mounted`, never + `open`** — `whileElementsMounted: autoUpdate` with `open: undefined` (default) and, for `keepMounted`, a + manual `autoUpdate` effect gated `keepMounted && mounted && reference && floating` + (`useAnchorPositioning.ts:441,506–511`) — so `flip`/`shift` continue through the exit. **Decision: keep + `autoUpdate` running until unmount** = parity, **no change** — our `RdxPopperContentWrapper.autoUpdate` + already lives for the directive's whole lifetime (ADR 0012), no `active` input, no freeze-on-`open=false`. + A "pin placement/transform for the exit" is an explicit **Popper** capability (out of scope here, not a + dismissal/focus concern) and is **not** added unless a primitive needs it. **Browser-verify pending:** a + visual-regression test that a late `flip`/`shift` does not visibly break the exit animation (layout- + dependent → Phase 5 Playwright). + +5. **Portal-ancestry vs custom-container audit (§6a, #1).** Characterize, per portaled primitive, the + resolved `portalParent`: implicit nesting (falls back to the enclosing portal context) vs an explicit + custom `container`. Verify against Base UI's `container ?? parentPortalNode ?? body` that a + custom-container child is **not** physically inside the parent portal subtree, and confirm our + `resolveRegisteredContainerParent(container)` returns the container's registration (or `null`), so the + parent's keep-sets exclude it. **Gate:** the resolved-parent algorithm is implemented and a child with + an explicit body-level container is **not** a keep-set descendant of its logical parent. + + **Resolution (source-derived; needs new infra).** Confirmed Base UI: + `resolvedContainer = container ?? parentPortalNode ?? document.body` (`FloatingPortal.tsx:110–113`) — an + explicit `container` is the portal target directly, so a custom-container child is **physically inside + that container, not the parent portal subtree**. **Our audit:** `resolveRegisteredContainerParent` **does + not exist**; the portal only has the stateless `resolvePortalContainer` (no registry tracking container + parents). **Decision:** mirror `container ?? parentPortal ?? body`, and **build** a portal registry + + `resolveRegisteredContainerParent(container)` that returns the container's registered owner node (or + `null`) so a parent's keep-sets exclude an explicit-body-container child. This is **net-new portal infra** + (Phase 1/4), not present today. Source-derived; the keep-set exclusion is asserted in a Phase 5 test. + +6. **Dismissal-inside vs portal-inside audit (§6a, #2).** Confirm `useDismiss` computes outside-press + "inside" from floating element + reference + **floating-tree children** + **trigger registry** + + **markers** (`useDismiss.ts:173,345,388,393`), **not** `PortalContext` descendants. **Gate:** the + dismissal engine (ADR 0015) treats portal-registry membership as **non-authoritative** for outside-press + — a contextless portal kept for `markOthers` is dismissal-inside **only** via floating descendant or + trigger/branch/inside-element registration. + + **Resolution (source-derived; satisfied by the foundation).** Confirmed: `useDismiss` computes + outside-press "inside" from floating + reference (`composedPath`, `useDismiss.ts:181`), **floating-tree + children** (`getNodeChildren`, `:344`), the **trigger registry** (`store.context.triggerElements`, + `:382`), and **inert markers** (`:373`) — there is **no `PortalContext`** reference in `useDismiss`. + **Decision:** the ADR 0015 engine treats portal-registry membership as **non-authoritative** for + outside-press. This already matches our foundation: containment reads `RdxFloatingTree.children()` + + the per-root-context `RdxTriggerRegistry`, never portal membership. A contextless portal kept only for + `markOthers` is dismissal-inside **only** via a floating descendant (tree child) or + trigger/branch/inside-element registration. Source-derived; gate met by the foundation's design. + +7. **Focus-host resolution audit (§3, #1).** Pin the focusable marker (`FOCUSABLE_ATTRIBUTE` analog) and + the `getFloatingFocusElement(floating)` algorithm per primitive: where `floatingElement` === + `floatingFocusElement` (popup carries handlers) and where they **diverge** (positioner-is-floating, + Select item-aligned, wrapper compositions). **Gate:** the manager resolves the focus host explicitly + (trap / `initialFocus` / `returnFocus` / `tabIndex` operate on `floatingFocusElement`; tree/dismissal on + `floatingElement`); a divergent case (focus host is a child of the floating element) is covered by a test. + + **Resolution (source-derived).** Confirmed: `getFloatingFocusElement(floating)` returns the floating + element when it `hasAttribute(FOCUSABLE_ATTRIBUTE)`, else its `querySelector([FOCUSABLE_ATTRIBUTE])` + match, else the floating element itself (`utils/element.ts:82`); `FOCUSABLE_ATTRIBUTE` is + `data-base-ui-focusable` (`utils/constants.ts:1`). **Decision:** pin a Radix focusable marker (e.g. + `data-rdx-focus-host`) and the identical resolution; the manager resolves the host **explicitly** — trap, + `initialFocus`, `returnFocus`, and `tabIndex` operate on **`floatingFocusElement`**, while tree/dismissal operate on + **`floatingElement`** (distinct roles, §6a five-role list). **Our divergent cases (audited):** Select's + popup **is** the floating element and the focus host (traps via `RdxFocusScope`, `select-popup.ts:101`), + with the positioner a separate geometry ancestor; **item-aligned** Select has no wrapper; Combobox does + **not** trap (host stays the input). A divergent case (focus host nested below the floating element) gets + a Phase-5 test. Source-derived; the marker name is our choice. + +8. **Focus-return traversal filter (#4).** Confirm the focus-inside-tree check on unmount/close walks + descendants with `onlyOpen=false` (`FloatingFocusManager.tsx:842`), so focus inside a closed-but-mounted + descendant still counts as inside the tree. **Gate:** the return-focus path calls the shared traversal + with `onlyOpen: false` (ADR 0015 §1), never the dismissal default `onlyOpen: true`; tested with a + keep-mounted closed child holding focus. + + **Resolution (source-derived; satisfied by the foundation).** Confirmed: the return-focus walk is + `getNodeChildren(tree, id, false)` at `FFM:842` (vs the default `onlyOpen=true` containment walk at + `FFM:466`). **Already implemented:** `RdxFloatingTree.children(node, { onlyOpen: false })` exists and is + documented for exactly this focus-return path (the dismissal default stays `onlyOpen: true`). The + Phase-3 return-focus path calls it with `onlyOpen: false`; a unit test covers a keep-mounted closed + child holding focus still counting as inside the tree. Gate met by the foundation. + +9. **`insideReactTree`-analog capture audit (#5).** Base UI keeps an `insideReactTree` capture marker + (`useDismiss.ts:167,234,367`) separate from DOM/floating tree, guarding document-capture timing and + logical-tree interactions. Our Angular bubbling after DOM relocation does **not** follow the declaration + tree. **Gate:** prove (with tests) that the shared floating tree + owner-`Document` host listeners + **replace** this mechanism — pointer inside a portaled child while a document-capture listener is armed, + child handler `preventDefault`s, parent must **not** dismiss, including when dispatch moves/removes the + target. If Phase 5 proves they do not fully cover it, add the explicit capture-marker analog as a + contingency (Tier B — before Acceptance, not a Phase 1 blocker). + + **Resolution (source-derived; capture-timing browser-pending).** Confirmed: Base UI's + `insideReactTree` is a **boolean on `dataRef.current`** set `true` by **capture-phase** handlers + (`onPointerDownCapture`/`onMouseDownCapture`/… on the floating element, `useDismiss.ts:729–740`), + auto-cleared on a `0ms` timeout (`:233`), and consulted by the document-level outside-press listener + (`if (insideReactTree) { clear(); return; }`, `:367`) — it suppresses one outside-press that bubbled + through the floating React subtree even across portals. **Decision:** for the **common** case the shared + floating tree (logical DI children via `RdxFloatingTree.children()`) + per-root-context trigger registry + + owner-`Document` host listeners **replace** it — an outside-press whose target is inside a portaled + **logical** child is "inside" via the tree walk regardless of DOM relocation. **Browser-pending (gated):** + the specific race it guards — a document-capture listener firing in the **same gesture** where a child + handler `preventDefault`s **and** the target is moved/removed mid-dispatch — is **not** proven by tree + membership alone and must be validated with a Playwright test in Phase 5; **if** it fails, an explicit + capture-marker analog is added as a contingency **before Acceptance** (Tier B, Phase 5). Recorded as the + gate condition. + 10. **Safari/WebKit blur-before-unmount (browser matrix).** Base UI force-`blur()`s a focused input inside a closing popup on WebKit before unmount to avoid a random scroll-to-bottom (`FloatingFocusManager.tsx:885–899`, gated `platform.engine.webkit && !open && floating`). **Gate:** the WebKit browser matrix asserts closing a popup with a focused inner input does **not** scroll the page and that return-focus stays correct. + + **Resolution (source-derived; browser-pending — WebKit only).** Confirmed: gate + `if (!platform.engine.webkit || open || !floating) return;` then blur the active element iff it is a + **typeable** element `contains`ed by `floating` (`FFM:889–900`). **Decision: adopt** — on WebKit only, + when `!open() && floatingElement` and `document.activeElement` is a typeable element inside the floating + element, the manager calls `.blur()` before unmount (platform detection via our browser guard). This is + **inherently WebKit/Safari-only**, so it is **browser-verify pending** — asserted in the Phase 5 + WebKit matrix (closing a popup with a focused inner input does not scroll the page; return-focus stays + correct). Source-derived. + 11. **Combobox input-inside vs input-outside — migrate the two layouts separately (#6, verified).** Base UI sets `focusManagerModal = !inputInsidePopup || modal` (`ComboboxPopup.tsx:117`), so an **input-outside** Combobox uses **modal** focus-manager behavior **even when `modal === false`**, with `returnFocus = false` @@ -752,6 +961,20 @@ complete.** the migration characterizes **both** layouts independently — `focusManagerModal`, `returnFocus`, `role`, the start/end dismiss buttons, and `getInsideElements` — and reconciles the popup's "does-not-trap" claim with Base UI's modal-for-input-outside behavior. + + **Resolution (source-derived).** Confirmed in `ComboboxPopup.tsx`: the focus-manager modality is + `!inputInsidePopup || modal` (`:117`); the resolved final focus is `undefined` for input-inside and + `false` for input-outside (`:114`); the role is `dialog` for input-inside, `presentation` otherwise + (`:81`); inside-elements are the start/end dismiss refs (`:127`); and the trailing dismiss button + renders only when the focus manager is modal (`:134`). **Our audit:** `combobox-popup.ts` does **not** + trap and carries the comment _"focus stays in the input throughout"_ — correct for input-**inside** + (non-modal/untrapped) but **wrong for input-outside**. **Decision (matches the corrected focus-out + table rows):** migrate the two layouts independently — **input-inside** is non-modal/untrapped + (`role=dialog`, default return focus); **input-outside** is modal/trapped even when `modal` is + false (`role=presentation`, return focus `false` so focus stays in the external input), and renders the + start/end dismiss buttons. The Radix "does-not-trap" comment is corrected in the Phase 4 migration. + Source-derived; reconciled with item #2. + 12. **Navigation Menu may run dismissal without a full floating node (#8, verified).** Base UI's Navigation Menu uses `useDismiss` with a **fallback empty floating context**, enabling interactions only when a positioner/value exists (`NavigationMenuList.tsx`). **Gate:** the shared dismissal API (ADR 0015) must @@ -759,9 +982,38 @@ complete.** **only when a popup exists**, or the capability tolerates a temporarily-absent node — so Navigation Menu is not forced to register a full node in every state. -### Phase 1: `RdxFloatingFocusManager` skeleton + **Resolution (source-derived; satisfied by the foundation).** Confirmed: Base UI's NavMenu runs + `useDismiss` with a **fallback empty floating context** (`getEmptyRootContext()` — a `FloatingRootStore` + with **no** tree node), enabling interactions only when a positioner/value exists. **Already + implemented:** the foundation provides `createFloatingRootContext()` (the `getEmptyRootContext` analog) + and the **node-optional capability model** — `RdxDismissableCapability` references a **root context + mandatorily** and a **node optionally** (ADR 0015 §1). So NavMenu registers a tree node **only when a + popup exists** and otherwise runs dismissal off a standalone root context with `node === null`. Gate met + by the foundation; the wiring lands in the Phase 4 migration. + +### Phase 1: Low-level focus foundation, then the `RdxFloatingFocusManager` skeleton + +This phase has **two ordered steps** — the foundation lands **before** the skeleton, and both before any +primitive migration (Phase 4). It is the home for the Phase 0 #0 / §6 rework, which today **does not exist**. + +**1a — low-level foundation (build / rework first):** + +- **Rework `RdxFocusScope`** (Phase 0 #0): owner-`Document` (not global `document`), shadow/`composedPath`-aware + containment (not bare `contains()`), and queued focus (rAF / `afterRenderEffect`, not `setTimeout`). The + **active-scope stack moves to `WeakMap`** — it pauses/resumes scopes, so it **is** + cross-document state (opening a scope in document B must not pause document A's scope) and cannot stay + process-global. Only a **passive** previously-focused-element history (a WeakRef list, no pause/resume + coordination) may remain module-global — that, not the active stack, is the true Base UI + `previouslyFocusedElements` analogue. +- **Build the portal-focus bridge + `RdxFocusGuards`** (Phase 0 #0 / §6 — net-new): leading/trailing + visually-hidden `tabindex=0` guard spans + capture-phase inside-tabbability toggle + the single `aria-owns` + anchor, tied to `RdxPortal` / `RdxPortalPresence`. +- Owner-`Document` focus guards (§6). + +**1b — manager skeleton:** -- Implement the independent policy set (§1, §2) composing `RdxFocusScope`; wire focus lifecycle + enabled. +- Implement the independent policy set (§1, §2) by **composing** the three low-level parts (reworked + `RdxFocusScope` + portal-focus bridge + owner-document guards); wire focus lifecycle + `enabled`. ### Phase 2: aria-hidden + marker passes @@ -936,9 +1188,15 @@ complete.** 10. `RdxFloatingFocusManager` reads the **shared** floating infrastructure (ADR 0015 §1) — nodes, traversal, the shared **trigger registry** (§2), and the typed **event channels** — not a focus-only tree or a duplicate trigger list. -11. Focus scope, focus guards, and portal-focus coordination are **owner-`Document`-scoped** (no - module-global `count`/`document`/stack), verified with two-document/iframe tests (§6) — the same - isolation this trilogy applies to pointer-events and scroll lock. +11. Focus scope, focus guards, and portal-focus coordination are **owner-`Document`-scoped**: no + module-global **`document` / `document.body`** references (listeners, return-focus fallbacks, and + tabbable queries all key off the owner document), verified with two-document/iframe tests (§6). The + **active-scope stack** (which pauses/resumes scopes) is **per-`Document`** — `WeakMap`, **not** process-global — because pausing document A's scope when a scope opens in + document B is exactly the cross-document corruption this forbids. Only a **passive** previously-focused + history (a WeakRef list, no pause/resume coordination) may stay module-global; that is the real Base UI + `previouslyFocusedElements` analogue, **not** the active stack. The hard isolation requirement is the + same one this trilogy applies to pointer-events and scroll lock: per-`Document`, not process-global. 12. The **per-effect lifecycle split** is honored exactly (verified line-by-line, §3/§6a): **focus-trap structure, close-on-focus-out, and return-focus follow `mounted`** (persist while mounted-but-closed, return-focus fires on that lifecycle, not the raw `open` flip), while **marker, `aria-hidden`, diff --git a/packages/primitives/core/__tests__/floating-registration.spec.ts b/packages/primitives/core/__tests__/floating-registration.spec.ts new file mode 100644 index 00000000..fe4b3187 --- /dev/null +++ b/packages/primitives/core/__tests__/floating-registration.spec.ts @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import { computed, inject, Injector } from '@angular/core'; +import { describe, expect, it } from 'vitest'; +import { + provideFloatingRegistration, + RDX_FLOATING_REGISTRATION, + RdxFloatingRegistrationContext +} from '../src/floating/floating-registration'; +import { RdxFloatingTree } from '../src/floating/floating-tree'; +import { provideFloatingTree, RDX_FLOATING_TREE, resolveFloatingTree } from '../src/floating/provide-floating-tree'; + +// ─── RdxFloatingRegistrationContext ────────────────────────────────────────── + +describe('RdxFloatingRegistrationContext', () => { + it('starts with tree and node both null', () => { + const ctx = new RdxFloatingRegistrationContext(); + expect(ctx.tree()).toBeNull(); + expect(ctx.node()).toBeNull(); + }); + + it('register() sets tree and node atomically', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + ctx.register(tree, node); + + expect(ctx.tree()).toBe(tree); + expect(ctx.node()).toBe(node); + }); + + it('clear() nulls both tree and node atomically', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + ctx.register(tree, node); + ctx.clear(); + + expect(ctx.tree()).toBeNull(); + expect(ctx.node()).toBeNull(); + }); + + it('register() enforces node.tree === tree (dev guard)', () => { + const ctx = new RdxFloatingRegistrationContext(); + const treeA = new RdxFloatingTree(); + const treeB = new RdxFloatingTree(); + const nodeInA = treeA.register({ id: 'n', parent: null, context: null }); + + // nodeInA belongs to treeA — passing treeB is a mismatch + expect(() => ctx.register(treeB, nodeInA)).toThrow(/register.*node\.tree|mismatch/i); + }); + + it('tree and node signals are always consistent (no half-set intermediate)', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + // computed that reads both fields simultaneously + const consistent = computed(() => { + const t = ctx.tree(); + const n = ctx.node(); + if (t === null && n === null) return 'null'; + if (t !== null && n !== null && n.tree === t) return 'valid'; + return 'inconsistent'; + }); + + expect(consistent()).toBe('null'); + ctx.register(tree, node); + expect(consistent()).toBe('valid'); + ctx.clear(); + expect(consistent()).toBe('null'); + }); + + it('a computed() over the handle re-evaluates when register()/clear() are called', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + // Derived signal — reactively tracks ctx.tree() + const derivedTree = computed(() => ctx.tree()); + + expect(derivedTree()).toBeNull(); + ctx.register(tree, node); + expect(derivedTree()).toBe(tree); + ctx.clear(); + expect(derivedTree()).toBeNull(); + }); +}); + +// ─── provideFloatingRegistration ────────────────────────────────────────────── + +describe('provideFloatingRegistration()', () => { + it('creates a fresh RdxFloatingRegistrationContext per injector', () => { + const injA = Injector.create({ providers: [provideFloatingRegistration()] }); + const injB = Injector.create({ providers: [provideFloatingRegistration()] }); + + const ctxA = injA.get(RDX_FLOATING_REGISTRATION); + const ctxB = injB.get(RDX_FLOATING_REGISTRATION); + + expect(ctxA).toBeInstanceOf(RdxFloatingRegistrationContext); + expect(ctxB).toBeInstanceOf(RdxFloatingRegistrationContext); + expect(ctxA).not.toBe(ctxB); + }); + + it('a child injector resolves its own handle (not the parent handle)', () => { + const parentCtx = new RdxFloatingRegistrationContext(); + const parentInj = Injector.create({ + providers: [{ provide: RDX_FLOATING_REGISTRATION, useValue: parentCtx }] + }); + + const childInj = Injector.create({ + providers: [provideFloatingRegistration()], + parent: parentInj + }); + + const childCtx = childInj.get(RDX_FLOATING_REGISTRATION); + expect(childCtx).not.toBe(parentCtx); + expect(childCtx.tree()).toBeNull(); + }); + + it('inject(RDX_FLOATING_REGISTRATION, { skipSelf }) from child resolves to the parent handle', () => { + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + const parentCtx = new RdxFloatingRegistrationContext(); + parentCtx.register(tree, node); + + const parentInj = Injector.create({ + providers: [{ provide: RDX_FLOATING_REGISTRATION, useValue: parentCtx }] + }); + + const childInj = Injector.create({ + providers: [provideFloatingRegistration()], + parent: parentInj + }); + + // skipSelf from the child context reaches the parent's handle + const parentFromChild = childInj.runInContext(() => + inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true }) + ); + + expect(parentFromChild).toBe(parentCtx); + expect(parentFromChild?.tree()).toBe(tree); + expect(parentFromChild?.node()).toBe(node); + }); + + it('a grandchild context inherits the child handle (not the grandparent)', () => { + const tree = new RdxFloatingTree(); + const parentNode = tree.register({ id: 'parent', parent: null, context: null }); + const parentCtx = new RdxFloatingRegistrationContext(); + parentCtx.register(tree, parentNode); + + const childNode = tree.register({ id: 'child', parent: parentNode, context: null }); + const childCtx = new RdxFloatingRegistrationContext(); + childCtx.register(tree, childNode); + + const parentInj = Injector.create({ + providers: [{ provide: RDX_FLOATING_REGISTRATION, useValue: parentCtx }] + }); + const childInj = Injector.create({ + providers: [{ provide: RDX_FLOATING_REGISTRATION, useValue: childCtx }], + parent: parentInj + }); + const grandchildInj = Injector.create({ providers: [], parent: childInj }); + + // grandchild sees child's handle (nearest ancestor), not grandparent's + const nearest = grandchildInj.get(RDX_FLOATING_REGISTRATION); + expect(nearest).toBe(childCtx); + expect(nearest.node()).toBe(childNode); + + // with skipSelf: grandchild context sees parent (grandchild has no own handle) + const withSkipSelf = grandchildInj.runInContext(() => + inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true }) + ); + // grandchild has no own handle, so skipSelf reaches child (the nearest parent that has one) + expect(withSkipSelf).toBe(childCtx); + }); +}); + +// ─── resolveFloatingTree() ─────────────────────────────────────────────────── + +describe('resolveFloatingTree()', () => { + it('returns the explicit externalTree, even when an ambient RDX_FLOATING_TREE is present', () => { + const ambient = Injector.create({ providers: [provideFloatingTree()] }); + const ambientTree = ambient.get(RDX_FLOATING_TREE); + const external = new RdxFloatingTree(); + + const result = ambient.runInContext(() => resolveFloatingTree(external)); + expect(result).toBe(external); + expect(result).not.toBe(ambientTree); + }); + + it('falls back to the ambient RDX_FLOATING_TREE when no externalTree is provided', () => { + const injector = Injector.create({ providers: [provideFloatingTree()] }); + const ambientTree = injector.get(RDX_FLOATING_TREE); + + const result = injector.runInContext(() => resolveFloatingTree()); + expect(result).toBe(ambientTree); + }); + + it('returns null when neither externalTree nor an ambient token is available', () => { + const injector = Injector.create({ providers: [] }); + const result = injector.runInContext(() => resolveFloatingTree()); + expect(result).toBeNull(); + }); + + it('null externalTree falls back to the ambient token (null does not win over ambient)', () => { + const injector = Injector.create({ providers: [provideFloatingTree()] }); + const ambientTree = injector.get(RDX_FLOATING_TREE); + + const result = injector.runInContext(() => resolveFloatingTree(null)); + expect(result).toBe(ambientTree); + }); +}); + +// ─── handle propagation contract (Phase 1 design) ──────────────────────────── + +describe('Handle propagation — Phase 1 design contract', () => { + it('a directive filling its handle propagates tree+node reactively to all readers', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + // Two independent derived signals — both should update together + const derivedTree = computed(() => ctx.tree()); + const derivedNode = computed(() => ctx.node()); + + expect(derivedTree()).toBeNull(); + expect(derivedNode()).toBeNull(); + + ctx.register(tree, node); + expect(derivedTree()).toBe(tree); + expect(derivedNode()).toBe(node); + }); + + it('clearing one handle does not affect an independent sibling handle', () => { + const treeA = new RdxFloatingTree(); + const treeB = new RdxFloatingTree(); + const ctxA = new RdxFloatingRegistrationContext(); + const ctxB = new RdxFloatingRegistrationContext(); + const nodeA = treeA.register({ id: 'a', parent: null, context: null }); + const nodeB = treeB.register({ id: 'b', parent: null, context: null }); + + ctxA.register(treeA, nodeA); + ctxB.register(treeB, nodeB); + + ctxA.clear(); + + expect(ctxA.tree()).toBeNull(); + expect(ctxB.tree()).toBe(treeB); // unaffected + }); + + it('the parent node from parentReg.node() is available to the child for tree.register()', () => { + const tree = new RdxFloatingTree(); + const parentCtx = new RdxFloatingRegistrationContext(); + const parentNode = tree.register({ id: 'parent', parent: null, context: null }); + parentCtx.register(tree, parentNode); + + // Child reads parent's node and registers with it as parent + const parentNodeRef = parentCtx.node(); + const childNode = tree.register({ id: 'child', parent: parentNodeRef, context: null }); + + expect(childNode.parent).toBe(parentNode); + expect(tree.ancestors(childNode)).toEqual([parentNode]); + }); +}); diff --git a/packages/primitives/core/__tests__/floating-tree.spec.ts b/packages/primitives/core/__tests__/floating-tree.spec.ts index 78e69390..e7e1c889 100644 --- a/packages/primitives/core/__tests__/floating-tree.spec.ts +++ b/packages/primitives/core/__tests__/floating-tree.spec.ts @@ -1,7 +1,17 @@ // @vitest-environment jsdom +import { Injector } from '@angular/core'; import { beforeEach, describe, expect, it } from 'vitest'; import { createFloatingRootContext, RdxFloatingRootContext } from '../src/floating/floating-root-context'; import { RdxFloatingNode, RdxFloatingTree } from '../src/floating/floating-tree'; +import { provideFloatingTree, RDX_FLOATING_TREE } from '../src/floating/provide-floating-tree'; + +// Test-only augmentation: add a synthetic tree-level event so we can exercise the tree's +// event channel without landing a real capability event first. +declare module '../src/floating/floating-events' { + interface RdxFloatingEventMap { + test: { value: number }; + } +} function context(open = false, ownerDocument: Document = document): RdxFloatingRootContext { return new RdxFloatingRootContext({ ownerDocument, open: () => open }); @@ -96,33 +106,6 @@ describe('RdxFloatingTree', () => { }); }); - describe('deepestOpen()', () => { - it('returns the deepest open descendant (topmost within the tree)', () => { - const root = register(tree, 'root', null, true); - const a = register(tree, 'a', root, true); - const aa = register(tree, 'aa', a, true); - - expect(tree.deepestOpen(root)).toBe(aa); - }); - - it('ignores closed branches when choosing the deepest', () => { - const root = register(tree, 'root', null, true); - const shallowOpen = register(tree, 'shallow', root, true); - const deepClosedParent = register(tree, 'deep-closed', root, false); - register(tree, 'deep-open', deepClosedParent, true); - - // the deep branch is gated behind a closed node, so the shallow open node wins - expect(tree.deepestOpen(root)).toBe(shallowOpen); - }); - - it('returns null when there is no open descendant', () => { - const root = register(tree, 'root', null, true); - register(tree, 'closed', root, false); - - expect(tree.deepestOpen(root)).toBeNull(); - }); - }); - describe('open() lives on the context and drives traversal', () => { it('reflects a live open-state accessor on the context', () => { const root = register(tree, 'root', null, true); @@ -137,7 +120,6 @@ describe('RdxFloatingTree', () => { childOpen = true; expect(tree.children(root, { onlyOpen: true })).toEqual([child]); - expect(tree.deepestOpen(root)).toBe(child); }); }); @@ -194,6 +176,18 @@ describe('RdxFloatingTree', () => { expect(() => tree.setParent(root, a)).toThrow(/cycle/i); }); + it('is a no-op when the parent is unchanged — preserves sibling registration order', () => { + const root = register(tree, 'root', null, true); + const a = register(tree, 'a', root, true); + const b = register(tree, 'b', root, true); + const c = register(tree, 'c', root, true); + + // Re-affirming a's existing parent must NOT move it to the end of the sibling list. + tree.setParent(a, root); + + expect(tree.children(root, { onlyOpen: false })).toEqual([a, b, c]); + }); + it('validates the WHOLE subtree against the new ancestor, not just the first descendant', () => { const otherDoc = document.implementation.createHTMLDocument('other'); const newParent = register(tree, 'new-parent', null, true); // document @@ -238,7 +232,6 @@ describe('RdxFloatingTree', () => { expect(() => tree.setContext(foreign, context())).toThrow(/belong/i); expect(() => tree.children(foreign)).toThrow(/belong/i); expect(() => tree.ancestors(foreign)).toThrow(/belong/i); - expect(() => tree.deepestOpen(foreign)).toThrow(/belong/i); }); it('rejects operating on an already-unregistered node', () => { @@ -259,6 +252,39 @@ describe('RdxFloatingTree', () => { }); }); + describe('adjacency index cleanup (no node retention)', () => { + // White-box: the adjacency map keys are STRONG refs to nodes. If an empty child list is left + // behind, its key node is retained → node → context → DOM elements leak. Assert the map is + // fully drained after teardown, in both teardown orders. + const childrenOfSize = (t: RdxFloatingTree): number => + (t as unknown as { childrenOf: Map }).childrenOf.size; + + it('prunes empty child lists on leaf-up teardown (no lingering map keys)', () => { + const root = register(tree, 'root', null, true); + const child = register(tree, 'child', root, true); + const grandchild = register(tree, 'grandchild', child, true); + + tree.unregister(grandchild); + tree.unregister(child); + tree.unregister(root); + + expect(childrenOfSize(tree)).toBe(0); + expect(tree.all).toEqual([]); + }); + + it('prunes the parent key once its last orphan unregisters (parent-first order)', () => { + const root = register(tree, 'root', null, true); + const child = register(tree, 'child', root, true); + + // Parent first — child becomes an orphan but keeps its parent ref... + tree.unregister(root); + // ...then the orphan; this empties root's child list and must drop root's key. + tree.unregister(child); + + expect(childrenOfSize(tree)).toBe(0); + }); + }); + describe('invariants', () => { it('rejects a parent from a different tree', () => { const otherTree = new RdxFloatingTree(); @@ -280,6 +306,28 @@ describe('RdxFloatingTree', () => { }); }); +describe('provideFloatingTree (inherit-or-create)', () => { + it('creates a tree at the top boundary and inherits it in nested roots', () => { + const top = Injector.create({ providers: [provideFloatingTree()] }); + const topTree = top.get(RDX_FLOATING_TREE); + expect(topTree).toBeInstanceOf(RdxFloatingTree); + + // a nested root that also provides the tree inherits the ancestor's instance (no split) + const nested = Injector.create({ providers: [provideFloatingTree()], parent: top }); + expect(nested.get(RDX_FLOATING_TREE)).toBe(topTree); + + // a deeper nested root still resolves to the same top tree + const deeper = Injector.create({ providers: [provideFloatingTree()], parent: nested }); + expect(deeper.get(RDX_FLOATING_TREE)).toBe(topTree); + }); + + it('an independent top-level root creates its own tree (no cross-root sharing)', () => { + const a = Injector.create({ providers: [provideFloatingTree()] }); + const b = Injector.create({ providers: [provideFloatingTree()] }); + expect(a.get(RDX_FLOATING_TREE)).not.toBe(b.get(RDX_FLOATING_TREE)); + }); +}); + describe('RdxFloatingRootContext', () => { it('exposes elements read-only behind validated setters', () => { const ctx = new RdxFloatingRootContext({ ownerDocument: document, open: () => true }); @@ -356,16 +404,69 @@ describe('RdxFloatingRootContext', () => { }); describe('RdxFloatingTree events', () => { - it('exposes a typed event channel private to the tree', () => { + it('exposes a typed tree-level event channel (verified with augmented test event)', () => { + const tree = new RdxFloatingTree(); + const received: { value: number }[] = []; + const listener = (data: { value: number }) => received.push(data); + + tree.events.on('test', listener); + tree.events.emit('test', { value: 1 }); + tree.events.off('test', listener); + tree.events.emit('test', { value: 2 }); // should be ignored — listener removed + + expect(received).toEqual([{ value: 1 }]); + }); + + it('dispatches to multiple listeners and survives listener removal during dispatch', () => { const tree = new RdxFloatingTree(); - const received: { open: boolean }[] = []; - const listener = (data: { open: boolean }) => received.push(data); + const received: number[] = []; + + const listenerA = () => { + received.push(1); + tree.events.off('test', listenerA); // remove self during dispatch + }; + const listenerB = () => received.push(2); + + tree.events.on('test', listenerA); + tree.events.on('test', listenerB); + tree.events.emit('test', { value: 0 }); + + // Both listeners ran once (snapshot prevents skip); listenerA is gone for the next emit. + expect(received).toEqual([1, 2]); + + tree.events.emit('test', { value: 0 }); + expect(received).toEqual([1, 2, 2]); // only listenerB ran + }); +}); + +describe('RdxFloatingRootContext events', () => { + it('exposes a per-popup openchange event channel (Base UI FloatingRootStore.events parity)', () => { + const ctx = createFloatingRootContext({ ownerDocument: document }); + const received: { open: boolean; reason?: string }[] = []; + const listener = (data: { open: boolean; reason?: string }) => received.push(data); + + ctx.events.on('openchange', listener); + ctx.events.emit('openchange', { open: true, reason: 'trigger' }); + ctx.events.emit('openchange', { open: false }); + ctx.events.off('openchange', listener); + ctx.events.emit('openchange', { open: true }); // should be ignored + + expect(received).toEqual([{ open: true, reason: 'trigger' }, { open: false }]); + }); + + it('each root context has an independent event channel (no cross-popup bleed)', () => { + const ctxA = createFloatingRootContext({ ownerDocument: document }); + const ctxB = createFloatingRootContext({ ownerDocument: document }); + const fromA: boolean[] = []; + const fromB: boolean[] = []; + + ctxA.events.on('openchange', (d) => fromA.push(d.open)); + ctxB.events.on('openchange', (d) => fromB.push(d.open)); - tree.events.on('openchange', listener); - tree.events.emit('openchange', { open: true }); - tree.events.off('openchange', listener); - tree.events.emit('openchange', { open: false }); + ctxA.events.emit('openchange', { open: true }); + ctxB.events.emit('openchange', { open: false }); - expect(received).toEqual([{ open: true }]); + expect(fromA).toEqual([true]); + expect(fromB).toEqual([false]); }); }); diff --git a/packages/primitives/core/__tests__/use-scroll-lock.spec.ts b/packages/primitives/core/__tests__/use-scroll-lock.spec.ts new file mode 100644 index 00000000..137ab8d4 --- /dev/null +++ b/packages/primitives/core/__tests__/use-scroll-lock.spec.ts @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import { DOCUMENT } from '@angular/common'; +import { PLATFORM_ID, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useScrollLock } from '../src/dom/use-scroll-lock'; + +function resetScroller(doc: Document): void { + doc.documentElement.style.overflow = ''; + doc.documentElement.style.paddingRight = ''; + doc.body.style.overflow = ''; +} + +describe('useScrollLock', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + resetScroller(document); + }); + + afterEach(() => { + resetScroller(document); + }); + + function configure(doc: Document): void { + TestBed.configureTestingModule({ + providers: [ + { provide: DOCUMENT, useValue: doc }, + { provide: PLATFORM_ID, useValue: 'browser' } + ] + }); + } + + function lock(active: WritableSignal): void { + TestBed.runInInjectionContext(() => useScrollLock(active)); + } + + it('locks and restores the scroller', () => { + configure(document); + const active = signal(false); + lock(active); + + active.set(true); + TestBed.tick(); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.documentElement.style.overflow).toBe('hidden'); + + active.set(false); + TestBed.tick(); + expect(document.body.style.overflow).toBe(''); + expect(document.documentElement.style.overflow).toBe(''); + }); + + it('shares a per-document lock count across callers (nested overlays compose)', () => { + configure(document); + const a = signal(false); + const b = signal(false); + lock(a); + lock(b); + + a.set(true); + b.set(true); + TestBed.tick(); + expect(document.body.style.overflow).toBe('hidden'); + + // releasing one lock keeps the page locked while the other is still active + a.set(false); + TestBed.tick(); + expect(document.body.style.overflow).toBe('hidden'); + + b.set(false); + TestBed.tick(); + expect(document.body.style.overflow).toBe(''); + }); + + it('isolates lock state per document — an iframe lock does not corrupt the main document', () => { + const otherDoc = document.implementation.createHTMLDocument('iframe'); + configure(otherDoc); + const active = signal(false); + lock(active); + + active.set(true); + TestBed.tick(); + + // the other document is locked... + expect(otherDoc.body.style.overflow).toBe('hidden'); + // ...while the main document's scroller is untouched (separate WeakMap state) + expect(document.body.style.overflow).toBe(''); + expect(document.documentElement.style.overflow).toBe(''); + }); + + it('is a no-op on the server (non-browser platform)', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: DOCUMENT, useValue: document }, + { provide: PLATFORM_ID, useValue: 'server' } + ] + }); + const active = signal(true); + lock(active); + TestBed.tick(); + + expect(document.body.style.overflow).toBe(''); + }); +}); diff --git a/packages/primitives/core/index.ts b/packages/primitives/core/index.ts index e12ff32a..8de6ac50 100644 --- a/packages/primitives/core/index.ts +++ b/packages/primitives/core/index.ts @@ -16,6 +16,7 @@ export * from './src/types'; export * from './src/floating/floating-events'; export * from './src/floating/floating-lifecycle'; +export * from './src/floating/floating-registration'; export * from './src/floating/floating-root-context'; export * from './src/floating/floating-tree'; export * from './src/floating/provide-floating-tree'; diff --git a/packages/primitives/core/src/dom/use-scroll-lock.ts b/packages/primitives/core/src/dom/use-scroll-lock.ts index 925008f6..7211dae4 100644 --- a/packages/primitives/core/src/dom/use-scroll-lock.ts +++ b/packages/primitives/core/src/dom/use-scroll-lock.ts @@ -1,17 +1,33 @@ -import { DOCUMENT } from '@angular/common'; -import { DestroyRef, effect, inject, Signal } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { DestroyRef, effect, inject, PLATFORM_ID, Signal } from '@angular/core'; + +interface ScrollLockState { + /** The scroller's saved inline styles, captured on the `0 -> 1` transition for this document. */ + original: { bodyOverflow: string; htmlOverflow: string; htmlPaddingRight: string } | null; + /** Number of active locks sharing this document's saved state. */ + count: number; +} /** - * Process-wide ownership of the document scroller's overflow while one or more overlays lock - * scrolling. + * Per-`Document` ownership of the scroller's overflow while one or more overlays lock scrolling. * - * A single shared counter across every primitive that locks scroll is essential: with separate - * per-primitive counters, a popover and a dialog open at the same time would each capture the - * other's already-locked state as the "original" and restore it on close, leaving the page - * permanently unscrollable. + * State is keyed by the owner `Document` (a `WeakMap`) rather than module-global, so locks in different + * documents — iframes, multi-document test environments — never share or corrupt each other's saved + * state (ADR 0015 §6 / Phase -1). Within one document a single shared counter is still essential: with + * separate per-primitive counters a popover and a dialog open at the same time would each capture the + * other's already-locked state as the "original" and restore it on close, leaving the page permanently + * unscrollable. */ -let original: { bodyOverflow: string; htmlOverflow: string; htmlPaddingRight: string } | null = null; -let scrollLockCount = 0; +const scrollLockStates = new WeakMap(); + +function getScrollLockState(document: Document): ScrollLockState { + let state = scrollLockStates.get(document); + if (!state) { + state = { original: null, count: 0 }; + scrollLockStates.set(document, state); + } + return state; +} /** * Locks page scrolling while `active()` is `true`, and restores the original state when it becomes @@ -22,11 +38,13 @@ let scrollLockCount = 0; * body-overflow only propagates to the viewport when ``'s overflow is `visible`. The width of * the removed scrollbar is added as `padding-right` on `` so the page doesn't shift. * - * Lock ownership is shared across all callers via a single module-level counter, so nested or - * concurrent overlays compose correctly. Must be called in an injection context. + * Lock ownership is shared across all callers in the same document via a per-`Document` counter, so + * nested or concurrent overlays compose correctly. No-op on the server. Must be called in an injection + * context. */ export function useScrollLock(active: Signal): void { const document = inject(DOCUMENT); + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); let isLocked = false; const lock = () => { @@ -34,13 +52,15 @@ export function useScrollLock(active: Signal): void { return; } - if (scrollLockCount === 0) { + const state = getScrollLockState(document); + + if (state.count === 0) { const html = document.documentElement; const body = document.body; const win = document.defaultView; const scrollbarWidth = win ? Math.max(0, win.innerWidth - html.clientWidth) : 0; - original = { + state.original = { bodyOverflow: body.style.overflow, htmlOverflow: html.style.overflow, htmlPaddingRight: html.style.paddingRight @@ -54,7 +74,7 @@ export function useScrollLock(active: Signal): void { } } - scrollLockCount++; + state.count++; isLocked = true; }; @@ -63,19 +83,23 @@ export function useScrollLock(active: Signal): void { return; } - scrollLockCount--; + const state = getScrollLockState(document); + state.count--; isLocked = false; - if (scrollLockCount === 0 && original !== null) { + if (state.count === 0 && state.original !== null) { const html = document.documentElement; - document.body.style.overflow = original.bodyOverflow; - html.style.overflow = original.htmlOverflow; - html.style.paddingRight = original.htmlPaddingRight; - original = null; + document.body.style.overflow = state.original.bodyOverflow; + html.style.overflow = state.original.htmlOverflow; + html.style.paddingRight = state.original.htmlPaddingRight; + state.original = null; } }; effect(() => { + if (!isBrowser) { + return; + } if (active()) { lock(); } else { @@ -83,5 +107,11 @@ export function useScrollLock(active: Signal): void { } }); - inject(DestroyRef).onDestroy(unlock); + // Only register the browser-DOM unlock callback when actually in a browser — on the server + // lock() is never called (guarded by isBrowser in the effect), so unlock() would no-op + // anyway (isLocked stays false), but keeping the DestroyRef subscription is still misleading + // and leaks a closure that references browser globals. + if (isBrowser) { + inject(DestroyRef).onDestroy(unlock); + } } diff --git a/packages/primitives/core/src/floating/floating-events.ts b/packages/primitives/core/src/floating/floating-events.ts index 2011f5a8..5a685a04 100644 --- a/packages/primitives/core/src/floating/floating-events.ts +++ b/packages/primitives/core/src/floating/floating-events.ts @@ -1,49 +1,67 @@ /** - * The typed event map for the shared floating channel. Neutral by design: it ships only the base - * `openchange` event, and each capability (hover-close, virtual focus, menu coordination, list - * navigation) **augments** this interface via module augmentation rather than emitting untyped strings: + * The typed event map for the **shared tree-level coordination channel** on {@link RdxFloatingTree}. + * Neutral by design — it ships no events initially. Each capability (hover-close, virtual focus, menu + * coordination, list navigation) **augments** this interface via module augmentation rather than + * emitting untyped strings: * * ```ts * declare module '@radix-ng/primitives/core' { * interface RdxFloatingEventMap { - * 'virtualfocus': { id: string; element: HTMLElement | null }; + * virtualfocus: { id: string; element: HTMLElement | null }; * } * } * ``` * - * Pinning the map up front (ADR 0015 §1, pillar 4) is what lets later consumers extend the channel - * type-safely instead of changing the fundamental tree API once `any` payloads have spread. + * **`openchange` belongs here only per-popup, not per-tree.** Base UI emits `openchange` on the + * per-popup `FloatingRootStore.events` (`FloatingRootStore.ts:121`), not on the shared + * `FloatingTreeStore.events`. For open-state changes, use {@link RdxFloatingRootContextEventMap} on + * the popup's {@link RdxFloatingRootContext.events}, not this tree-level channel. */ -export interface RdxFloatingEventMap { - /** - * The popup's open-state changed. Neutral, matching Base UI's tree events — the tree is - * scoped-by-default (one per coordinating root, not application-wide), so events do not leak - * across unrelated popups and an event need not carry node identity. `reason` mirrors Base UI's - * open-change reason strings. - */ +// Intentionally empty: an augmentation seed. Capabilities add keys via `declare module` augmentation, +// which only works on an `interface` — so this must stay an interface and starts with no members. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface RdxFloatingEventMap {} + +/** + * The typed event map for the **per-popup events channel** on {@link RdxFloatingRootContext} — + * the Angular counterpart of Base UI's `FloatingRootStore.events` (`FloatingRootStore.ts:121`). + * Unlike the tree channel (one per coordinating root shared by all nested popups), this channel + * lives **on the root context** so each popup has its own scoped emitter with no cross-popup bleed. + */ +export interface RdxFloatingRootContextEventMap { + /** The popup's open-state changed. `reason` mirrors Base UI open-change reason strings. */ openchange: { open: boolean; reason?: string; event?: Event }; } /** - * Neutral typed event channel shared by every floating capability — the Angular counterpart of Base - * UI's `FloatingTreeStore.events` (`createEventEmitter`), keyed by {@link RdxFloatingEventMap}. + * Neutral typed event channel — the Angular counterpart of Base UI's `createEventEmitter()`, typed + * over `M` (a {@link RdxFloatingEventMap} sub-type for tree-level or a + * {@link RdxFloatingRootContextEventMap} sub-type for per-popup). */ -export interface RdxFloatingEvents { - emit(event: K, data: RdxFloatingEventMap[K]): void; - on(event: K, listener: (data: RdxFloatingEventMap[K]) => void): void; - off(event: K, listener: (data: RdxFloatingEventMap[K]) => void): void; +export interface RdxFloatingEvents { + emit(event: K, data: M[K]): void; + on(event: K, listener: (data: M[K]) => void): void; + off(event: K, listener: (data: M[K]) => void): void; } /** - * Creates a {@link RdxFloatingEvents} emitter backed by a `Map>`, mirroring Base - * UI's implementation exactly: synchronous dispatch, set-deduplicated listeners, no replay. + * Creates an {@link RdxFloatingEvents} emitter backed by `Map>`, mirroring Base + * UI's implementation: synchronous dispatch, set-deduplicated listeners, no replay. + * + * **Snapshot dispatch:** `emit()` snapshots the listener set before iterating so that a listener + * calling `on()`/`off()` during dispatch does not cause skip/revisit issues. */ -export function createFloatingEvents(): RdxFloatingEvents { +export function createFloatingEvents(): RdxFloatingEvents { const listeners = new Map void>>(); return { emit(event, data) { - listeners.get(event as string)?.forEach((listener) => listener(data as never)); + const set = listeners.get(event as string); + if (!set) return; + // Snapshot avoids mutation-during-dispatch (on()/off() in a listener). + for (const listener of [...set]) { + listener(data as never); + } }, on(event, listener) { let set = listeners.get(event as string); diff --git a/packages/primitives/core/src/floating/floating-registration.ts b/packages/primitives/core/src/floating/floating-registration.ts new file mode 100644 index 00000000..bb2cf4d3 --- /dev/null +++ b/packages/primitives/core/src/floating/floating-registration.ts @@ -0,0 +1,116 @@ +import { computed, InjectionToken, Provider, signal, Signal } from '@angular/core'; +import { rdxDevError } from '../dev/diagnostics'; +import type { RdxFloatingNode, RdxFloatingTree } from './floating-tree'; + +const DOCS = 'utils/floating-tree'; + +/** Atomic payload — always set or cleared together, so `node.tree === tree` is always true. */ +interface RegistrationState { + readonly tree: RdxFloatingTree; + readonly node: RdxFloatingNode; +} + +/** + * A **stable DI handle** created at injector formation time and filled in at runtime once the + * registration directive resolves its `externalTree` / `parentOverride` inputs. + * + * **Why a handle, not direct token replacement.** Angular injectors are sealed at creation — a + * directive that resolves its tree from a runtime `externalTree` input cannot change what + * `RDX_FLOATING_TREE` resolves to for its subtree afterwards. The handle is the object that _is_ + * provided at creation; its internal state signal changes at runtime. Descendants inject the handle + * (with `skipSelf: true`) and read `parentReg.tree()` / `parentReg.node()` reactively — they never + * depend on tokens being swapped post-construction. + * + * **Atomicity.** `tree` and `node` are **not** separate `WritableSignal`s — independent `.set()` + * calls would create intermediate states where `node.tree !== tree`. Instead there is **one** private + * state signal; `register(tree, node)` sets both together after asserting `node.tree === tree`, and + * `clear()` nulls both together. + * + * **Registration directive usage pattern:** + * + * ```ts + * @Directive({ providers: [provideFloatingRegistration()] }) + * class SomeFloatingDirective { + * private readonly selfReg = inject(RDX_FLOATING_REGISTRATION); + * private readonly parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true }); + * private readonly ambientTree = inject(RDX_FLOATING_TREE, { optional: true }); + * + * constructor() { + * effect((onCleanup) => { + * // `resolveFloatingTree(externalTree)` calls `inject()` — only legal at construction time, + * // not inside effect(). Here we replicate its logic with already-captured fields instead. + * const externalTree = this.externalTreeInput(); // input() signal + * const resolvedTree = externalTree ?? this.parentReg?.tree() ?? this.ambientTree; + * const parentNode = this.parentReg?.node() ?? null; // for 'inherit' + * + * if (!resolvedTree) return; // node-optional: standalone with no tree + * + * const node = resolvedTree.register({ id: ..., parent: parentNode, context: ... }); + * this.selfReg.register(resolvedTree, node); + * + * onCleanup(() => { + * resolvedTree.unregister(node); + * this.selfReg.clear(); + * }); + * }); + * } + * } + * ``` + */ +export class RdxFloatingRegistrationContext { + private readonly _state = signal(null); + + /** + * The tree this directive joined, or `null` before `register()` is called. A `computed()` derived + * from the internal state — always consistent with {@link node}. + */ + readonly tree: Signal = computed(() => this._state()?.tree ?? null); + + /** + * The node this directive registered, or `null` before `register()` is called. A `computed()` + * derived from the internal state — always consistent with {@link tree} + * (`node.tree === tree` is invariant). + */ + readonly node: Signal = computed(() => this._state()?.node ?? null); + + /** + * Atomically records the resolved tree and the registered node. Asserts `node.tree === tree` + * so no intermediate state where `tree` and `node` point to different stores can exist. + * Called by the directive inside `effect()` after `tree.register(…)` succeeds. + */ + register(tree: RdxFloatingTree, node: RdxFloatingNode): void { + if (node.tree !== tree) { + rdxDevError( + 'floating/registration-mismatch', + `register(tree, node): node.tree must equal tree. ` + `Node "${node.id}" belongs to a different tree.`, + DOCS + ); + } + this._state.set({ tree, node }); + } + + /** + * Clears both tree and node atomically (the `onCleanup` counterpart of {@link register}). + * Called after `tree.unregister(node)` so the handle reverts to the null state. + */ + clear(): void { + this._state.set(null); + } +} + +/** + * DI token for the nearest ancestor's {@link RdxFloatingRegistrationContext}. Each registration + * directive provides one instance (via {@link provideFloatingRegistration}) and fills it after + * resolving its inputs. Descendants inject with `{ optional: true, skipSelf: true }` to find the + * nearest parent's handle — not the directive's own. + */ +export const RDX_FLOATING_REGISTRATION = new InjectionToken('RdxFloatingRegistration'); + +/** + * Creates a {@link Provider} that seals a fresh {@link RdxFloatingRegistrationContext} into this + * directive's injector at creation time. Call this in a directive's `providers` array; the directive + * then calls `selfReg.register(tree, node)` and `selfReg.clear()` to manage the handle lifecycle. + */ +export function provideFloatingRegistration(): Provider { + return { provide: RDX_FLOATING_REGISTRATION, useFactory: () => new RdxFloatingRegistrationContext() }; +} diff --git a/packages/primitives/core/src/floating/floating-root-context.ts b/packages/primitives/core/src/floating/floating-root-context.ts index 46895729..8109a69f 100644 --- a/packages/primitives/core/src/floating/floating-root-context.ts +++ b/packages/primitives/core/src/floating/floating-root-context.ts @@ -1,5 +1,6 @@ import { isDevMode } from '@angular/core'; import { rdxDevError } from '../dev/diagnostics'; +import { createFloatingEvents, RdxFloatingEvents, RdxFloatingRootContextEventMap } from './floating-events'; import { RdxTriggerRegistry } from './trigger-registry'; const DOCS = 'utils/floating-tree'; @@ -34,6 +35,13 @@ export class RdxFloatingRootContext { readonly open: () => boolean; /** Per-popup trigger registry (Base UI `triggerElements`), read by both dismissal and focus. */ readonly triggers = new RdxTriggerRegistry(); + /** + * Per-popup typed event channel (Base UI `FloatingRootStore.events`). Scoped to this popup, + * so open-change events carry no cross-popup bleed. Use `events.emit('openchange', …)` when + * the popup's `open` state changes; dismissal / focus manager subscribe here, not on the tree. + */ + readonly events: RdxFloatingEvents = + createFloatingEvents(); private floatingElementRef: HTMLElement | null = null; private referenceElementRef: Element | null = null; diff --git a/packages/primitives/core/src/floating/floating-tree.ts b/packages/primitives/core/src/floating/floating-tree.ts index d4cebb66..b30e519e 100644 --- a/packages/primitives/core/src/floating/floating-tree.ts +++ b/packages/primitives/core/src/floating/floating-tree.ts @@ -99,16 +99,18 @@ function nodeIsOpen(node: RdxFloatingNode): boolean { /** * The shared floating tree (node store) — the Angular counterpart of Base UI's `FloatingTreeStore`. * - * It owns a flat list of {@link RdxFloatingNode | nodes} linked by `parent` and a neutral typed - * {@link RdxFloatingEvents | event channel}. It owns **neither** trigger registries **nor** `open` - * state — those live per-popup on each {@link RdxFloatingRootContext} (Base UI keeps them on the root - * store, not the tree store). Dismissal (ADR 0015) and the focus manager (ADR 0017) read the **same** - * nodes, traversal, and events; neither owns the tree. + * It owns a flat set of {@link RdxFloatingNode | nodes} linked by `parent`, an adjacency index for + * O(1) child lookup, and a neutral typed {@link RdxFloatingEvents | event channel}. It owns **neither** + * trigger registries **nor** `open` state — those live per-popup on each {@link RdxFloatingRootContext} + * (Base UI keeps them on the root store, not the tree store). Dismissal (ADR 0015) and the focus + * manager (ADR 0017) read the **same** nodes, traversal, and events; neither owns the tree. * * Ancestry is **logical** (DI-derived), not DOM-derived, so portal relocation never changes ownership - * (ADR 0015 §1). "Topmost within a tree" is the deepest open descendant — resolved here, never from - * DOM or construction order. Independent roots are **not** coordinated against each other (Base UI - * parity): the tree only answers questions *within* itself. + * (ADR 0015 §1). Independent roots are **not** coordinated against each other (Base UI parity): the + * tree only answers questions *within* itself. + * + * **Performance:** `isRegistered()` is O(1) via `nodeSet`; `directChildren()` is O(1) via the + * `childrenOf` adjacency map; `ancestors()` is O(depth); `children()` is O(n) total. */ export class RdxFloatingTree { /** @@ -118,7 +120,17 @@ export class RdxFloatingTree { */ readonly events: RdxFloatingEvents = createFloatingEvents(); - private readonly nodes: RdxFloatingNode[] = []; + /** O(1) membership test and snapshot for `all`. */ + private readonly nodeSet = new Set(); + + /** + * Adjacency index: maps each node (or `null` for root nodes) to its direct children in + * registration order. Maintained in sync by `register`, `unregister`, and `setParent`. + * Eliminates the O(n) `filter` per node in recursive traversal. Invariant: only **non-empty** + * arrays are stored — an entry is pruned the moment its last child leaves, so a key never + * outlives its node (see `removeFromChildrenOf`). + */ + private readonly childrenOf = new Map(); /** Registers a new node. `init.parent` must already be resolved (DI layer handles `inherit`). */ register(init: RdxFloatingNodeInit): RdxFloatingNode { @@ -132,7 +144,8 @@ export class RdxFloatingTree { } const node = new RdxFloatingNode(NODE_CONSTRUCT_KEY, init.id, this, init.parent, init.context); - this.nodes.push(node); + this.nodeSet.add(node); + this.addToChildrenOf(init.parent, node); return node; } @@ -141,10 +154,13 @@ export class RdxFloatingTree { if (isDevMode()) { this.assertOwnedNode(node); } - const index = this.nodes.indexOf(node); - if (index !== -1) { - this.nodes.splice(index, 1); - } + this.nodeSet.delete(node); + this.removeFromChildrenOf(node.parent, node); + // `childrenOf.get(node)` (this node's OWN child list) is intentionally NOT cleared here while + // it still has registered children: those orphans keep their `parent` ref and must be able to + // remove themselves later. The key is pruned automatically once the last orphan unregisters + // (removeFromChildrenOf deletes empty lists), so the node is not retained. Orphans are never + // reached by traversal meanwhile, since isRegistered(node) = false. } /** @@ -160,8 +176,8 @@ export class RdxFloatingTree { if (isDevMode() && context !== null) { // dev-only: expensive ancestry/subtree document validation. this.assertContextDocument(context, this.nearestContext(node.parent)); - for (const descendantContext of this.descendantContexts(node)) { - this.assertContextDocument(context, descendantContext); + for (const dc of this.descendantContexts(node)) { + this.assertContextDocument(context, dc); } } @@ -174,11 +190,19 @@ export class RdxFloatingTree { this.assertOwnedNode(node); this.assertRegisterableParent(parent); - // The cycle check is ALSO structural — a cycle would make traversal (children / deepestOpen / - // ancestors / nearestContext) recurse/loop forever — so it runs in production too. Walk the + // No-op reparent: the parent is unchanged, so there is nothing to do — and crucially we must + // NOT fall through, because removeFromChildrenOf + addToChildrenOf would move `node` to the + // END of its sibling list, silently changing traversal/focus order (Base UI keeps node order + // stable). The guards above still run, so a foreign/unregistered node is rejected first. + if (node.parent === parent) { + return; + } + + // The cycle check is ALSO structural — a cycle would make traversal (children / ancestors / + // nearestContext) recurse/loop forever — so it runs in production too. Walk the // prospective parent chain (stopping at an unregistered node, like `ancestors`); reaching `node` // means an ancestry cycle. O(depth). - for (let ancestor = parent; ancestor !== null && this.isRegistered(ancestor); ancestor = ancestor.parent) { + for (let ancestor = parent; ancestor !== null && this.nodeSet.has(ancestor); ancestor = ancestor.parent) { if (ancestor === node) { rdxDevError( 'floating/parent-cycle', @@ -189,18 +213,14 @@ export class RdxFloatingTree { } if (isDevMode()) { - // dev-only: expensive full-subtree owner-document validation. The node's ENTIRE subtree must - // stay document-consistent with the new ancestry — check the node's own context AND every - // descendant context (a contextless subtree may hold several documents until a context - // bridges them). - const ancestorContext = this.nearestContext(parent); - this.assertContextDocument(node.context, ancestorContext); - for (const descendantContext of this.descendantContexts(node)) { - this.assertContextDocument(descendantContext, ancestorContext); - } + // dev-only: validate the WHOLE subtree against the new ancestry. + this.assertSubtreeDocuments(node, parent); } + const oldParent = node.parent; // capture before updating internals nodeInternals.get(node)!.parent = parent; + this.removeFromChildrenOf(oldParent, node); + this.addToChildrenOf(parent, node); } /** @@ -209,10 +229,11 @@ export class RdxFloatingTree { * at a closed node, so a keep-mounted/closed parent never hides an open grandchild (Base UI * `getNodeChildren`, ADR 0015 §1 traversal contract). * - * Dismissal and focus-out **containment** pass `onlyOpen: true` (Base UI `movedToUnrelatedNode` - * walks `getNodeChildren` with the default `onlyOpen=true`); only the focus-return / unmount path - * passes `onlyOpen: false`, so focus inside a mounted-but-closed descendant still counts as inside - * the tree (ADR 0017 #4 / #8, Base UI `FloatingFocusManager.tsx:842`). + * Dismissal children queries pass `onlyOpen: true` (the `hasBlockingChild` pattern in the + * capability). The focus manager's focus-return check passes `onlyOpen: false` explicitly (Base UI + * `FloatingFocusManager.tsx:842`) — so focus inside a closed-but-mounted descendant still counts as + * "inside the tree". Always pass `onlyOpen` explicitly for non-dismissal paths; do not inherit the + * default. */ children(node: RdxFloatingNode, options: { onlyOpen?: boolean } = {}): RdxFloatingNode[] { if (isDevMode()) { @@ -240,69 +261,63 @@ export class RdxFloatingTree { * unregistered node**: Base UI resolves ancestry by `parentId` lookup in the live nodes array, so * unregistering a parent breaks the chain (a removed middle node truncates ancestry — its children * keep the raw `parent` identity but it no longer appears as an ancestor). This avoids a "ghost" - * ancestor lingering in DI-ownership / document / dismissal traversal when Angular destroys a parent - * before its child. + * ancestor lingering in DI-ownership / document / dismissal/focus traversal when Angular destroys a + * parent before its child. */ ancestors(node: RdxFloatingNode): RdxFloatingNode[] { if (isDevMode()) { this.assertOwnedNode(node); } const result: RdxFloatingNode[] = []; - for (let current = node.parent; current !== null && this.isRegistered(current); current = current.parent) { + for (let current = node.parent; current !== null && this.nodeSet.has(current); current = current.parent) { result.push(current); } return result; } - /** - * The deepest **open** descendant of `node` — "topmost within the tree" for Escape/outside-press - * ownership (Base UI `getDeepestNode`). Returns `null` when `node` has no open descendant. - */ - deepestOpen(node: RdxFloatingNode): RdxFloatingNode | null { - if (isDevMode()) { - this.assertOwnedNode(node); - } - let deepest: RdxFloatingNode | null = null; - let maxDepth = -1; - - const visit = (current: RdxFloatingNode, depth: number): void => { - if (depth > maxDepth) { - maxDepth = depth; - deepest = current; - } - for (const child of this.directChildren(current)) { - if (nodeIsOpen(child)) { - visit(child, depth + 1); - } - } - }; + /** Snapshot of all registered nodes (debugging / diagnostics). Registration order is preserved. */ + get all(): readonly RdxFloatingNode[] { + return [...this.nodeSet]; + } - // Start below `node`: only its own open descendants qualify as "topmost". - for (const child of this.directChildren(node)) { - if (nodeIsOpen(child)) { - visit(child, 0); - } - } + // ─── Private adjacency helpers ─────────────────────────────────────────── - return deepest; + /** Direct children of `parent` in registration order. O(1) via the adjacency map. */ + private directChildren(parent: RdxFloatingNode): RdxFloatingNode[] { + return this.childrenOf.get(parent) ?? []; } - /** Snapshot of all registered nodes (debugging / diagnostics). */ - get all(): readonly RdxFloatingNode[] { - return this.nodes; + private addToChildrenOf(parent: RdxFloatingNode | null, node: RdxFloatingNode): void { + let children = this.childrenOf.get(parent); + if (!children) { + children = []; + this.childrenOf.set(parent, children); + } + children.push(node); } - /** Direct children of `node`, in registration order. */ - private directChildren(node: RdxFloatingNode): RdxFloatingNode[] { - return this.nodes.filter((candidate) => candidate.parent === node); + private removeFromChildrenOf(parent: RdxFloatingNode | null, node: RdxFloatingNode): void { + const children = this.childrenOf.get(parent); + if (!children) return; + const idx = children.indexOf(node); + if (idx !== -1) children.splice(idx, 1); + // Prune the now-empty list so its `parent` key (a STRONG ref to a node) is released. Without + // this an unregistered node that ever had a child lingers as a map key forever — retaining the + // node → context → floating/reference DOM elements (a leak that grows on every nested + // mount/unmount). `childrenOf` therefore only ever holds non-empty arrays. + if (children.length === 0) { + this.childrenOf.delete(parent); + } } + // ─── Private traversal helpers ─────────────────────────────────────────── + /** * Nearest context-bearing node walking up from `node` (inclusive), skipping contextless ancestors. * Stops at an unregistered node (same ghost-ancestry rule as {@link ancestors}). */ private nearestContext(node: RdxFloatingNode | null): RdxFloatingRootContext | null { - for (let current = node; current !== null && this.isRegistered(current); current = current.parent) { + for (let current = node; current !== null && this.nodeSet.has(current); current = current.parent) { if (current.context !== null) { return current.context; } @@ -318,17 +333,32 @@ export class RdxFloatingTree { } /** - * Guards that `node` actually belongs to **this** tree and is still registered — so a tree can - * never mutate/traverse a node owned by another tree (which would leave `node.tree` pointing - * elsewhere while its ancestry leads here) or one that was already unregistered. + * Dev-only: validates every context in `node`'s subtree (node + transitive descendants) against + * the nearest context-bearing ancestor walking up from `newParent`. Shared between `setParent` + * (moves a subtree under a new parent) to keep the document-consistency rule in one place. */ - /** Whether `node` is currently registered in this tree. */ + private assertSubtreeDocuments(node: RdxFloatingNode, newParent: RdxFloatingNode | null): void { + const ancestorCtx = this.nearestContext(newParent); + this.assertContextDocument(node.context, ancestorCtx); + for (const dc of this.descendantContexts(node)) { + this.assertContextDocument(dc, ancestorCtx); + } + } + + // ─── Private invariant guards ───────────────────────────────────────────── + + /** Whether `node` is currently registered in this tree. O(1). */ private isRegistered(node: RdxFloatingNode): boolean { - return this.nodes.includes(node); + return this.nodeSet.has(node); } + /** + * Guards that `node` actually belongs to **this** tree and is still registered — so a tree can + * never mutate/traverse a node owned by another tree (which would leave `node.tree` pointing + * elsewhere while its ancestry leads here) or one that was already unregistered. + */ private assertOwnedNode(node: RdxFloatingNode): void { - if (node.tree !== this || !this.isRegistered(node)) { + if (node.tree !== this || !this.nodeSet.has(node)) { rdxDevError( 'floating/foreign-node', 'This node does not belong to this tree (or was already unregistered).', @@ -343,7 +373,7 @@ export class RdxFloatingTree { if (parent.tree !== this) { rdxDevError('floating/cross-tree-parent', 'A floating node parent must belong to the same tree.', DOCS); } - if (!this.isRegistered(parent)) { + if (!this.nodeSet.has(parent)) { rdxDevError( 'floating/unregistered-parent', 'A floating node parent must be currently registered in the tree.', diff --git a/packages/primitives/core/src/floating/provide-floating-tree.ts b/packages/primitives/core/src/floating/provide-floating-tree.ts index a145b54d..3f0f044c 100644 --- a/packages/primitives/core/src/floating/provide-floating-tree.ts +++ b/packages/primitives/core/src/floating/provide-floating-tree.ts @@ -1,58 +1,78 @@ import { inject, InjectionToken, Provider } from '@angular/core'; -import { RdxFloatingNode, RdxFloatingParentOverride, RdxFloatingTree } from './floating-tree'; +import { RdxFloatingRootContext } from './floating-root-context'; +import { RdxFloatingTree } from './floating-tree'; /** * The nearest shared {@link RdxFloatingTree}. **Scoped-by-default, sharing explicit** — strict Base UI - * parity: Base UI creates a `FloatingTree` only where nested floating elements must coordinate. There is - * deliberately **no** application-root provider, so the token resolves only under a root that opts in - * with {@link provideFloatingTree}; elsewhere injecting it optionally yields `null` and the primitive is - * its own independent root (`parent === null`). This keeps each tree's `events` channel private to its - * coordinating subtree (no app-wide leak) without forcing node identity onto every event. - * - * A nesting-capable root (Menu/Menubar/nested Dialog) provides one tree for its descendants; a - * standalone popup needs no shared tree at all. + * parity: Base UI creates a `FloatingTree` only at the **coordination boundary** (e.g. a top-level Menu + * renders ``, a nested submenu does **not** — it inherits the parent's store, + * `MenuRoot.tsx:533`). There is deliberately **no** application-root provider, so the token resolves only + * under a root that opts in with {@link provideFloatingTree}; elsewhere injecting it optionally yields + * `null` and the primitive is its own independent root (`parent === null`). */ export const RDX_FLOATING_TREE = new InjectionToken('RdxFloatingTree'); /** - * Provides a {@link RdxFloatingTree} for a subtree — the Angular `FloatingTree` analogue. A - * nesting-capable root puts this in its `providers` so descendants join the same tree. + * Provides a {@link RdxFloatingTree} for a subtree — the Angular `FloatingTree` analogue. **Inherit-or- + * create:** it returns the **nearest ancestor tree** if one is already provided above, and creates a new + * one **only at the top coordination boundary**. This is what makes it safe for a nesting-capable root + * (Menu/Menubar/Context Menu/nested Dialog) to put it in `providers` unconditionally — a **nested** + * instance inherits the parent's tree (so its node parents correctly), while the **top** instance starts + * the store. (An always-new tree on a nested root would split ancestry / throw `cross-tree-parent`.) + * + * **Tree selection is separate from parent assignment** (Base UI: `tree = externalTree ?? contextTree`, + * `parentId = nearest FloatingNodeContext`). This helper + {@link resolveFloatingTree} own tree + * selection. Parent assignment is resolved at runtime via the `RdxFloatingRegistrationContext` handle + * (`parentReg.node()` in `effect()`). In particular `{ kind: 'root' }` is **not** tree isolation — it + * sets `parent = null` **within the same tree**. A genuinely separate tree is supplied explicitly via + * `resolveFloatingTree(externalTree)`. */ export function provideFloatingTree(): Provider { - return { provide: RDX_FLOATING_TREE, useFactory: () => new RdxFloatingTree() }; + return { + provide: RDX_FLOATING_TREE, + useFactory: () => inject(RDX_FLOATING_TREE, { optional: true, skipSelf: true }) ?? new RdxFloatingTree() + }; } /** - * The nearest enclosing {@link RdxFloatingNode}, used to resolve a child's **logical** parent - * (`inherit`). A floating directive provides itself under this token so descendants reparent to it - * regardless of where the portal relocates them in the DOM — the Angular analogue of Base UI's - * `FloatingNodeContext` (`parentId`). + * Resolves **which tree** a node joins — the tree-selection contract, the Angular counterpart of Base + * UI's `externalTree ?? contextTree` (`FloatingTree.tsx:25`). An explicit `externalTree` wins, + * otherwise the nearest injected {@link RDX_FLOATING_TREE} (or `null` → the capability runs + * **node-optional**). Parent assignment is separate — resolved reactively via + * `parentReg.node()` from the {@link RDX_FLOATING_REGISTRATION} handle, not via a token. * - * This is **tree selection / parent assignment only**: a detached *trigger* is never provided here - * (it has no node, ADR 0015 §1/§2). + * For a **detached** node registered with an explicit `{ kind: 'node', parent }` override from a sibling + * injector, the nearest injected tree may be absent or a *different* tree than `parent.tree` — so the + * caller **must** pass `externalTree = override.parent.tree` here, so the node joins its parent's tree (the + * cross-tree invariant then holds). Must be called in an injection context when `externalTree` is omitted. */ -export const RDX_FLOATING_NODE = new InjectionToken('RdxFloatingNode'); +export function resolveFloatingTree(externalTree?: RdxFloatingTree | null): RdxFloatingTree | null { + return externalTree ?? inject(RDX_FLOATING_TREE, { optional: true }); +} /** - * Resolves a {@link RdxFloatingParentOverride} to a concrete parent node, in an injection context: + * The shared per-popup {@link RdxFloatingRootContext} — the Angular counterpart of Base UI's + * `FloatingRootContext`, created by `useFloatingRootContext` at the **primitive root** and **received** + * by `useDismiss` / `FloatingFocusManager` (they never create their own). Mirroring that: a primitive + * root (Dialog/Popover/Menu/…) creates **one** context and provides it here; the dismissal capability + * (ADR 0015) and the focus manager (ADR 0017) read the **same** context, so `open`, `triggers`, and the + * elements are never split across mechanisms. * - * - `inherit` (default) → the nearest enclosing {@link RDX_FLOATING_NODE} (DI ancestry), or `null`; - * - `root` → `null` (independent root — DI ancestry ignored); - * - `node` → the explicitly supplied parent (detached injector-subtree composition). - * - * Keeping `inherit` and `root` distinct is the whole point of the discriminated override: "no - * override" and "explicit independent root" must not both collapse to `parent == null` by accident. + * Optional: a **standalone** `rdxDismissableLayer` (no enclosing primitive root) has none, and + * {@link injectFloatingRootContext} creates a fallback for that case only. + */ +export const RDX_FLOATING_ROOT_CONTEXT = new InjectionToken('RdxFloatingRootContext'); + +/** Provides the shared {@link RdxFloatingRootContext} for a primitive root's subtree. */ +export function provideFloatingRootContext(factory: () => RdxFloatingRootContext): Provider { + return { provide: RDX_FLOATING_ROOT_CONTEXT, useFactory: factory }; +} + +/** + * Returns the shared {@link RdxFloatingRootContext} provided by an enclosing primitive root, or creates + * a **standalone fallback** via `fallback()` when none is provided (a bare `rdxDismissableLayer`). Must be + * called in an injection context. */ -export function resolveFloatingParent( - override: RdxFloatingParentOverride = { kind: 'inherit' } -): RdxFloatingNode | null { - switch (override.kind) { - case 'root': - return null; - case 'node': - return override.parent; - case 'inherit': - default: - return inject(RDX_FLOATING_NODE, { optional: true, skipSelf: true }); - } +export function injectFloatingRootContext(fallback: () => RdxFloatingRootContext): RdxFloatingRootContext { + return inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }) ?? fallback(); } diff --git a/packages/primitives/dismissable-layer/__tests__/dismissable-layer-characterization.spec.ts b/packages/primitives/dismissable-layer/__tests__/dismissable-layer-characterization.spec.ts new file mode 100644 index 00000000..11fe601a --- /dev/null +++ b/packages/primitives/dismissable-layer/__tests__/dismissable-layer-characterization.spec.ts @@ -0,0 +1,129 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RdxDismissableLayer } from '../src/dismissable-layer'; +import { RdxDismissableLayerBranch } from '../src/dismissable-layer-branch'; +import { RdxDismissableLayersContextToken } from '../src/dismissable-layer.config'; + +/** + * Characterization baseline for the behaviors that ADR 0015 Phase 1 rewrites through the shared + * tree/capability — they must pass against the CURRENT (pre-refactor) implementation so the refactor is + * proven behavior-preserving. (Portaled-child / non-modal-child cases are the known-bug targets and are + * authored together with the Phase 2 fix, not here.) + */ +@Component({ + imports: [RdxDismissableLayer, RdxDismissableLayerBranch], + template: ` +
Layer
+
Branch
+ +
Outside
+ + @if (showNested()) { +
Parent
+ +
+ Child + inside child +
+ } + ` +}) +class Host { + readonly onDismiss = vi.fn(); + readonly onDismissParent = vi.fn(); + readonly onDismissChild = vi.fn(); + readonly showNested = signal(false); + readonly childModal = signal(true); +} + +/** The document `pointerdown` listener registers on a `setTimeout(0)`; drain a macrotask first. */ +function flushListenerRegistration(): Promise { + return new Promise((resolve) => setTimeout(resolve)); +} + +function pointerDownOn(element: Element): void { + element.dispatchEvent(new Event('pointerdown', { bubbles: true })); +} + +/** Focus-outside defers two microtasks before deciding; drain them after dispatching `focusin`. */ +async function focusInOn(element: Element): Promise { + element.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('RdxDismissableLayer — characterization baseline', () => { + let fixture: ComponentFixture; + let host: Host; + + function query(selector: string): HTMLElement { + return fixture.nativeElement.querySelector(selector) as HTMLElement; + } + + beforeEach(async () => { + document.body.style.pointerEvents = ''; + fixture = TestBed.createComponent(Host); + host = fixture.componentInstance; + fixture.detectChanges(); + await flushListenerRegistration(); + }); + + afterEach(() => { + fixture.destroy(); + document.body.style.pointerEvents = ''; + }); + + it('control: a pointerdown on an unrelated outside element DOES dismiss', () => { + pointerDownOn(query('div:last-of-type')); + expect(host.onDismiss).toHaveBeenCalledTimes(1); + }); + + it('a pointerdown on a registered branch does NOT dismiss', () => { + pointerDownOn(query('[rdxDismissableLayerBranch]')); + expect(host.onDismiss).not.toHaveBeenCalled(); + }); + + it('a focus moving into a registered branch does NOT dismiss', async () => { + await focusInOn(query('[rdxDismissableLayerBranch]')); + expect(host.onDismiss).not.toHaveBeenCalled(); + }); + + it('a pointerdown on an associated trigger (registered as a branch) does NOT dismiss', async () => { + // Menu / Navigation Menu register their trigger as an inside-element via the branch array today. + const trigger = query('button'); + const context = TestBed.inject(RdxDismissableLayersContextToken); + context.branches.update((branches) => [...branches, trigger]); + + pointerDownOn(trigger); + + expect(host.onDismiss).not.toHaveBeenCalled(); + }); + + it('a pointerdown inside a sibling MODAL layer does NOT dismiss the layer below it (pointer-events suppression)', async () => { + host.childModal.set(true); + host.showNested.set(true); + fixture.detectChanges(); + await flushListenerRegistration(); + + // the span is in the sibling child — genuinely OUTSIDE the parent's DOM — so only the modal + // pointer-events layering protects the parent + pointerDownOn(query('#inside-child')); + + expect(host.onDismissParent).not.toHaveBeenCalled(); + }); + + it('control: a pointerdown inside a sibling NON-MODAL layer DOES dismiss the layer below it', async () => { + host.childModal.set(false); + host.showNested.set(true); + fixture.detectChanges(); + await flushListenerRegistration(); + + // proves the harness really delivers an outside-pointer to the parent from the sibling — so the + // modal test above is meaningful (it is the suppression, not DOM containment, that protects) + pointerDownOn(query('#inside-child')); + + expect(host.onDismissParent).toHaveBeenCalledTimes(1); + }); +}); From f9db173d8d8d78944dc5c5e16186c105902ba464 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Sun, 14 Jun 2026 22:51:41 +0300 Subject: [PATCH 03/35] fix: tri-state, DI and etc --- .../0015-base-ui-aligned-dismissal-engine.md | 63 +++++-- .../__tests__/floating-registration.spec.ts | 90 +++++++++- .../core/__tests__/floating-tree.spec.ts | 11 ++ .../src/floating/floating-registration.ts | 163 ++++++++++++++---- .../core/src/floating/trigger-registry.ts | 14 +- 5 files changed, 280 insertions(+), 61 deletions(-) diff --git a/docs/adr/0015-base-ui-aligned-dismissal-engine.md b/docs/adr/0015-base-ui-aligned-dismissal-engine.md index 6be8af65..4d5fc746 100644 --- a/docs/adr/0015-base-ui-aligned-dismissal-engine.md +++ b/docs/adr/0015-base-ui-aligned-dismissal-engine.md @@ -960,32 +960,61 @@ mix of legacy and migrated consumers, so the mixed-state failure modes cannot oc **Angular DI propagation — handle pattern (decided; do not use dynamic token replacement).** Angular injectors are sealed at creation time: a directive cannot change what `RDX_FLOATING_TREE` resolves to for descendants after it processes runtime inputs. Instead, the directive provides a - `RdxFloatingRegistrationContext` (a stable DI handle with a **single atomic state signal** — not two - independent `WritableSignal` fields) via `provideFloatingRegistration()` **in its `providers` array** - (at injector creation). After resolving inputs in an `effect()`, the directive calls - `selfReg.register(resolvedTree, registeredNode)`. Descendants inject the handle with - `{ optional: true, skipSelf: true }` and read `parentReg.tree()` / `parentReg.node()` reactively. + `RdxFloatingRegistrationContext` (a stable DI handle with a **single atomic state signal** holding a + **three-state lifecycle** — `pending | detached | registered` — not two independent `WritableSignal` + fields) via `provideFloatingRegistration()` **in its `providers` array** (at injector creation). After + resolving inputs in an `effect()`, the directive calls `selfReg.register(resolvedTree, registeredNode)` + (→ `registered`) or `selfReg.markDetached()` (→ `detached`, node-optional). Descendants inject the + handle with `{ optional: true, skipSelf: true }` and read `parentReg.status()` / `parentReg.tree()` / + `parentReg.node()` reactively. + + **Reader / writer split.** `provideFloatingRegistration()` returns **two** providers over **one** + instance: the concrete `RdxFloatingRegistrationContext` (the writer — `register` / `markDetached` / + `clear`) and a `useExisting` alias under the reader-typed `RDX_FLOATING_REGISTRATION` token. The owning + directive injects the **class** (writer) for its own handle; a descendant injects the **token** + (`RdxFloatingRegistrationReader`: only `status` / `tree` / `node`). So a descendant cannot clear or + re-point its parent's registration — the reader/writer boundary is enforced at the type level. + + **Why three states (not nullable).** A child must distinguish a parent that is **still resolving** + (`pending`) from one that **resolved with no node** (`detached`, node-optional). Both report + `node() === null`, so a two-state `null | {tree,node}` handle conflates them: a child seeing the + initial `null` could fall back to the ambient tree and **transiently register as a root in the wrong + tree** before the parent finishes resolving. With `status()`, a child **waits** on `pending` (its + effect re-runs reactively when the parent flips) and only treats `detached` as "no parent". + + **Only `inherit` waits — which is also why a destroyed parent never strands a child.** The wait is + gated on `override.kind === 'inherit'`: a `root` / `node` override does not depend on the DI parent and + registers immediately (waiting on a `pending` DI parent would wrongly stall it, or — once that parent + is destroyed and its handle is fixed at `pending` by the final `clear()` — strand it forever). And an + `inherit` node is by definition a **DI descendant** of the parent whose handle it reads, so Angular + tears it down together with (before) that parent: it can never survive to observe the parent's + post-destroy `pending`. So the only handle that ever waits is one guaranteed to die with its parent — + `clear()`'s transient `pending` is safe, and no extra terminal/destroyed state is needed. Concrete sequence in `providers` + `constructor`: 1. `providers: [provideFloatingRegistration()]` — seals the stable handle at injector creation. 2. Inject at construction time (injection context): - `selfReg = inject(RDX_FLOATING_REGISTRATION)`, - `parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true })`, + `selfReg = inject(RdxFloatingRegistrationContext)` (the concrete writer, own handle), + `parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true })` (reader), `ambientTree = inject(RDX_FLOATING_TREE, { optional: true })`. - 3. In `effect((onCleanup) => { … })`: read `this.externalTreeInput()` (an `input()` signal), then - resolve `tree = externalTree ?? parentReg?.tree() ?? ambientTree`. For `inherit`, resolve - `parentNode = parentReg?.node() ?? null`; for `root` override, `parentNode = null`; for `node` - override, `parentNode = override.parent`. + 3. In `effect((onCleanup) => { … })`: resolve `override` first, then wait **only for `inherit`** — + `if (override.kind === 'inherit' && parentReg?.status() === 'pending') return;` (reading `status()` + subscribes us, so the effect re-runs on the parent's next transition). `root` / `node` overrides are + independent of the DI ancestor and proceed immediately. Then resolve `parentNode`: `inherit` → + `parentReg?.node() ?? null` (a `detached` parent reads `null` → this node becomes a root in its + tree); `root` → `null`; `node` → `override.parent`. Resolve the tree: + `tree = (override.kind === 'node' ? override.parent.tree : undefined) ?? externalTree ?? +parentReg?.tree() ?? ambientTree`. 4. If tree is non-null: `const node = tree.register(…)`, then `selfReg.register(tree, node)`; - cleanup on `onCleanup(() => { tree.unregister(node); selfReg.clear(); })`. - 5. If tree is null: node-optional mode — capability runs without a tree node; only root context is - injected. `selfReg.tree()` stays `null`. + cleanup on `onCleanup(() => { tree.unregister(node); selfReg.clear(); })` (→ back to `pending`). + 5. If tree is null: node-optional mode — `selfReg.markDetached()`; the capability runs without a tree + node, reading only the root context. `selfReg.status()` is `detached`, `selfReg.tree()` is `null`. `inject()` is **not** available inside `effect()` (no injection context there), so all DI resolution (`inject(RDX_FLOATING_REGISTRATION, …)`, `inject(RDX_FLOATING_TREE, …)`) happens in the constructor. - The handle's `parentReg.node()` is the single mechanism for parent resolution — there is no separate - `resolveFloatingParent` / `RDX_FLOATING_NODE` API (those were removed from the foundation; the handle - subsumes them). + The handle's `parentReg.status()` / `parentReg.node()` is the single mechanism for parent resolution — + there is no separate `resolveFloatingParent` / `RDX_FLOATING_NODE` API (those were removed from the + foundation; the handle subsumes them). - Build the dismissal-ownership resolution within the capability using `tree.children(node, { onlyOpen: true }).some(isBlocking)` — the `hasBlockingChild` pattern lives in diff --git a/packages/primitives/core/__tests__/floating-registration.spec.ts b/packages/primitives/core/__tests__/floating-registration.spec.ts index fe4b3187..d4f0ace4 100644 --- a/packages/primitives/core/__tests__/floating-registration.spec.ts +++ b/packages/primitives/core/__tests__/floating-registration.spec.ts @@ -12,8 +12,9 @@ import { provideFloatingTree, RDX_FLOATING_TREE, resolveFloatingTree } from '../ // ─── RdxFloatingRegistrationContext ────────────────────────────────────────── describe('RdxFloatingRegistrationContext', () => { - it('starts with tree and node both null', () => { + it('starts pending, with tree and node both null', () => { const ctx = new RdxFloatingRegistrationContext(); + expect(ctx.status()).toBe('pending'); expect(ctx.tree()).toBeNull(); expect(ctx.node()).toBeNull(); }); @@ -86,14 +87,77 @@ describe('RdxFloatingRegistrationContext', () => { ctx.clear(); expect(derivedTree()).toBeNull(); }); + + // ─── tri-state lifecycle: pending | detached | registered ──────────────── + + it('register() transitions status to "registered"', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + ctx.register(tree, node); + expect(ctx.status()).toBe('registered'); + }); + + it('markDetached() resolves to "detached" with no node (node-optional)', () => { + const ctx = new RdxFloatingRegistrationContext(); + + ctx.markDetached(); + + expect(ctx.status()).toBe('detached'); + // resolved, but deliberately node-less — readers treat this parent as absent, not pending + expect(ctx.tree()).toBeNull(); + expect(ctx.node()).toBeNull(); + }); + + it('distinguishes pending (still resolving) from detached (resolved, no node)', () => { + const ctx = new RdxFloatingRegistrationContext(); + + // both report node() === null, but the STATUS lets a child decide wait-vs-fallback + expect(ctx.status()).toBe('pending'); + expect(ctx.node()).toBeNull(); + + ctx.markDetached(); + expect(ctx.status()).toBe('detached'); + expect(ctx.node()).toBeNull(); + }); + + it('clear() reverts to "pending" (not detached) — the onCleanup phase before re-resolution', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + ctx.register(tree, node); + ctx.clear(); + + expect(ctx.status()).toBe('pending'); + expect(ctx.tree()).toBeNull(); + expect(ctx.node()).toBeNull(); + }); + + it('status() is reactive across every transition (pending → registered → pending → detached)', () => { + const ctx = new RdxFloatingRegistrationContext(); + const tree = new RdxFloatingTree(); + const node = tree.register({ id: 'root', parent: null, context: null }); + + const derivedStatus = computed(() => ctx.status()); + + expect(derivedStatus()).toBe('pending'); + ctx.register(tree, node); + expect(derivedStatus()).toBe('registered'); + ctx.clear(); + expect(derivedStatus()).toBe('pending'); + ctx.markDetached(); + expect(derivedStatus()).toBe('detached'); + }); }); // ─── provideFloatingRegistration ────────────────────────────────────────────── describe('provideFloatingRegistration()', () => { it('creates a fresh RdxFloatingRegistrationContext per injector', () => { - const injA = Injector.create({ providers: [provideFloatingRegistration()] }); - const injB = Injector.create({ providers: [provideFloatingRegistration()] }); + const injA = Injector.create({ providers: [...provideFloatingRegistration()] }); + const injB = Injector.create({ providers: [...provideFloatingRegistration()] }); const ctxA = injA.get(RDX_FLOATING_REGISTRATION); const ctxB = injB.get(RDX_FLOATING_REGISTRATION); @@ -103,6 +167,22 @@ describe('provideFloatingRegistration()', () => { expect(ctxA).not.toBe(ctxB); }); + it('owner injects the concrete writer; the reader token aliases the SAME instance (one instance, two views)', () => { + const inj = Injector.create({ providers: [...provideFloatingRegistration()] }); + + // The owning directive injects the concrete class — the WRITER side. + const writer = inj.get(RdxFloatingRegistrationContext); + // Descendants inject the reader-typed token — `useExisting` resolves to the same instance. + const reader = inj.get(RDX_FLOATING_REGISTRATION); + + expect(reader).toBe(writer); + // The reader surface (status/tree/node) is present and reactive; write-protection of the + // reader view is enforced at the type level (RDX_FLOATING_REGISTRATION is reader-typed). + expect(reader.status()).toBe('pending'); + expect(reader.tree()).toBeNull(); + expect(reader.node()).toBeNull(); + }); + it('a child injector resolves its own handle (not the parent handle)', () => { const parentCtx = new RdxFloatingRegistrationContext(); const parentInj = Injector.create({ @@ -110,7 +190,7 @@ describe('provideFloatingRegistration()', () => { }); const childInj = Injector.create({ - providers: [provideFloatingRegistration()], + providers: [...provideFloatingRegistration()], parent: parentInj }); @@ -130,7 +210,7 @@ describe('provideFloatingRegistration()', () => { }); const childInj = Injector.create({ - providers: [provideFloatingRegistration()], + providers: [...provideFloatingRegistration()], parent: parentInj }); diff --git a/packages/primitives/core/__tests__/floating-tree.spec.ts b/packages/primitives/core/__tests__/floating-tree.spec.ts index e7e1c889..9218a1d2 100644 --- a/packages/primitives/core/__tests__/floating-tree.spec.ts +++ b/packages/primitives/core/__tests__/floating-tree.spec.ts @@ -400,6 +400,17 @@ describe('RdxFloatingRootContext', () => { expect(ctx.triggers.contains(foreignTrigger)).toBe(true); expect(ctx.triggers.hasElement(document.createElement('button'))).toBe(false); }); + + it('contains() tolerates a non-Node EventTarget (e.g. window) without throwing', () => { + const ctx = createFloatingRootContext({ ownerDocument: document }); + ctx.triggers.add(document.createElement('button')); + + // A DOM event target can be `window` (a non-Node EventTarget); Node.contains() would throw + // on it, so contains() must guard and report `false`, not blow up the dismissal handler. + const win = document.defaultView as unknown as EventTarget; + expect(() => ctx.triggers.contains(win)).not.toThrow(); + expect(ctx.triggers.contains(win)).toBe(false); + }); }); }); diff --git a/packages/primitives/core/src/floating/floating-registration.ts b/packages/primitives/core/src/floating/floating-registration.ts index bb2cf4d3..75199265 100644 --- a/packages/primitives/core/src/floating/floating-registration.ts +++ b/packages/primitives/core/src/floating/floating-registration.ts @@ -4,10 +4,43 @@ import type { RdxFloatingNode, RdxFloatingTree } from './floating-tree'; const DOCS = 'utils/floating-tree'; -/** Atomic payload — always set or cleared together, so `node.tree === tree` is always true. */ -interface RegistrationState { - readonly tree: RdxFloatingTree; - readonly node: RdxFloatingNode; +/** The three lifecycle phases of a {@link RdxFloatingRegistrationContext} (see {@link RegistrationState}). */ +export type RdxFloatingRegistrationStatus = 'pending' | 'detached' | 'registered'; + +/** + * The handle's lifecycle as a **discriminated union** — three states a nullable payload would conflate: + * + * - **`pending`** — the directive's `effect()` has not resolved yet (the initial state, and the + * transient window between an `onCleanup` and the next re-resolution). A child observing a `pending` + * parent must **wait** — its own effect re-runs reactively when the parent's `status` flips — and must + * **not** treat the parent as absent and fall back to the ambient tree / become a root. + * - **`detached`** — the directive resolved and deliberately has **no node** (node-optional: no tree was + * available, e.g. a standalone `rdxDismissableLayer`). A child treats a `detached` parent as absent + * (logical parent `null`). + * - **`registered`** — joined `tree` as `node`. The only state carrying a payload, and the only one + * where `node`/`tree` read non-null; `node.tree === tree` holds because both are set together. + * + * The `pending` vs `detached` split is the whole point: "still resolving" and "resolved, no node" must + * be distinguishable, or a child races and transiently mis-registers as a root in the wrong tree. + */ +type RegistrationState = + | { readonly status: 'pending' } + | { readonly status: 'detached' } + | { readonly status: 'registered'; readonly tree: RdxFloatingTree; readonly node: RdxFloatingNode }; + +/** + * The **read-only projection** of a registration handle — what a **descendant** injects (it reads its + * nearest ancestor's handle to resolve its logical parent, ADR 0015 §1). It deliberately exposes only + * the reactive reads (`status` / `tree` / `node`) and **not** the writers (`register` / `markDetached` / + * `clear`), which belong solely to the directive that owns the handle: a descendant must never be able + * to clear or re-point its parent's registration. The {@link RDX_FLOATING_REGISTRATION} token is typed + * as this reader, so the ergonomic, cast-free injection path is read-only; the owning directive uses the + * concrete {@link RdxFloatingRegistrationContext} type (the writer) for its own handle. + */ +export interface RdxFloatingRegistrationReader { + readonly status: Signal; + readonly tree: Signal; + readonly node: Signal; } /** @@ -23,59 +56,95 @@ interface RegistrationState { * * **Atomicity.** `tree` and `node` are **not** separate `WritableSignal`s — independent `.set()` * calls would create intermediate states where `node.tree !== tree`. Instead there is **one** private - * state signal; `register(tree, node)` sets both together after asserting `node.tree === tree`, and - * `clear()` nulls both together. + * {@link RegistrationState} signal; `register(tree, node)` sets the `registered` payload after asserting + * `node.tree === tree`, `markDetached()` records "resolved, no node", and `clear()` reverts to + * `pending`. The `tree`/`node`/`status` reads are `computed()` over that one signal, so they can never + * disagree. * * **Registration directive usage pattern:** * * ```ts * @Directive({ providers: [provideFloatingRegistration()] }) * class SomeFloatingDirective { - * private readonly selfReg = inject(RDX_FLOATING_REGISTRATION); + * // Own handle — the WRITER side. Inject the concrete type so register()/markDetached()/clear() are + * // available; this is the only place that writes this handle. + * private readonly selfReg = inject(RdxFloatingRegistrationContext); + * // Parent handle — the READER side (token is reader-typed). A descendant can read status/tree/node + * // but cannot mutate the parent's registration. * private readonly parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true }); * private readonly ambientTree = inject(RDX_FLOATING_TREE, { optional: true }); * * constructor() { * effect((onCleanup) => { - * // `resolveFloatingTree(externalTree)` calls `inject()` — only legal at construction time, - * // not inside effect(). Here we replicate its logic with already-captured fields instead. - * const externalTree = this.externalTreeInput(); // input() signal - * const resolvedTree = externalTree ?? this.parentReg?.tree() ?? this.ambientTree; - * const parentNode = this.parentReg?.node() ?? null; // for 'inherit' + * const override = this.parentOverride(); // { kind: 'inherit' | 'root' | 'node' } + * + * // ONLY an `inherit` node depends on the DI parent, so only it waits on a `pending` parent (a + * // `pending` parent is NOT "no parent"; reading status() subscribes us, so we re-run when it + * // flips). `root` / `node` overrides are independent of the DI ancestor and register NOW — + * // waiting on the DI parent would wrongly stall them, or strand them if that parent is destroyed. + * if (override.kind === 'inherit' && this.parentReg?.status() === 'pending') return; * - * if (!resolvedTree) return; // node-optional: standalone with no tree + * // Logical parent from the override (DI parent only for `inherit`; a `detached` parent reads + * // null → this node becomes a root in its tree). + * const parentNode = + * override.kind === 'node' ? override.parent + * : override.kind === 'root' ? null + * : (this.parentReg?.node() ?? null); // 'inherit' + * + * // Tree selection (resolveFloatingTree's logic; inject() is illegal inside effect()). A `node` + * // override must join its parent's tree. + * const externalTree = this.externalTreeInput(); // input() signal + * const resolvedTree = + * (override.kind === 'node' ? override.parent.tree : undefined) ?? + * externalTree ?? this.parentReg?.tree() ?? this.ambientTree; + * if (!resolvedTree) { + * this.selfReg.markDetached(); // node-optional: resolved, but no tree → no node + * return; + * } * * const node = resolvedTree.register({ id: ..., parent: parentNode, context: ... }); * this.selfReg.register(resolvedTree, node); * * onCleanup(() => { * resolvedTree.unregister(node); - * this.selfReg.clear(); + * this.selfReg.clear(); // transient: back to 'pending' until the effect re-resolves * }); * }); * } * } * ``` */ -export class RdxFloatingRegistrationContext { - private readonly _state = signal(null); +export class RdxFloatingRegistrationContext implements RdxFloatingRegistrationReader { + private readonly _state = signal({ status: 'pending' }); + + /** + * Lifecycle phase: `pending` (resolving — children must wait), `detached` (resolved, node-optional), + * or `registered`. A `computed()` over the one internal state signal. See {@link RegistrationState}. + */ + readonly status: Signal = computed(() => this._state().status); /** - * The tree this directive joined, or `null` before `register()` is called. A `computed()` derived - * from the internal state — always consistent with {@link node}. + * The tree this directive joined, or `null` unless `status() === 'registered'`. A `computed()` + * derived from the internal state — always consistent with {@link node} and {@link status}. */ - readonly tree: Signal = computed(() => this._state()?.tree ?? null); + readonly tree: Signal = computed(() => { + const state = this._state(); + return state.status === 'registered' ? state.tree : null; + }); /** - * The node this directive registered, or `null` before `register()` is called. A `computed()` + * The node this directive registered, or `null` unless `status() === 'registered'`. A `computed()` * derived from the internal state — always consistent with {@link tree} - * (`node.tree === tree` is invariant). + * (`node.tree === tree` is invariant in the `registered` state). */ - readonly node: Signal = computed(() => this._state()?.node ?? null); + readonly node: Signal = computed(() => { + const state = this._state(); + return state.status === 'registered' ? state.node : null; + }); /** - * Atomically records the resolved tree and the registered node. Asserts `node.tree === tree` - * so no intermediate state where `tree` and `node` point to different stores can exist. + * Atomically records the resolved tree and the registered node (`status → 'registered'`). Asserts + * `node.tree === tree` so no state where `tree` and `node` point to different stores can exist. * Called by the directive inside `effect()` after `tree.register(…)` succeeds. */ register(tree: RdxFloatingTree, node: RdxFloatingNode): void { @@ -86,31 +155,49 @@ export class RdxFloatingRegistrationContext { DOCS ); } - this._state.set({ tree, node }); + this._state.set({ status: 'registered', tree, node }); + } + + /** + * Records that the directive **resolved but has no node** (`status → 'detached'`): node-optional — + * no tree was available (e.g. a standalone `rdxDismissableLayer`). Distinct from `pending`: a child + * treats a `detached` parent as absent (inherits `null`), whereas it must **wait** on a `pending` one. + */ + markDetached(): void { + this._state.set({ status: 'detached' }); } /** - * Clears both tree and node atomically (the `onCleanup` counterpart of {@link register}). - * Called after `tree.unregister(node)` so the handle reverts to the null state. + * Reverts to `pending` (the `onCleanup` counterpart of {@link register} / {@link markDetached}). + * Called after `tree.unregister(node)` so the handle re-enters the "resolving" phase until the + * directive's effect re-runs; `tree`/`node` read `null` again. */ clear(): void { - this._state.set(null); + this._state.set({ status: 'pending' }); } } /** - * DI token for the nearest ancestor's {@link RdxFloatingRegistrationContext}. Each registration - * directive provides one instance (via {@link provideFloatingRegistration}) and fills it after - * resolving its inputs. Descendants inject with `{ optional: true, skipSelf: true }` to find the - * nearest parent's handle — not the directive's own. + * DI token for the nearest ancestor's registration handle, **typed as the read-only + * {@link RdxFloatingRegistrationReader}**. A descendant injects it with `{ optional: true, skipSelf: + * true }` to read its parent's `status` / `tree` / `node` — and, because the token is reader-typed, + * **cannot** call the parent's writers (`register` / `markDetached` / `clear`) without a deliberate + * cast. The owning directive writes through its own handle, which it injects by the concrete + * {@link RdxFloatingRegistrationContext} type instead (see {@link provideFloatingRegistration}). */ -export const RDX_FLOATING_REGISTRATION = new InjectionToken('RdxFloatingRegistration'); +export const RDX_FLOATING_REGISTRATION = new InjectionToken('RdxFloatingRegistration'); /** - * Creates a {@link Provider} that seals a fresh {@link RdxFloatingRegistrationContext} into this - * directive's injector at creation time. Call this in a directive's `providers` array; the directive - * then calls `selfReg.register(tree, node)` and `selfReg.clear()` to manage the handle lifecycle. + * Seals a fresh registration handle into this directive's injector at creation time. Returns **two** + * providers backed by **one** instance: the concrete {@link RdxFloatingRegistrationContext} (the + * writer, injected by the owning directive) and a reader-typed {@link RDX_FLOATING_REGISTRATION} alias + * (`useExisting`) that descendants inject. Splitting writer from reader is what stops a descendant from + * mutating its parent's registration. Call this in a directive's `providers` array; the directive then + * calls `selfReg.register(tree, node)` / `markDetached()` / `clear()` on its own (writer) handle. */ -export function provideFloatingRegistration(): Provider { - return { provide: RDX_FLOATING_REGISTRATION, useFactory: () => new RdxFloatingRegistrationContext() }; +export function provideFloatingRegistration(): Provider[] { + return [ + { provide: RdxFloatingRegistrationContext, useFactory: () => new RdxFloatingRegistrationContext() }, + { provide: RDX_FLOATING_REGISTRATION, useExisting: RdxFloatingRegistrationContext } + ]; } diff --git a/packages/primitives/core/src/floating/trigger-registry.ts b/packages/primitives/core/src/floating/trigger-registry.ts index e25a1f34..179ca3eb 100644 --- a/packages/primitives/core/src/floating/trigger-registry.ts +++ b/packages/primitives/core/src/floating/trigger-registry.ts @@ -55,6 +55,18 @@ export class RdxTriggerRegistry { /** `true` when `target` is a registered trigger or lives inside one. */ contains(target: EventTarget | Node | null): boolean { - return this.hasElement(target) || this.hasMatchingElement(target as Node | null); + if (this.hasElement(target)) { + return true; + } + // `hasMatchingElement` calls `Node.contains()`, which requires a real `Node`. An `EventTarget` + // that is not a `Node` (e.g. `window`, a `MediaQueryList`) reaches here from a DOM event target + // and would make `contains()` throw — so duck-type `nodeType` and skip the ancestor match + // otherwise (a non-`Node` can never be a descendant of a registered element anyway). + return this.hasMatchingElement(isNode(target) ? target : null); } } + +/** Narrows an `EventTarget` to `Node` by duck-typing `nodeType` (cross-realm-safe; no `instanceof`). */ +function isNode(target: EventTarget | Node | null): target is Node { + return target !== null && typeof (target as Node).nodeType === 'number'; +} From 31d861bf764a13a46a8e7a981d813715674dd121 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 09:27:23 +0300 Subject: [PATCH 04/35] feat(dismissable-layer): parallel Base UI-aligned dismissal engine --- .../floating-node-registration.spec.ts | 191 +++++++ .../use-body-pointer-events-lock.spec.ts | 107 ++++ packages/primitives/core/index.ts | 2 + .../src/dom/use-body-pointer-events-lock.ts | 90 ++++ .../floating/floating-node-registration.ts | 105 ++++ .../__tests__/dismissable-capability.spec.ts | 472 ++++++++++++++++++ .../primitives/dismissable-layer/index.ts | 1 + .../src/dismissable-capability.ts | 371 ++++++++++++++ 8 files changed, 1339 insertions(+) create mode 100644 packages/primitives/core/__tests__/floating-node-registration.spec.ts create mode 100644 packages/primitives/core/__tests__/use-body-pointer-events-lock.spec.ts create mode 100644 packages/primitives/core/src/dom/use-body-pointer-events-lock.ts create mode 100644 packages/primitives/core/src/floating/floating-node-registration.ts create mode 100644 packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts create mode 100644 packages/primitives/dismissable-layer/src/dismissable-capability.ts diff --git a/packages/primitives/core/__tests__/floating-node-registration.spec.ts b/packages/primitives/core/__tests__/floating-node-registration.spec.ts new file mode 100644 index 00000000..7345c393 --- /dev/null +++ b/packages/primitives/core/__tests__/floating-node-registration.spec.ts @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +import { Component, signal, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { RdxFloatingNodeRegistration } from '../src/floating/floating-node-registration'; +import { createFloatingRootContext, RdxFloatingRootContext } from '../src/floating/floating-root-context'; +import { RdxFloatingParentOverride, RdxFloatingTree } from '../src/floating/floating-tree'; +import { + provideFloatingRootContext, + provideFloatingTree, + RDX_FLOATING_TREE +} from '../src/floating/provide-floating-tree'; + +describe('RdxFloatingNodeRegistration', () => { + beforeEach(() => TestBed.resetTestingModule()); + + // ─── inherit (the default) ─────────────────────────────────────────────── + + @Component({ + imports: [RdxFloatingNodeRegistration], + providers: [provideFloatingTree()], + template: ` +
+ ` + }) + class SingleHost { + readonly n = viewChild.required('n', { read: RdxFloatingNodeRegistration }); + } + + it('registers a root node under the ambient tree (inherit, no DI parent)', () => { + const fixture = TestBed.createComponent(SingleHost); + fixture.detectChanges(); + + const dir = fixture.componentInstance.n(); + const ambient = fixture.debugElement.injector.get(RDX_FLOATING_TREE); + + expect(dir.status()).toBe('registered'); + expect(dir.tree()).toBe(ambient); + expect(dir.node()).not.toBeNull(); + expect(dir.node()!.parent).toBeNull(); + expect(ambient.all).toContain(dir.node()); + }); + + @Component({ + imports: [RdxFloatingNodeRegistration], + providers: [provideFloatingTree()], + template: ` +
+
+
+ ` + }) + class NestedHost { + readonly parent = viewChild.required('parent', { read: RdxFloatingNodeRegistration }); + readonly child = viewChild.required('child', { read: RdxFloatingNodeRegistration }); + } + + it('nests a child under its DI parent in the same tree', () => { + const fixture = TestBed.createComponent(NestedHost); + fixture.detectChanges(); + + const parent = fixture.componentInstance.parent(); + const child = fixture.componentInstance.child(); + + expect(parent.tree()).toBe(child.tree()); + expect(child.node()!.parent).toBe(parent.node()); + expect(parent.tree()!.ancestors(child.node()!)).toEqual([parent.node()]); + }); + + // ─── overrides ─────────────────────────────────────────────────────────── + + @Component({ + imports: [RdxFloatingNodeRegistration], + providers: [provideFloatingTree()], + template: ` +
+
+
+ ` + }) + class OverrideHost { + readonly override = signal({ kind: 'inherit' }); + readonly parent = viewChild.required('parent', { read: RdxFloatingNodeRegistration }); + readonly child = viewChild.required('child', { read: RdxFloatingNodeRegistration }); + } + + it('{ kind: "root" } registers as an independent root despite the DI parent', () => { + const fixture = TestBed.createComponent(OverrideHost); + fixture.componentInstance.override.set({ kind: 'root' }); + fixture.detectChanges(); + + const parent = fixture.componentInstance.parent(); + const child = fixture.componentInstance.child(); + + // same tree (it inherited the ambient one), but NOT parented to the DI ancestor + expect(child.tree()).toBe(parent.tree()); + expect(child.node()!.parent).toBeNull(); + }); + + @Component({ + // deliberately NO provideFloatingTree → no ambient tree + imports: [RdxFloatingNodeRegistration], + template: ` +
+ ` + }) + class NodeOverrideHost { + readonly override = signal({ kind: 'inherit' }); + readonly n = viewChild.required('n', { read: RdxFloatingNodeRegistration }); + } + + it('{ kind: "node", parent } joins the parent\'s tree even with no ambient tree', () => { + const treeB = new RdxFloatingTree(); + const externalParent = treeB.register({ id: 'external-parent', parent: null, context: null }); + + const fixture = TestBed.createComponent(NodeOverrideHost); + fixture.componentInstance.override.set({ kind: 'node', parent: externalParent }); + fixture.detectChanges(); + + const dir = fixture.componentInstance.n(); + expect(dir.tree()).toBe(treeB); + expect(dir.node()!.parent).toBe(externalParent); + expect(treeB.ancestors(dir.node()!)).toEqual([externalParent]); + }); + + // ─── node-optional ─────────────────────────────────────────────────────── + + @Component({ + // no provideFloatingTree, no externalTree, inherit → no tree available + imports: [RdxFloatingNodeRegistration], + template: ` +
+ ` + }) + class NoTreeHost { + readonly n = viewChild.required('n', { read: RdxFloatingNodeRegistration }); + } + + it('runs node-optional (detached) when no tree is available', () => { + const fixture = TestBed.createComponent(NoTreeHost); + fixture.detectChanges(); + + const dir = fixture.componentInstance.n(); + expect(dir.status()).toBe('detached'); + expect(dir.node()).toBeNull(); + expect(dir.tree()).toBeNull(); + }); + + // ─── context attachment ────────────────────────────────────────────────── + + it('attaches the enclosing root context to its node', () => { + const context = createFloatingRootContext({ ownerDocument: document, open: () => true }); + + @Component({ + imports: [RdxFloatingNodeRegistration], + providers: [provideFloatingTree(), provideFloatingRootContext(() => context)], + template: ` +
+ ` + }) + class ContextHost { + readonly ctx: RdxFloatingRootContext = context; + readonly n = viewChild.required('n', { read: RdxFloatingNodeRegistration }); + } + + const fixture = TestBed.createComponent(ContextHost); + fixture.detectChanges(); + + const dir = fixture.componentInstance.n(); + expect(dir.node()!.context).toBe(context); + // the popup's open-state is read off the attached context (drives onlyOpen traversal) + expect(dir.node()!.context!.open()).toBe(true); + }); + + // ─── teardown ────────────────────────────────────────────────────────────── + + it('unregisters the node when the directive is destroyed', () => { + const fixture = TestBed.createComponent(SingleHost); + fixture.detectChanges(); + + const dir = fixture.componentInstance.n(); + const tree = dir.tree()!; + const node = dir.node()!; + expect(tree.all).toContain(node); + + fixture.destroy(); + + // the unregistered node leaves the tree; no ghost stays in `all` + expect(tree.all).not.toContain(node); + }); +}); diff --git a/packages/primitives/core/__tests__/use-body-pointer-events-lock.spec.ts b/packages/primitives/core/__tests__/use-body-pointer-events-lock.spec.ts new file mode 100644 index 00000000..7b2ae68d --- /dev/null +++ b/packages/primitives/core/__tests__/use-body-pointer-events-lock.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { DOCUMENT } from '@angular/common'; +import { PLATFORM_ID, signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useBodyPointerEventsLock } from '../src/dom/use-body-pointer-events-lock'; + +function resetBody(doc: Document): void { + doc.body.style.pointerEvents = ''; +} + +describe('useBodyPointerEventsLock', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + resetBody(document); + }); + + afterEach(() => { + resetBody(document); + }); + + function configure(doc: Document, platform: 'browser' | 'server' = 'browser'): void { + TestBed.configureTestingModule({ + providers: [ + { provide: DOCUMENT, useValue: doc }, + { provide: PLATFORM_ID, useValue: platform } + ] + }); + } + + function lock(active: WritableSignal): void { + TestBed.runInInjectionContext(() => useBodyPointerEventsLock(active)); + } + + it('disables and restores body pointer-events', () => { + configure(document); + const active = signal(false); + lock(active); + + active.set(true); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe('none'); + + active.set(false); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe(''); + }); + + it('preserves a pre-existing inline pointer-events value on restore', () => { + document.body.style.pointerEvents = 'auto'; + configure(document); + const active = signal(false); + lock(active); + + active.set(true); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe('none'); + + active.set(false); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe('auto'); + }); + + it('shares a per-document lock count across callers (stacked layers compose)', () => { + configure(document); + const a = signal(false); + const b = signal(false); + lock(a); + lock(b); + + a.set(true); + b.set(true); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe('none'); + + // releasing one lock keeps the body disabled while the other is still active + a.set(false); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe('none'); + + b.set(false); + TestBed.tick(); + expect(document.body.style.pointerEvents).toBe(''); + }); + + it('isolates lock state per document — an iframe lock does not corrupt the main document', () => { + const otherDoc = document.implementation.createHTMLDocument('iframe'); + configure(otherDoc); + const active = signal(false); + lock(active); + + active.set(true); + TestBed.tick(); + + expect(otherDoc.body.style.pointerEvents).toBe('none'); + expect(document.body.style.pointerEvents).toBe(''); + }); + + it('is a no-op on the server (non-browser platform)', () => { + configure(document, 'server'); + const active = signal(true); + lock(active); + TestBed.tick(); + + expect(document.body.style.pointerEvents).toBe(''); + }); +}); diff --git a/packages/primitives/core/index.ts b/packages/primitives/core/index.ts index 8de6ac50..9d178bbd 100644 --- a/packages/primitives/core/index.ts +++ b/packages/primitives/core/index.ts @@ -16,6 +16,7 @@ export * from './src/types'; export * from './src/floating/floating-events'; export * from './src/floating/floating-lifecycle'; +export * from './src/floating/floating-node-registration'; export * from './src/floating/floating-registration'; export * from './src/floating/floating-root-context'; export * from './src/floating/floating-tree'; @@ -25,6 +26,7 @@ export * from './src/floating/trigger-registry'; export * from './src/dom/document'; export * from './src/dom/element-size'; export * from './src/dom/get-active-element'; +export * from './src/dom/use-body-pointer-events-lock'; export * from './src/dom/use-resize-observer'; export * from './src/dom/use-scroll-lock'; diff --git a/packages/primitives/core/src/dom/use-body-pointer-events-lock.ts b/packages/primitives/core/src/dom/use-body-pointer-events-lock.ts new file mode 100644 index 00000000..f61f242e --- /dev/null +++ b/packages/primitives/core/src/dom/use-body-pointer-events-lock.ts @@ -0,0 +1,90 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { DestroyRef, effect, inject, PLATFORM_ID, Signal } from '@angular/core'; + +interface BodyPointerEventsState { + /** The body's saved inline `pointer-events`, captured on the `0 -> 1` transition for this document. */ + original: string | null; + /** Number of active locks sharing this document's saved state. */ + count: number; +} + +/** + * Per-`Document` ownership of ``'s inline `pointer-events` while one or more dismissable layers + * disable outside pointer events (ADR 0015 §6 / Phase 1 — the WeakMap replacement for the legacy engine's + * module-global `originalBodyPointerEvents`). + * + * Keyed by the owner `Document` (a `WeakMap`) so locks in different documents — iframes, multi-document + * test environments — never share or corrupt each other's saved value. Within one document a single + * shared counter is essential: with separate per-layer counters two open layers would each capture the + * other's already-`none` value as the "original" and restore it on close, leaving the body permanently + * non-interactive. Mirrors the per-`Document` scroll-lock state in `use-scroll-lock.ts`. + */ +const bodyPointerEventsStates = new WeakMap(); + +function getBodyPointerEventsState(document: Document): BodyPointerEventsState { + let state = bodyPointerEventsStates.get(document); + if (!state) { + state = { original: null, count: 0 }; + bodyPointerEventsStates.set(document, state); + } + return state; +} + +/** + * Sets ``'s `pointer-events: none` while `active()` is `true`, and restores the original value when + * it becomes `false` or the calling context is destroyed. This blocks interaction with everything outside + * the dismissable layers; each layer re-enables itself with its own `pointer-events: auto` (the layer's + * concern, not this primitive's). + * + * Ownership is shared across all callers in the same document via a per-`Document` counter, so stacked or + * concurrent layers compose correctly. No-op on the server. Must be called in an injection context. + */ +export function useBodyPointerEventsLock(active: Signal): void { + const document = inject(DOCUMENT); + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + let isLocked = false; + + const lock = () => { + if (isLocked) { + return; + } + + const state = getBodyPointerEventsState(document); + + if (state.count === 0) { + state.original = document.body.style.pointerEvents; + document.body.style.pointerEvents = 'none'; + } + + state.count++; + isLocked = true; + }; + + const unlock = () => { + if (!isLocked) { + return; + } + + const state = getBodyPointerEventsState(document); + state.count--; + isLocked = false; + + if (state.count === 0 && state.original !== null) { + document.body.style.pointerEvents = state.original; + state.original = null; + } + }; + + effect(() => { + if (!isBrowser) { + return; + } + if (active()) { + lock(); + } else { + unlock(); + } + }); + + inject(DestroyRef).onDestroy(unlock); +} diff --git a/packages/primitives/core/src/floating/floating-node-registration.ts b/packages/primitives/core/src/floating/floating-node-registration.ts new file mode 100644 index 00000000..c9559bb3 --- /dev/null +++ b/packages/primitives/core/src/floating/floating-node-registration.ts @@ -0,0 +1,105 @@ +import { Directive, effect, inject, input, Signal } from '@angular/core'; +import { injectId } from '../id-generator'; +import { + provideFloatingRegistration, + RDX_FLOATING_REGISTRATION, + RdxFloatingRegistrationContext +} from './floating-registration'; +import { RdxFloatingNode, RdxFloatingParentOverride, RdxFloatingTree } from './floating-tree'; +import { RDX_FLOATING_ROOT_CONTEXT, RDX_FLOATING_TREE } from './provide-floating-tree'; + +/** + * Registers a {@link RdxFloatingNode} into the shared {@link RdxFloatingTree} for its DI subtree and + * propagates the registration handle to descendants — the reusable Angular counterpart of mounting a + * Base UI `` (ADR 0015 §1, Phase 1). It is the **single** place that runs the handle + * pattern; the dismissal capability (ADR 0015) and the focus manager (ADR 0017) **consume** the node / + * context / tree it registers, they do not re-implement registration. + * + * **What it owns vs. what it reads.** It provides its own {@link RdxFloatingRegistrationContext} (so + * descendants resolve it with `skipSelf`) and registers/unregisters a node reactively. It does **not** + * create the tree or the root context — a coordination-boundary primitive root supplies those + * (`provideFloatingTree()` inherit-or-create + `provideFloatingRootContext()`); this directive injects + * them. With **no** enclosing tree it runs **node-optional** (`status() === 'detached'`, `node() === + * null`), reading its context directly — the standalone `rdxDismissableLayer` case. + * + * **Resolution (per {@link RdxFloatingParentOverride}).** Only an `inherit` node depends on the DI + * parent, so only it waits on a `pending` parent; `root` / `node` overrides are independent and register + * immediately. The node carries the injected {@link RDX_FLOATING_ROOT_CONTEXT} (or `null` for a + * contextless intermediate). All teardown (re-resolution **and** destroy) unregisters the node and + * reverts the handle. + */ +@Directive({ + selector: '[rdxFloatingNode]', + exportAs: 'rdxFloatingNode', + providers: [provideFloatingRegistration()] +}) +export class RdxFloatingNodeRegistration { + /** Explicit tree for detached sibling composition — Base UI's `externalTree`. */ + readonly externalTree = input(null); + /** How this node's logical parent is resolved. Defaults to `inherit` (nearest DI ancestor). */ + readonly parentOverride = input({ kind: 'inherit' }); + + /** Own handle — the WRITER side (concrete class); this directive is the only writer. */ + private readonly selfReg = inject(RdxFloatingRegistrationContext); + /** Nearest ancestor handle — the READER side (reader-typed token), or `null` at the top. */ + private readonly parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true }); + /** The enclosing tree, if a coordination boundary provided one (else node-optional). */ + private readonly ambientTree = inject(RDX_FLOATING_TREE, { optional: true }); + /** This node's per-popup context, or `null` for a contextless intermediate node. */ + private readonly rootContext = inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }); + private readonly nodeId = injectId('rdx-floating-node-'); + + /** This directive's node once registered (`null` while `pending` / `detached`). */ + readonly node: Signal = this.selfReg.node; + /** Lifecycle phase of this directive's registration (`pending` | `detached` | `registered`). */ + readonly status = this.selfReg.status; + /** The tree this node joined (`null` until `registered`). */ + readonly tree: Signal = this.selfReg.tree; + + constructor() { + effect((onCleanup) => { + const override = this.parentOverride(); + + // Only `inherit` depends on the DI parent → only it waits on a `pending` parent (reading + // status() subscribes us, so we re-run when the parent resolves). `root` / `node` overrides + // are independent of the DI ancestor and register immediately. + if (override.kind === 'inherit' && this.parentReg?.status() === 'pending') { + return; + } + + // Logical parent: explicit for `node`, `null` for `root`, the DI parent for `inherit` + // (a `detached` parent reads `null` → this node becomes a root within its tree). + const parentNode = + override.kind === 'node' + ? override.parent + : override.kind === 'root' + ? null + : (this.parentReg?.node() ?? null); + + // Tree selection (resolveFloatingTree's logic, replicated because inject() is illegal inside + // effect()): a `node` override must join its parent's tree. + const resolvedTree = + (override.kind === 'node' ? override.parent.tree : null) ?? + this.externalTree() ?? + this.parentReg?.tree() ?? + this.ambientTree; + + if (!resolvedTree) { + this.selfReg.markDetached(); // node-optional: resolved, but no tree → no node + return; + } + + const node = resolvedTree.register({ + id: this.nodeId, + parent: parentNode, + context: this.rootContext + }); + this.selfReg.register(resolvedTree, node); + + onCleanup(() => { + resolvedTree.unregister(node); + this.selfReg.clear(); // transient: back to `pending` until the effect re-resolves + }); + }); + } +} diff --git a/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts b/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts new file mode 100644 index 00000000..47df5deb --- /dev/null +++ b/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts @@ -0,0 +1,472 @@ +// @vitest-environment jsdom +import { + createEnvironmentInjector, + EnvironmentInjector, + PLATFORM_ID, + runInInjectionContext, + signal +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + createFloatingRootContext, + RdxFloatingNode, + RdxFloatingRootContext, + RdxFloatingTree +} from '@radix-ng/primitives/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RdxDismissableCapability, RdxDismissableConfig, RdxDismissReason } from '../src/dismissable-capability'; + +/** Drain microtasks (focus-out defers two) and the deferred `pointerdown` attach (a `setTimeout(0)`). */ +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('RdxDismissableCapability', () => { + const cleanups: Array<() => void> = []; + const appended: Element[] = []; + + function el(tag = 'div'): HTMLElement { + const node = document.createElement(tag); + document.body.appendChild(node); + appended.push(node); + return node; + } + + function context(open: () => boolean, floatingElement: HTMLElement | null = null): RdxFloatingRootContext { + return createFloatingRootContext({ ownerDocument: document, open, floatingElement }); + } + + /** Build a capability inside a destroyable child injector; returns it plus its `destroy`. */ + function build( + ctx: RdxFloatingRootContext, + node: () => RdxFloatingNode | null, + onDismiss: (reason: RdxDismissReason, event: Event) => void, + config: RdxDismissableConfig = {} + ): RdxDismissableCapability { + const injector = createEnvironmentInjector( + [{ provide: PLATFORM_ID, useValue: 'browser' }], + TestBed.inject(EnvironmentInjector) + ); + cleanups.push(() => injector.destroy()); + return runInInjectionContext(injector, () => new RdxDismissableCapability(ctx, node, { onDismiss, ...config })); + } + + beforeEach(() => TestBed.resetTestingModule()); + + afterEach(() => { + cleanups.splice(0).forEach((fn) => fn()); + appended.splice(0).forEach((node) => node.remove()); + }); + + // ─── Escape ────────────────────────────────────────────────────────────── + + it('dismisses on Escape when active', () => { + const onDismiss = vi.fn(); + build( + context(() => true), + () => null, + onDismiss + ); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(onDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + }); + + it('does not dismiss on Escape when the popup is closed (inactive)', () => { + const open = signal(false); + const onDismiss = vi.fn(); + build( + context(() => open()), + () => null, + onDismiss + ); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss on Escape when escapeKey is disabled', () => { + const onDismiss = vi.fn(); + build( + context(() => true), + () => null, + onDismiss, + { escapeKey: () => false } + ); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('onEscapeKeyDown can preventDefault to veto the dismissal', () => { + const onDismiss = vi.fn(); + build( + context(() => true), + () => null, + onDismiss, + { + onEscapeKeyDown: (event) => event.preventDefault() + } + ); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('Escape yields to an open descendant (hasBlockingChild — only the deepest layer closes)', () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true); + const childOpen = signal(true); + const childCtx = context(() => childOpen()); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const onDismiss = vi.fn(); + build(parentCtx, () => parentNode, onDismiss); + + // child open → parent yields + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onDismiss).not.toHaveBeenCalled(); + + // child closed → parent now owns Escape + childOpen.set(false); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + }); + + // ─── outside press ───────────────────────────────────────────────────────── + + it('dismisses on a pointer press outside the layer', async () => { + const onDismiss = vi.fn(); + const floating = el(); + build( + context(() => true, floating), + () => null, + onDismiss + ); + await flush(); // let the deferred pointerdown listener attach + + el('button').dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + it('does not dismiss when the press lands inside the floating element', async () => { + const onDismiss = vi.fn(); + const floating = el(); + const inner = document.createElement('span'); + floating.appendChild(inner); + build( + context(() => true, floating), + () => null, + onDismiss + ); + await flush(); + + inner.dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when the press lands on a registered trigger', async () => { + const onDismiss = vi.fn(); + const ctx = context(() => true); + const trigger = el('button'); + ctx.triggers.add(trigger); + build(ctx, () => null, onDismiss); + await flush(); + + trigger.dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not dismiss when the press lands inside a registered branch', async () => { + const onDismiss = vi.fn(); + const branch = el(); + const cap = build( + context(() => true), + () => null, + onDismiss + ); + cap.branches.add(branch); + await flush(); + + branch.dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('counts an open descendant popup as inside (logical containment survives portaling)', async () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true, el()); + const childFloating = el(); // a portaled child popup, NOT a DOM descendant of the parent + const childCtx = context(() => true, childFloating); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const onDismiss = vi.fn(); + build(parentCtx, () => parentNode, onDismiss); + await flush(); + + childFloating.dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('outside-press closes the whole stack — a parent with an open child still dismisses', async () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true, el()); + const childCtx = context(() => true, el()); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const onDismiss = vi.fn(); + build(parentCtx, () => parentNode, onDismiss); + await flush(); + + el('button').dispatchEvent(new Event('pointerdown', { bubbles: true })); + + // unlike Escape, outside-press does NOT yield to the open child (Base UI outsidePressBubbles=true) + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + // ─── focus outside ─────────────────────────────────────────────────────── + + it('dismisses when focus moves outside the layer (deferred)', async () => { + const onDismiss = vi.fn(); + build( + context(() => true, el()), + () => null, + onDismiss + ); + + el('button').dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await flush(); + + expect(onDismiss).toHaveBeenCalledWith('focus-outside', expect.any(FocusEvent)); + }); + + it('does not dismiss when focus stays inside the layer', async () => { + const onDismiss = vi.fn(); + const floating = el(); + const inner = document.createElement('input'); + floating.appendChild(inner); + build( + context(() => true, floating), + () => null, + onDismiss + ); + + inner.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await flush(); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + // ─── Phase 2: bubbles policy & propagation ─────────────────────────────── + + it('escapeKeyBubbles=true re-emits Escape to the parent (closeParentOnEsc — both close)', () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true); + const childCtx = context(() => true); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + const childNode = tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const parentDismiss = vi.fn(); + const childDismiss = vi.fn(); + build(parentCtx, () => parentNode, parentDismiss); + build(childCtx, () => childNode, childDismiss, { escapeKeyBubbles: () => true }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + // the bubbling child no longer blocks the parent — both close + expect(childDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + expect(parentDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + }); + + it('a child capability with default (non-bubbling) Escape blocks the parent', () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true); + const childCtx = context(() => true); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + const childNode = tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const parentDismiss = vi.fn(); + const childDismiss = vi.fn(); + build(parentCtx, () => parentNode, parentDismiss); + build(childCtx, () => childNode, childDismiss); // default escapeKeyBubbles = false + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(childDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + expect(parentDismiss).not.toHaveBeenCalled(); // parent yields to the non-bubbling child + }); + + it('a non-bubbling Escape owner stops propagation (the key does not reach app handlers)', () => { + const inner = el(); + const appHandler = vi.fn(); + document.addEventListener('keydown', appHandler); // bubble-phase app listener + cleanups.push(() => document.removeEventListener('keydown', appHandler)); + + build( + context(() => true), + () => null, + vi.fn() + ); + + inner.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(appHandler).not.toHaveBeenCalled(); + }); + + it('escapeKeyBubbles=true lets the key reach app handlers (no stopPropagation)', () => { + const inner = el(); + const appHandler = vi.fn(); + document.addEventListener('keydown', appHandler); + cleanups.push(() => document.removeEventListener('keydown', appHandler)); + + build( + context(() => true), + () => null, + vi.fn(), + { escapeKeyBubbles: () => true } + ); + + inner.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(appHandler).toHaveBeenCalled(); + }); + + it('outsidePressBubbles=false yields to a non-bubbling open child (only the deepest closes)', async () => { + const tree = new RdxFloatingTree(); + const parentCtx = context(() => true, el()); + const childCtx = context(() => true, el()); + const parentNode = tree.register({ id: 'parent', parent: null, context: parentCtx }); + const childNode = tree.register({ id: 'child', parent: parentNode, context: childCtx }); + + const parentDismiss = vi.fn(); + const childDismiss = vi.fn(); + build(parentCtx, () => parentNode, parentDismiss, { outsidePressBubbles: () => false }); + build(childCtx, () => childNode, childDismiss, { outsidePressBubbles: () => false }); + await flush(); + + el('button').dispatchEvent(new Event('pointerdown', { bubbles: true })); + + expect(childDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + expect(parentDismiss).not.toHaveBeenCalled(); // parent yields to the non-bubbling child + }); + + // ─── Phase 3: press / IME hardening ────────────────────────────────────── + + it('ignores a non-primary mouse button (only a primary press dismisses)', async () => { + const onDismiss = vi.fn(); + build( + context(() => true, el()), + () => null, + onDismiss + ); + await flush(); + + el('button').dispatchEvent(new MouseEvent('pointerdown', { button: 2, bubbles: true })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('ignores Escape while an IME composition is active', async () => { + const onDismiss = vi.fn(); + build( + context(() => true), + () => null, + onDismiss + ); + + document.dispatchEvent(new Event('compositionstart')); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onDismiss).not.toHaveBeenCalled(); + + document.dispatchEvent(new Event('compositionend')); + await flush(); // compositionend clears `isComposing` on the next tick + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(onDismiss).toHaveBeenCalledWith('escape-key', expect.any(KeyboardEvent)); + }); + + it('intentional mode dismisses on click, not on pointerdown', async () => { + const onDismiss = vi.fn(); + build( + context(() => true, el()), + () => null, + onDismiss, + { outsidePressEvent: () => 'intentional' } + ); + await flush(); + + const outside = el('button'); + outside.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); // waits for the full press (click) + + outside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + it('intentional mode suppresses the outside click when the press started inside (drag-out)', async () => { + const onDismiss = vi.fn(); + const floating = el(); + build( + context(() => true, floating), + () => null, + onDismiss, + { outsidePressEvent: () => 'intentional' } + ); + await flush(); + + floating.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); // press starts inside + el('button').dispatchEvent(new MouseEvent('click', { bubbles: true })); // drag-out + release + + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('pointercancel resets press-start tracking (a cancelled inside press no longer suppresses)', async () => { + const onDismiss = vi.fn(); + const floating = el(); + build( + context(() => true, floating), + () => null, + onDismiss, + { outsidePressEvent: () => 'intentional' } + ); + await flush(); + + floating.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); // press inside... + document.dispatchEvent(new Event('pointercancel')); // ...cancelled + el('button').dispatchEvent(new MouseEvent('click', { bubbles: true })); // a later, unrelated outside click + + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + // ─── teardown ────────────────────────────────────────────────────────────── + + it('stops listening once its injector is destroyed', () => { + const onDismiss = vi.fn(); + const injector = createEnvironmentInjector( + [{ provide: PLATFORM_ID, useValue: 'browser' }], + TestBed.inject(EnvironmentInjector) + ); + runInInjectionContext( + injector, + () => + new RdxDismissableCapability( + context(() => true), + () => null, + { onDismiss } + ) + ); + + injector.destroy(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(onDismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/primitives/dismissable-layer/index.ts b/packages/primitives/dismissable-layer/index.ts index 0c0a5a0a..bcb053b7 100644 --- a/packages/primitives/dismissable-layer/index.ts +++ b/packages/primitives/dismissable-layer/index.ts @@ -1,3 +1,4 @@ +export * from './src/dismissable-capability'; export * from './src/dismissable-layer'; export * from './src/dismissable-layer-branch'; export * from './src/dismissable-layer.config'; diff --git a/packages/primitives/dismissable-layer/src/dismissable-capability.ts b/packages/primitives/dismissable-layer/src/dismissable-capability.ts new file mode 100644 index 00000000..f030be18 --- /dev/null +++ b/packages/primitives/dismissable-layer/src/dismissable-capability.ts @@ -0,0 +1,371 @@ +import { isPlatformBrowser } from '@angular/common'; +import { DestroyRef, inject, PLATFORM_ID } from '@angular/core'; +import { RdxFloatingNode, RdxFloatingRootContext } from '@radix-ng/primitives/core'; + +/** Why a dismissal was requested — mirrors Base UI's open-change `reason` strings (`useDismiss.ts`). */ +export type RdxDismissReason = 'escape-key' | 'outside-press' | 'focus-outside'; + +/** + * Configuration for {@link RdxDismissableCapability}. Every flag is a getter so it can be a signal + * read (reactive) or a plain predicate. The `on*` pre-hooks are **preventable**: call + * `event.preventDefault()` inside one to veto that dismissal (the layer then stays open). + */ +export interface RdxDismissableConfig { + /** Whole-capability gate (on top of `context.open()`). Default `() => true`. */ + enabled?: () => boolean; + /** Whether Escape requests dismissal. Default `() => true`. */ + escapeKey?: () => boolean; + /** + * Whether this layer's Escape **bubbles** to ancestor layers (Base UI `bubbles.escapeKey`). Default + * `() => false` — Escape closes only the deepest layer. `true` re-emits to the parent too (this is + * Menu's `closeParentOnEsc`: a submenu's Escape also closes the parent menu). + */ + escapeKeyBubbles?: () => boolean; + /** Whether an outside pointer press requests dismissal. Default `() => true`. */ + outsidePress?: () => boolean; + /** + * When an outside press dismisses (Base UI `outsidePressEvent`). `'sloppy'` (default) closes on + * `pointerdown` — immediate, OS-like. `'intentional'` closes on `click` — requires a full + * press-and-release on the same outside target, and suppresses the click when the press **started + * inside** (so selecting text and dragging out does not dismiss). + */ + outsidePressEvent?: () => 'sloppy' | 'intentional'; + /** + * Whether this layer's outside-press **bubbles** to ancestor layers (Base UI `bubbles.outsidePress`). + * Default `() => true` — an outside press closes the whole stack. `false` makes an open non-bubbling + * child block the parent (only the deepest closes). + */ + outsidePressBubbles?: () => boolean; + /** Whether focus leaving the layer requests dismissal. Default `() => true`. */ + focusOutside?: () => boolean; + /** Preventable pre-hook for Escape. */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** Preventable pre-hook for an outside pointer press. */ + onPointerDownOutside?: (event: PointerEvent) => void; + /** Preventable pre-hook for focus moving outside. */ + onFocusOutside?: (event: FocusEvent) => void; + /** Called when a non-prevented dismissal is requested. */ + onDismiss?: (reason: RdxDismissReason, event: Event) => void; +} + +const alwaysTrue = (): boolean => true; +const alwaysFalse = (): boolean => false; +const sloppy = (): 'sloppy' => 'sloppy'; + +/** Duck-types an `EventTarget` to `Node` (cross-realm-safe; `Node.contains` throws on a non-Node). */ +function isNode(target: EventTarget | null): target is Node { + return target !== null && typeof (target as Node).nodeType === 'number'; +} + +/** Only a primary (left / default) press dismisses — a non-primary mouse button is ignored. */ +function isPrimaryButton(event: Event): boolean { + return !('button' in event) || (event as MouseEvent).button === 0; +} + +/** + * Whether the press landed on `target`'s scrollbar (a scrollbar drag must not dismiss). Mirrors Base + * UI's geometry check (`useDismiss.ts`): skipped for touch (scrollbars get no touch events) and resolved + * from the element's scroll metrics + the press offset. + * + * @remarks Layout-dependent — verified by Playwright, not jsdom (which reports zero box metrics, so this + * correctly returns `false` there). See `apps/visual-regression`. + */ +function isScrollbarPress(event: Event): boolean { + const target = event.target; + if (!(target instanceof HTMLElement) || 'touches' in event || typeof (event as MouseEvent).offsetX !== 'number') { + return false; + } + const view = target.ownerDocument.defaultView; + if (!view) { + return false; + } + const press = event as MouseEvent; + const style = view.getComputedStyle(target); + const scrollable = /auto|scroll/; + const canScrollX = scrollable.test(style.overflowX) && target.scrollWidth > target.clientWidth; + const canScrollY = scrollable.test(style.overflowY) && target.scrollHeight > target.clientHeight; + const isRtl = style.direction === 'rtl'; + + const onVerticalScrollbar = + canScrollY && + (isRtl ? press.offsetX <= target.offsetWidth - target.clientWidth : press.offsetX > target.clientWidth); + const onHorizontalScrollbar = canScrollX && press.offsetY > target.clientHeight; + + return onVerticalScrollbar || onHorizontalScrollbar; +} + +/** A layer's per-event "bubbles to ancestors" policy, published for its ancestors to read. */ +interface DismissBubbles { + escapeKey: () => boolean; + outsidePress: () => boolean; +} + +/** + * Each capability publishes its bubbles policy here, keyed by its root context, so an **ancestor**'s + * `hasBlockingChild` can read a child's flags — the Angular counterpart of Base UI storing + * `__escapeKeyBubbles` / `__outsidePressBubbles` on the context's `dataRef` (`useDismiss.ts`). Kept in a + * dismissal-private `WeakMap` (not on the neutral context) so the context stays capability-agnostic. + */ +const dismissBubblesByContext = new WeakMap(); + +/** + * Whether a child `context` lets the given event bubble past it to its ancestors. Defaults match Base + * UI for a child that has no dismissal policy (e.g. focus-only): Escape does **not** bubble (so it + * blocks), an outside press **does** (so it does not block). + */ +function childBubbles(context: RdxFloatingRootContext, reason: RdxDismissReason): boolean { + const bubbles = dismissBubblesByContext.get(context); + if (reason === 'escape-key') { + return bubbles ? bubbles.escapeKey() : false; + } + return bubbles ? bubbles.outsidePress() : true; +} + +/** + * The dismissal **capability** (ADR 0015 §1) — the Angular counterpart of Base UI's `useDismiss`. It + * **references** a {@link RdxFloatingRootContext} (mandatory: `open` / `triggers` / elements live there) + * and a {@link RdxFloatingNode} (**optional**: a node-optional / Navigation-Menu state has `node === + * null`); it never creates them. It listens for Escape / outside-press / focus-out and **requests** a + * dismissal via `onDismiss` when an interaction lands outside the logical layer and this layer owns the + * event. + * + * **Logical, not DOM-order, containment.** "Inside" is resolved through the shared tree — this popup's + * floating element + its registered triggers, **plus** the same for every open descendant node, **plus** + * this capability's own {@link branches}. So a portal-relocated child still counts as inside its parent, + * which the legacy DOM-order `isLayerExist` cannot do. + * + * **Ownership & propagation (`hasBlockingChild`, Phase 2).** Each layer publishes per-event `bubbles` + * flags (`escapeKeyBubbles` default `false`, `outsidePressBubbles` default `true`); an ancestor reads its + * open children's flags via {@link childBubbles}. A non-bubbling layer **yields** to a non-bubbling open + * child, so by default Escape closes only the **deepest** layer while an outside press closes the **whole + * stack**. A layer with `escapeKeyBubbles = true` re-emits to its parent (Menu's `closeParentOnEsc`). The + * owning non-bubbling Escape layer also `stopPropagation()`s so the key does not reach app handlers. + * + * **Press / IME hardening (Phase 3).** Outside-press honors `outsidePressEvent` (`'sloppy'` → + * `pointerdown`, `'intentional'` → `click` with press-start-inside drag-out suppression), ignores + * non-primary mouse buttons and scrollbar presses, resets on `pointercancel`, and ignores Escape while an + * IME composition is active. **Touch** gesture hardening (long-press / drag distance thresholds) is + * deliberately **not** ported here — jsdom cannot exercise it; it belongs in `apps/visual-regression` + * (Playwright), and the legacy engine keeps driving touch until the Phase-4 cutover. + * + * **Scope.** Built in parallel and **not wired** to the live legacy path (the Phase-4 atomic cutover does + * that). Must be constructed in an injection context (`DestroyRef` / `PLATFORM_ID`). No-op on the server. + */ +export class RdxDismissableCapability { + /** This capability's own inside-content set (ADR 0015 Phase 1 — separate from the legacy global). */ + readonly branches = new Set(); + + /** This capability's active-ness: the popup is open **and** the capability is enabled. */ + readonly active: () => boolean; + + constructor( + readonly context: RdxFloatingRootContext, + readonly node: () => RdxFloatingNode | null, + config: RdxDismissableConfig = {} + ) { + const enabled = config.enabled ?? alwaysTrue; + const escapeKey = config.escapeKey ?? alwaysTrue; + const escapeKeyBubbles = config.escapeKeyBubbles ?? alwaysFalse; + const outsidePress = config.outsidePress ?? alwaysTrue; + const outsidePressBubbles = config.outsidePressBubbles ?? alwaysTrue; + const outsidePressEvent = config.outsidePressEvent ?? sloppy; + const focusOutside = config.focusOutside ?? alwaysTrue; + + // Plain function (not `computed`) so it re-reads `open()` per event even when `open` is not a + // signal — while still tracking signals when read inside a reactive context. + this.active = () => this.context.open() && enabled(); + + // Publish this layer's bubbles policy so an ANCESTOR's hasBlockingChild can read it. Registered + // (and cleaned up) regardless of platform so SSR leaves no stale entry. + dismissBubblesByContext.set(this.context, { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles }); + const destroyRef = inject(DestroyRef); + destroyRef.onDestroy(() => dismissBubblesByContext.delete(this.context)); + + if (!isPlatformBrowser(inject(PLATFORM_ID))) { + return; // SSR: no listeners, no DOM access. + } + + const ownerDocument = this.context.ownerDocument; + const ownerWindow = ownerDocument.defaultView ?? globalThis; + + const dismiss = (reason: RdxDismissReason, event: Event): void => { + config.onDismiss?.(reason, event); + }; + + // IME: a press of Escape while composing should close the compose menu, not the popup. + let isComposing = false; + // Press-start-inside tracking (drag-out suppression for `intentional` mode). + let pressStartedInside = false; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !this.active() || !escapeKey() || isComposing) { + return; + } + // A non-bubbling layer yields to a deeper non-bubbling open layer (Base UI: + // `!escapeKeyBubbles && hasBlockingChild`). A bubbling layer never yields. + if (!escapeKeyBubbles() && this.hasBlockingChild('escape-key')) { + return; + } + config.onEscapeKeyDown?.(event); + if (!event.defaultPrevented) { + dismiss('escape-key', event); + } + // Propagation control: the owning non-bubbling layer consumes the Escape so it does not + // reach ancestor layers / app-level handlers (Base UI `event.stopPropagation()`). + if (!escapeKeyBubbles()) { + event.stopPropagation(); + } + }; + + const handleCompositionStart = (): void => { + isComposing = true; + }; + const handleCompositionEnd = (): void => { + // Safari fires `compositionend` before `keydown`, so clear on the next tick (Base UI). + ownerWindow.setTimeout(() => { + isComposing = false; + }, 0); + }; + + const tryOutsidePress = (event: Event): void => { + if (!this.active() || !outsidePress() || !isPrimaryButton(event) || this.isInside(event.target)) { + return; + } + if (isScrollbarPress(event)) { + return; // a scrollbar drag is not an outside press + } + // By default outside-press bubbles (closes the whole stack); only a non-bubbling layer with + // a non-bubbling open child yields to it. + if (!outsidePressBubbles() && this.hasBlockingChild('outside-press')) { + return; + } + config.onPointerDownOutside?.(event as PointerEvent); + if (!event.defaultPrevented) { + dismiss('outside-press', event); + } + }; + + // Capture-phase, so it records where the press began before any dismiss handler runs. + const handlePressStart = (event: Event): void => { + pressStartedInside = this.isInside(event.target); + }; + const handlePointerCancel = (): void => { + pressStartedInside = false; + }; + + const handlePointerDown = (event: Event): void => { + if (outsidePressEvent() !== 'sloppy') { + return; // `intentional` dismisses on click, not pointerdown + } + tryOutsidePress(event); + }; + const handleClick = (event: Event): void => { + if (outsidePressEvent() !== 'intentional') { + return; + } + // A press that started inside (text selection dragged out) consumes its one outside click. + if (pressStartedInside) { + pressStartedInside = false; + return; + } + tryOutsidePress(event); + }; + + const handleFocusIn = (event: FocusEvent): void => { + // Defer two microtasks so focus settles before reading containment (matches the legacy + // RdxFocusOutside; the `on*` hook still runs synchronously, so `preventDefault` is honored). + void Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + if (!this.active() || !focusOutside() || this.isInside(event.target)) { + return; + } + config.onFocusOutside?.(event); + if (!event.defaultPrevented) { + dismiss('focus-outside', event); + } + }); + }; + + ownerDocument.addEventListener('keydown', handleKeyDown, { capture: true }); + ownerDocument.addEventListener('compositionstart', handleCompositionStart, { capture: true }); + ownerDocument.addEventListener('compositionend', handleCompositionEnd, { capture: true }); + ownerDocument.addEventListener('focusin', handleFocusIn); + ownerDocument.addEventListener('pointerdown', handlePressStart, { capture: true }); + ownerDocument.addEventListener('pointercancel', handlePointerCancel, { capture: true }); + + // Defer attaching the dismiss listeners past the current event loop so the very interaction that + // opened this layer doesn't immediately dismiss it (the opening press is still propagating). + const pointerTimer = ownerWindow.setTimeout(() => { + ownerDocument.addEventListener('pointerdown', handlePointerDown); + ownerDocument.addEventListener('click', handleClick); + }, 0); + + destroyRef.onDestroy(() => { + ownerWindow.clearTimeout(pointerTimer); + ownerDocument.removeEventListener('keydown', handleKeyDown, { capture: true }); + ownerDocument.removeEventListener('compositionstart', handleCompositionStart, { capture: true }); + ownerDocument.removeEventListener('compositionend', handleCompositionEnd, { capture: true }); + ownerDocument.removeEventListener('focusin', handleFocusIn); + ownerDocument.removeEventListener('pointerdown', handlePressStart, { capture: true }); + ownerDocument.removeEventListener('pointercancel', handlePointerCancel, { capture: true }); + ownerDocument.removeEventListener('pointerdown', handlePointerDown); + ownerDocument.removeEventListener('click', handleClick); + }); + } + + /** + * Whether `target` is logically inside this layer: within this popup's floating element or one of its + * registered triggers, within any **open descendant** node's floating element / triggers, or within a + * registered branch. + */ + private isInside(target: EventTarget | null): boolean { + if (this.contextContains(this.context, target)) { + return true; + } + + const node = this.node(); + if (node) { + for (const child of node.tree.children(node, { onlyOpen: true })) { + if (child.context && this.contextContains(child.context, target)) { + return true; + } + } + } + + if (isNode(target)) { + for (const branch of this.branches) { + if (branch.contains(target)) { + return true; + } + } + } + + return false; + } + + /** `target` is inside a context's floating element or one of its registered triggers. */ + private contextContains(context: RdxFloatingRootContext, target: EventTarget | null): boolean { + const floating = context.floatingElement; + if (floating && isNode(target) && floating.contains(target)) { + return true; + } + return context.triggers.contains(target); + } + + /** + * Whether an open descendant blocks this layer for `reason` (so it should yield). A child blocks when + * it does **not** let the event bubble past it ({@link childBubbles} is `false`). Base UI's + * `hasBlockingChild` (`useDismiss.ts:170`) is a capability-local function — **not** a tree method — so + * the tree stays neutral. + */ + private hasBlockingChild(reason: RdxDismissReason): boolean { + const node = this.node(); + if (!node) { + return false; + } + return node.tree + .children(node, { onlyOpen: true }) + .some((child) => child.context !== null && !childBubbles(child.context, reason)); + } +} From 1aa9f946c8f3be2e8acbde98520b477295c89732 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 11:27:24 +0300 Subject: [PATCH 05/35] feat(focus-scope): owner-Document rework + portal-focus bridge toolkit --- .../core/src/dom/get-active-element.ts | 10 +- .../__tests__/focus-guards.spec.ts | 149 +++++++++ .../focus-scope-characterization.spec.ts | 312 ++++++++++++++++++ packages/primitives/focus-scope/index.ts | 1 + .../focus-scope/src/focus-guards.ts | 131 ++++++++ .../primitives/focus-scope/src/focus-scope.ts | 43 ++- packages/primitives/focus-scope/src/stack.ts | 27 +- packages/primitives/focus-scope/src/utils.ts | 33 +- skills/radix-ng/references/api-contract.json | 27 ++ 9 files changed, 702 insertions(+), 31 deletions(-) create mode 100644 packages/primitives/focus-scope/__tests__/focus-guards.spec.ts create mode 100644 packages/primitives/focus-scope/__tests__/focus-scope-characterization.spec.ts create mode 100644 packages/primitives/focus-scope/src/focus-guards.ts diff --git a/packages/primitives/core/src/dom/get-active-element.ts b/packages/primitives/core/src/dom/get-active-element.ts index 74cedf2f..d5a7b8e6 100644 --- a/packages/primitives/core/src/dom/get-active-element.ts +++ b/packages/primitives/core/src/dom/get-active-element.ts @@ -1,5 +1,11 @@ -export function getActiveElement(): Element | null { - let activeElement = document.activeElement; +/** + * The deepest active element, descending into open shadow roots. Pass a specific `root` + * (`Document` or `ShadowRoot`) to read focus in that document — defaults to the global `document` + * (backward compatible). A focus scope passes its host's `ownerDocument` so it stays correct across + * iframes / multi-document environments. + */ +export function getActiveElement(root: DocumentOrShadowRoot = document): Element | null { + let activeElement = root.activeElement; if (activeElement == null) { return null; } diff --git a/packages/primitives/focus-scope/__tests__/focus-guards.spec.ts b/packages/primitives/focus-scope/__tests__/focus-guards.spec.ts new file mode 100644 index 00000000..b0106f37 --- /dev/null +++ b/packages/primitives/focus-scope/__tests__/focus-guards.spec.ts @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { Component, ElementRef, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createAriaOwnsAnchor, + createFocusGuard, + disableFocusInside, + enableFocusInside, + FOCUS_GUARD_ATTR, + isOutsideEvent, + useFocusGuardsTabbability +} from '../src/focus-guards'; + +describe('focus guards', () => { + const appended: Element[] = []; + + function host(html: string): HTMLElement { + const el = document.createElement('div'); + el.innerHTML = html; + document.body.appendChild(el); + appended.push(el); + return el; + } + + afterEach(() => appended.splice(0).forEach((el) => el.remove())); + + // ─── createFocusGuard / createAriaOwnsAnchor ────────────────────────────── + + describe('createFocusGuard', () => { + it('is a visually-hidden, tabbable, aria-hidden span', () => { + const guard = createFocusGuard(document); + + expect(guard.tagName).toBe('SPAN'); + expect(guard.getAttribute('tabindex')).toBe('0'); + expect(guard.getAttribute('aria-hidden')).toBe('true'); + expect(guard.hasAttribute(FOCUS_GUARD_ATTR)).toBe(true); + expect(guard.style.position).toBe('fixed'); + expect(guard.style.width).toBe('1px'); + }); + + it('createAriaOwnsAnchor links the portal id via aria-owns', () => { + const anchor = createAriaOwnsAnchor(document, 'portal-42'); + expect(anchor.getAttribute('aria-owns')).toBe('portal-42'); + expect(anchor.style.position).toBe('fixed'); + }); + }); + + // ─── disableFocusInside / enableFocusInside ─────────────────────────────── + + describe('disable / enable focus inside', () => { + it('round-trips tabindex: suspends then restores original values', () => { + const container = host(` + + a +
t
+ `); + + disableFocusInside(container); + expect(container.querySelector('#b')!.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('#a')!.getAttribute('tabindex')).toBe('-1'); + expect(container.querySelector('#t')!.getAttribute('tabindex')).toBe('-1'); + + enableFocusInside(container); + // button/anchor had no explicit tabindex → attribute removed entirely + expect(container.querySelector('#b')!.hasAttribute('tabindex')).toBe(false); + expect(container.querySelector('#a')!.hasAttribute('tabindex')).toBe(false); + // the explicit tabindex="2" is restored verbatim + expect(container.querySelector('#t')!.getAttribute('tabindex')).toBe('2'); + }); + + it('enableFocusInside is a no-op when nothing was disabled', () => { + const container = host(``); + enableFocusInside(container); + expect(container.querySelector('#b')!.getAttribute('tabindex')).toBe('0'); + }); + }); + + // ─── isOutsideEvent ─────────────────────────────────────────────────────── + + describe('isOutsideEvent', () => { + it('is true when relatedTarget is null', () => { + const container = host(``); + expect(isOutsideEvent(new FocusEvent('focusout', { relatedTarget: null }), container)).toBe(true); + }); + + it('is true when relatedTarget is outside the container', () => { + const container = host(``); + const outside = host(``).querySelector('button')!; + expect(isOutsideEvent(new FocusEvent('focusout', { relatedTarget: outside }), container)).toBe(true); + }); + + it('is false when relatedTarget is inside the container', () => { + const container = host(``); + const inside = container.querySelector('#in')!; + expect(isOutsideEvent(new FocusEvent('focusin', { relatedTarget: inside }), container)).toBe(false); + }); + }); + + // ─── useFocusGuardsTabbability (capture-phase toggle) ───────────────────── + + describe('useFocusGuardsTabbability', () => { + @Component({ + template: ` +
+ +
+ ` + }) + class GuardHost { + readonly portal = viewChild.required('portal', { read: ElementRef }); + constructor() { + useFocusGuardsTabbability(() => (this.portal() ? this.portal().nativeElement : null)); + } + } + + it('disables content tabbability when focus leaves to outside, re-enables when it returns', () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(GuardHost); + fixture.detectChanges(); + + const portal = fixture.componentInstance.portal().nativeElement as HTMLElement; + const inner = portal.querySelector('button') as HTMLElement; + + // focus leaves the portal to an outside element → content becomes untabbable + portal.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside })); + expect(inner.getAttribute('tabindex')).toBe('-1'); + + // focus returns into the portal from outside → content tabbability restored + portal.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: outside })); + expect(inner.hasAttribute('tabindex')).toBe(false); + }); + + it('ignores focus moves that stay inside the portal (relatedTarget inside)', () => { + const fixture = TestBed.createComponent(GuardHost); + fixture.detectChanges(); + + const portal = fixture.componentInstance.portal().nativeElement as HTMLElement; + const inner = portal.querySelector('button') as HTMLElement; + + // a focusout whose relatedTarget is still inside must NOT disable tabbability + portal.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: inner })); + expect(inner.hasAttribute('tabindex')).toBe(false); + }); + }); +}); diff --git a/packages/primitives/focus-scope/__tests__/focus-scope-characterization.spec.ts b/packages/primitives/focus-scope/__tests__/focus-scope-characterization.spec.ts new file mode 100644 index 00000000..a6a65de5 --- /dev/null +++ b/packages/primitives/focus-scope/__tests__/focus-scope-characterization.spec.ts @@ -0,0 +1,312 @@ +// @vitest-environment jsdom +import { Component, ElementRef, signal, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { RdxFocusScope } from '../src/focus-scope'; +import { createFocusScopesStack, FocusScopeAPI, removeLinks } from '../src/stack'; +import { getTabbableCandidates, getTabbableEdges } from '../src/utils'; + +/** + * Characterization of the CURRENT `RdxFocusScope` behavior (ADR 0017 Phase 0 / 1a). These lock the + * observable contract that the Phase-1a rework (owner-`Document`, shadow/`composedPath` containment, + * queued focus, `WeakMap` stack) must preserve. Real cross-document / shadow / rAF timing is + * Playwright's job; this pins the jsdom-deterministic surface. + */ + +// Drains microtasks (mount auto-focus) AND a window animation frame (the queued return-focus). +const flush = (): Promise => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); + +// ─── Tab loop (handleKeyDown) ───────────────────────────────────────────────── + +@Component({ + imports: [RdxFocusScope], + template: ` +
+ + + +
+ ` +}) +class LoopHost { + readonly loop = signal(false); + readonly scope = viewChild.required('scope', { read: ElementRef }); + readonly a = viewChild.required('a', { read: ElementRef }); + readonly b = viewChild.required('b', { read: ElementRef }); + readonly c = viewChild.required('c', { read: ElementRef }); +} + +describe('RdxFocusScope characterization', () => { + beforeEach(() => TestBed.resetTestingModule()); + + describe('Tab loop (handleKeyDown)', () => { + function tab(scope: HTMLElement, shift = false): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: shift, + bubbles: true, + cancelable: true + }); + scope.dispatchEvent(event); + return event; + } + + it('wraps Tab from the last element to the first when loop is on', () => { + const fixture = TestBed.createComponent(LoopHost); + fixture.componentInstance.loop.set(true); + fixture.detectChanges(); + const host = fixture.componentInstance.scope().nativeElement as HTMLElement; + + (fixture.componentInstance.c().nativeElement as HTMLElement).focus(); + const event = tab(host); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(fixture.componentInstance.a().nativeElement); + }); + + it('wraps Shift+Tab from the first element to the last when loop is on', () => { + const fixture = TestBed.createComponent(LoopHost); + fixture.componentInstance.loop.set(true); + fixture.detectChanges(); + const host = fixture.componentInstance.scope().nativeElement as HTMLElement; + + (fixture.componentInstance.a().nativeElement as HTMLElement).focus(); + const event = tab(host, true); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(fixture.componentInstance.c().nativeElement); + }); + + it('prevents default at the edge but does not wrap when loop is off', () => { + const fixture = TestBed.createComponent(LoopHost); + fixture.detectChanges(); + const host = fixture.componentInstance.scope().nativeElement as HTMLElement; + + const last = fixture.componentInstance.c().nativeElement as HTMLElement; + last.focus(); + const event = tab(host); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(last); // no wrap + }); + + it('does not interfere with a non-edge Tab', () => { + const fixture = TestBed.createComponent(LoopHost); + fixture.componentInstance.loop.set(true); + fixture.detectChanges(); + const host = fixture.componentInstance.scope().nativeElement as HTMLElement; + + (fixture.componentInstance.b().nativeElement as HTMLElement).focus(); // middle + const event = tab(host); + + expect(event.defaultPrevented).toBe(false); + }); + }); + + // ─── focus scope stack (module-global pause/resume) ─────────────────────── + + describe('focus scope stack', () => { + const tracked: FocusScopeAPI[] = []; + + function makeScope(): FocusScopeAPI { + const paused = signal(false); + const scope: FocusScopeAPI = { paused, pause: () => paused.set(true), resume: () => paused.set(false) }; + tracked.push(scope); + return scope; + } + + afterEach(() => { + // Drain anything a test left in the (per-document) stack so tests stay isolated. + const stack = createFocusScopesStack(document); + tracked.splice(0).forEach((scope) => stack.remove(scope)); + }); + + it('pauses the previously-active scope when a new one is added', () => { + const stack = createFocusScopesStack(document); + const a = makeScope(); + const b = makeScope(); + + stack.add(a); + expect(a.paused()).toBe(false); + + stack.add(b); + expect(a.paused()).toBe(true); + expect(b.paused()).toBe(false); + }); + + it('resumes the new top scope when the active one is removed', () => { + const stack = createFocusScopesStack(document); + const a = makeScope(); + const b = makeScope(); + stack.add(a); + stack.add(b); + + stack.remove(b); + expect(a.paused()).toBe(false); // a is the new top, resumed + }); + + it('shares one stack per document (instances for the same document coordinate)', () => { + const s1 = createFocusScopesStack(document); + const s2 = createFocusScopesStack(document); + const a = makeScope(); + const b = makeScope(); + + s1.add(a); + s2.add(b); // same document → adding through a different instance still pauses a + + expect(a.paused()).toBe(true); + }); + + it('isolates the stack per document (a scope in another document does not pause this one)', () => { + const otherDoc = document.implementation.createHTMLDocument('iframe'); + const here = createFocusScopesStack(document); + const there = createFocusScopesStack(otherDoc); + const a = makeScope(); + const b = makeScope(); + + here.add(a); + there.add(b); // different document → must NOT pause `a` + + expect(a.paused()).toBe(false); + there.remove(b); + }); + }); + + // ─── tabbable utils ─────────────────────────────────────────────────────── + + describe('tabbable utils', () => { + it('getTabbableCandidates collects tabbable elements and skips disabled/hidden', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + link + `; + const ids = getTabbableCandidates(container).map((el) => el.id); + + expect(ids).toContain('x'); + expect(ids).toContain('link'); + expect(ids).not.toContain('d'); // disabled skipped + expect(ids).not.toContain('h'); // hidden input skipped + }); + + it('getTabbableEdges returns the first and last tabbable element', () => { + const container = document.createElement('div'); + container.innerHTML = ``; + document.body.appendChild(container); + + const [first, last] = getTabbableEdges(container); + expect(first?.id).toBe('first'); + expect(last?.id).toBe('last'); + + container.remove(); + }); + + it('removeLinks strips anchor elements', () => { + const button = document.createElement('button'); + const anchor = document.createElement('a'); + expect(removeLinks([button, anchor])).toEqual([button]); + }); + }); + + // ─── auto-focus on mount ────────────────────────────────────────────────── + + describe('auto-focus on mount', () => { + it('focuses the first tabbable element on mount', async () => { + const fixture = TestBed.createComponent(LoopHost); + fixture.autoDetectChanges(); + await flush(); + + expect(document.activeElement).toBe(fixture.componentInstance.a().nativeElement); + }); + + it('mountAutoFocus is preventable (preventDefault skips the auto-focus)', async () => { + @Component({ + imports: [RdxFocusScope], + template: ` +
+ +
+ ` + }) + class PreventHost { + readonly a = viewChild.required('a', { read: ElementRef }); + } + + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + + const fixture = TestBed.createComponent(PreventHost); + fixture.autoDetectChanges(); + await flush(); + + // auto-focus was vetoed → focus did not move into the scope + expect(document.activeElement).not.toBe(fixture.componentInstance.a().nativeElement); + outside.remove(); + }); + }); + + // ─── return focus on unmount ────────────────────────────────────────────── + + describe('return focus on unmount', () => { + it('restores focus to the previously-focused element after destroy', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + expect(document.activeElement).toBe(outside); + + const fixture = TestBed.createComponent(LoopHost); + fixture.autoDetectChanges(); + await flush(); // mount auto-focus moves focus into the scope + expect(document.activeElement).not.toBe(outside); + + fixture.destroy(); + await flush(); // unmount return-focus runs in a setTimeout(0) + + expect(document.activeElement).toBe(outside); + outside.remove(); + }); + }); + + // ─── trapped containment ────────────────────────────────────────────────── + + describe('trapped containment', () => { + @Component({ + imports: [RdxFocusScope], + template: ` +
+ + +
+ ` + }) + class TrappedHost { + readonly scope = viewChild.required('scope', { read: ElementRef }); + readonly a = viewChild.required('a', { read: ElementRef }); + } + + it('returns focus inside when focus moves to a legitimate element outside the trap', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + + const fixture = TestBed.createComponent(TrappedHost); + fixture.autoDetectChanges(); + await flush(); // mount + trap listeners attached + + const inside = fixture.componentInstance.a().nativeElement as HTMLElement; + inside.focus(); // focusin marks `inside` as the last-focused element in the scope + + // focus actually leaves to a legitimate outside element → the native focusout (relatedTarget + // = outside) drives the trap, which pulls focus back inside + outside.focus(); + + expect(document.activeElement).toBe(inside); + outside.remove(); + }); + + // The `relatedTarget === null` early-return (tab-away / element-removed, Chrome CPU-spin guard) + // depends on browser-specific blur semantics and is verified in Playwright, not jsdom. + }); +}); diff --git a/packages/primitives/focus-scope/index.ts b/packages/primitives/focus-scope/index.ts index 3f0c6f14..7b70d471 100644 --- a/packages/primitives/focus-scope/index.ts +++ b/packages/primitives/focus-scope/index.ts @@ -1,2 +1,3 @@ +export * from './src/focus-guards'; export * from './src/focus-scope'; export * from './src/focus-scope.config'; diff --git a/packages/primitives/focus-scope/src/focus-guards.ts b/packages/primitives/focus-scope/src/focus-guards.ts new file mode 100644 index 00000000..82138a48 --- /dev/null +++ b/packages/primitives/focus-scope/src/focus-guards.ts @@ -0,0 +1,131 @@ +import { effect } from '@angular/core'; +import { composedContains, getTabbableCandidates } from './utils'; + +/** Marks the leading / trailing focus-guard spans (Base UI `data-base-ui-focus-guard`). */ +export const FOCUS_GUARD_ATTR = 'data-rdx-focus-guard'; + +/** Saved-tabindex marker used by {@link disableFocusInside} / {@link enableFocusInside}. */ +const SAVED_TABINDEX_ATTR = 'data-rdx-tabindex'; + +/** Visually-hidden, off-flow style for a focus guard / `aria-owns` anchor (Base UI `visuallyHidden`). */ +export const FOCUS_GUARD_STYLE: Partial = { + position: 'fixed', + top: '0', + left: '0', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: '0' +}; + +/** + * Creates a visually-hidden, **tabbable** focus-guard `` — the Angular counterpart of Base UI's + * `FocusGuard`. The portal-focus bridge places one before and one after the portal content so a Tab into + * (or out of) the portal lands on a guard, which then redirects focus to the right boundary. + */ +export function createFocusGuard(ownerDocument: Document): HTMLSpanElement { + const guard = ownerDocument.createElement('span'); + guard.setAttribute('tabindex', '0'); + guard.setAttribute('aria-hidden', 'true'); + guard.setAttribute(FOCUS_GUARD_ATTR, ''); + Object.assign(guard.style, FOCUS_GUARD_STYLE); + return guard; +} + +/** + * Creates a visually-hidden `` that links the portal node into the trigger's tab / + * AT order (Base UI's single `aria-owns` anchor). The manager places it next to the trigger. + */ +export function createAriaOwnsAnchor(ownerDocument: Document, portalId: string): HTMLSpanElement { + const anchor = ownerDocument.createElement('span'); + anchor.setAttribute('aria-owns', portalId); + Object.assign(anchor.style, FOCUS_GUARD_STYLE); + return anchor; +} + +/** + * Makes every tabbable descendant of `container` **non-tabbable** (`tabindex="-1"`), saving each one's + * original tabindex so {@link enableFocusInside} can restore it. Base UI `disableFocusInside`: a + * non-modal portal keeps its content untabbable until focus is actually inside it, so a Tab from the + * trigger steps onto the guard instead of jumping into the content. + */ +export function disableFocusInside(container: HTMLElement): void { + for (const element of getTabbableCandidates(container)) { + element.setAttribute(SAVED_TABINDEX_ATTR, element.getAttribute('tabindex') ?? ''); + element.setAttribute('tabindex', '-1'); + } +} + +/** Restores the tabbability that {@link disableFocusInside} suspended. Base UI `enableFocusInside`. */ +export function enableFocusInside(container: HTMLElement): void { + container.querySelectorAll(`[${SAVED_TABINDEX_ATTR}]`).forEach((element) => { + const original = element.getAttribute(SAVED_TABINDEX_ATTR); + element.removeAttribute(SAVED_TABINDEX_ATTR); + if (original) { + element.setAttribute('tabindex', original); + } else { + element.removeAttribute('tabindex'); + } + }); +} + +/** + * Whether a focus event crossed the `container` boundary — its `relatedTarget` (the other side of the + * focus move) is `null` or outside `container` (Base UI `isOutsideEvent`). Shadow-DOM-aware via + * {@link composedContains}. + */ +export function isOutsideEvent(event: FocusEvent, container: Element): boolean { + const relatedTarget = event.relatedTarget as Node | null; + return !relatedTarget || !composedContains(container, relatedTarget); +} + +/** + * The portal-focus bridge's **tabbability toggle** (Base UI `FloatingPortal` capture-phase `onFocus`). + * While `portalNode` is mounted (and `enabled`), it makes the portal content tabbable **only when focus + * is inside it**: focus entering from outside re-enables tabbability, focus leaving to outside disables + * it again. Listens on the **capture** phase so it settles before the focus manager's guards react. + * + * Must be called in an injection context. The initial disable-on-mount and the guard-span placement are + * the manager's responsibility (Phase 1b); this owns only the dynamic in/out toggle. + */ +export function useFocusGuardsTabbability( + portalNode: () => HTMLElement | null, + options: { enabled?: () => boolean } = {} +): void { + const enabled = options.enabled ?? (() => true); + let focusInsideDisabled = false; + + effect((onCleanup) => { + const node = portalNode(); + if (!node || !enabled()) { + return; + } + + const onFocus = (event: FocusEvent): void => { + // Only react to focus actually crossing the portal boundary. + if (!event.relatedTarget || !isOutsideEvent(event, node)) { + return; + } + if (event.type === 'focusin') { + if (focusInsideDisabled) { + enableFocusInside(node); + focusInsideDisabled = false; + } + } else { + disableFocusInside(node); + focusInsideDisabled = true; + } + }; + + node.addEventListener('focusin', onFocus, true); + node.addEventListener('focusout', onFocus, true); + onCleanup(() => { + node.removeEventListener('focusin', onFocus, true); + node.removeEventListener('focusout', onFocus, true); + }); + }); +} diff --git a/packages/primitives/focus-scope/src/focus-scope.ts b/packages/primitives/focus-scope/src/focus-scope.ts index 7a86cbd8..584021c6 100644 --- a/packages/primitives/focus-scope/src/focus-scope.ts +++ b/packages/primitives/focus-scope/src/focus-scope.ts @@ -19,9 +19,11 @@ import { createFocusScopesStack, FocusScopeAPI, removeLinks } from './stack'; import { AUTOFOCUS_ON_MOUNT, AUTOFOCUS_ON_UNMOUNT, + composedContains, EVENT_OPTIONS, focus, focusFirst, + getEventTarget, getTabbableCandidates, getTabbableEdges } from './utils'; @@ -64,6 +66,9 @@ export class RdxFocusScope { private readonly elementRef = inject>(ElementRef); private readonly config = inject(RdxFocusScopeConfigToken); + /** The host's owner `Document` — all focus listeners / reads are scoped here, never global `document`. */ + private readonly ownerDocument = this.elementRef.nativeElement.ownerDocument ?? document; + /** * When `true`, tabbing from last item will focus first tabbable * and shift+tab from first item will focus last tababble. @@ -104,7 +109,7 @@ export class RdxFocusScope { readonly lastFocusedElement = signal(null); - private readonly focusScopesStack = createFocusScopesStack(); + private readonly focusScopesStack = createFocusScopesStack(this.ownerDocument); readonly focusScope: FocusScopeAPI = { paused: signal(false), @@ -130,8 +135,8 @@ export class RdxFocusScope { return; } - const target = event.target as HTMLElement | null; - if (this.elementRef.nativeElement.contains(target)) { + const target = getEventTarget(event) as HTMLElement | null; + if (composedContains(container, target)) { this.lastFocusedElement.set(target); } else { focus(this.lastFocusedElement(), { select: true }); @@ -158,13 +163,13 @@ export class RdxFocusScope { // If the focus has moved to an actual legitimate element (`relatedTarget !== null`) // that is outside the container, we move focus to the last valid focused element inside. - if (!container.contains(relatedTarget)) { + if (!composedContains(container, relatedTarget)) { focus(this.lastFocusedElement(), { select: true }); } }; const handleMutations = () => { - const isLastFocusedElementExist = container.contains(this.lastFocusedElement()); + const isLastFocusedElementExist = composedContains(container, this.lastFocusedElement()); if (!isLastFocusedElementExist) { focus(container); @@ -176,12 +181,12 @@ export class RdxFocusScope { mutationObserver.observe(container, { childList: true, subtree: true }); } - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); + this.ownerDocument.addEventListener('focusin', handleFocusIn); + this.ownerDocument.addEventListener('focusout', handleFocusOut); onCleanup(() => { - document.removeEventListener('focusin', handleFocusIn); - document.removeEventListener('focusout', handleFocusOut); + this.ownerDocument.removeEventListener('focusin', handleFocusIn); + this.ownerDocument.removeEventListener('focusout', handleFocusOut); mutationObserver.disconnect(); }); } @@ -200,8 +205,8 @@ export class RdxFocusScope { this.focusScopesStack.add(this.focusScope); - const previouslyFocusedElement = getActiveElement() as HTMLElement | null; - const hasFocusedCandidate = container.contains(previouslyFocusedElement); + const previouslyFocusedElement = getActiveElement(this.ownerDocument) as HTMLElement | null; + const hasFocusedCandidate = composedContains(container, previouslyFocusedElement); const mountEventHandler = (ev: Event) => { if (this.alive) this.mountAutoFocus.emit(ev); }; @@ -215,7 +220,7 @@ export class RdxFocusScope { focusFirst(removeLinks(getTabbableCandidates(container)), { select: true }); - if (getActiveElement() === previouslyFocusedElement) focus(container); + if (getActiveElement(this.ownerDocument) === previouslyFocusedElement) focus(container); } } @@ -230,15 +235,19 @@ export class RdxFocusScope { const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS); container.dispatchEvent(unmountEvent); - setTimeout(() => { - if (!unmountEvent.defaultPrevented) - focus(previouslyFocusedElement ?? document.body, { select: true }); + // Queue the return-focus on the owner window's animation frame (not `setTimeout`), + // so it runs after the unmounting paint settles (ADR 0017 Phase 1a queued focus). + const view = this.ownerDocument.defaultView ?? globalThis; + view.requestAnimationFrame(() => { + if (!unmountEvent.defaultPrevented) { + focus(previouslyFocusedElement ?? this.ownerDocument.body, { select: true }); + } // we need to remove the listener after we `dispatchEvent` container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, unmountEventHandler); this.focusScopesStack.remove(this.focusScope); - }, 0); + }); }); }, { injector: this.injector } @@ -249,7 +258,7 @@ export class RdxFocusScope { handleKeyDown(event: KeyboardEvent) { const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey; - const focusedElement = getActiveElement() as HTMLElement | null; + const focusedElement = getActiveElement(this.ownerDocument) as HTMLElement | null; if (isTabKey && focusedElement) { const container = event.currentTarget as HTMLElement; diff --git a/packages/primitives/focus-scope/src/stack.ts b/packages/primitives/focus-scope/src/stack.ts index d0ba247a..7734bf2e 100644 --- a/packages/primitives/focus-scope/src/stack.ts +++ b/packages/primitives/focus-scope/src/stack.ts @@ -1,21 +1,30 @@ import { signal, WritableSignal } from '@angular/core'; -export function createGlobalState(factory: () => T): () => T { - const state = factory(); - return () => state; -} - export interface FocusScopeAPI { paused: WritableSignal; pause(): void; resume(): void; } -const useFocusStackState = createGlobalState(() => signal([])); +/** + * The active-scope stack pauses/resumes scopes, so it **is** cross-document coordination state — keyed + * per owner `Document` (a `WeakMap`) rather than process-global (ADR 0017 Phase 1a): opening a scope in + * document B must not pause document A's scope. + */ +const stacksByDocument = new WeakMap>(); + +function getFocusStackState(document: Document): WritableSignal { + let state = stacksByDocument.get(document); + if (!state) { + state = signal([]); + stacksByDocument.set(document, state); + } + return state; +} -export function createFocusScopesStack() { - /** A stack of focus scopes, with the active one at the top */ - const stack = useFocusStackState(); +export function createFocusScopesStack(document: Document) { + /** A stack of focus scopes for this document, with the active one at the top */ + const stack = getFocusStackState(document); return { add(focusScope: FocusScopeAPI) { diff --git a/packages/primitives/focus-scope/src/utils.ts b/packages/primitives/focus-scope/src/utils.ts index a6e5b9bc..fe367c3b 100644 --- a/packages/primitives/focus-scope/src/utils.ts +++ b/packages/primitives/focus-scope/src/utils.ts @@ -6,6 +6,29 @@ export const EVENT_OPTIONS = { bubbles: false, cancelable: true }; type FocusableTarget = HTMLElement | { focus: () => void }; +/** + * The real target of a (possibly retargeted) event, piercing shadow boundaries via `composedPath()`. + * Falls back to `event.target` when `composedPath` is unavailable. + */ +export function getEventTarget(event: Event): EventTarget | null { + return event.composedPath?.()[0] ?? event.target; +} + +/** + * Shadow-DOM-aware containment: whether `node` is `container` or lives inside it, crossing shadow roots + * via their `host` (unlike `Node.contains`, which stops at a shadow boundary). + */ +export function composedContains(container: Node, node: Node | null): boolean { + let current: Node | null = node; + while (current) { + if (current === container) { + return true; + } + current = current instanceof ShadowRoot ? current.host : current.parentNode; + } + return false; +} + /** * Attempts focusing the first element in a list of candidates. * Stops when focus has actually moved. @@ -32,7 +55,7 @@ export function focusFirst(candidates: HTMLElement[], { select = false } = {}) { */ export function getTabbableCandidates(container: HTMLElement) { const nodes: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + const walker = container.ownerDocument.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { acceptNode: (node: any) => { const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'; if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP; @@ -49,11 +72,15 @@ export function getTabbableCandidates(container: HTMLElement) { } export function isHidden(node: HTMLElement, { upTo }: { upTo?: HTMLElement }) { - if (getComputedStyle(node).visibility === 'hidden') return true; + const view = node.ownerDocument.defaultView; + if (!view) { + return false; // no view (detached / SSR) — cannot resolve computed styles, treat as visible + } + if (view.getComputedStyle(node).visibility === 'hidden') return true; while (node) { // we stop at `upTo` (excluding it) if (upTo !== undefined && node === upTo) return false; - if (getComputedStyle(node).display === 'none') return true; + if (view.getComputedStyle(node).display === 'none') return true; node = node.parentElement as HTMLElement; } return false; diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index 762618c7..adea1bc7 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -2009,6 +2009,33 @@ } ] }, + { + "slug": "core", + "section": "components", + "parts": [ + { + "directive": "RdxFloatingNodeRegistration", + "selector": "[rdxFloatingNode]", + "exportAs": "rdxFloatingNode", + "description": "Registers a {@link RdxFloatingNode} into the shared {@link RdxFloatingTree} for its DI subtree and\npropagates the registration handle to descendants — the reusable Angular counterpart of mounting a\nBase UI `` (ADR 0015 §1, Phase 1). It is the **single** place that runs the handle\npattern; the dismissal capability (ADR 0015) and the focus manager (ADR 0017) **consume** the node /\ncontext / tree it registers, they do not re-implement registration.\n\n**What it owns vs. what it reads.** It provides its own {@link RdxFloatingRegistrationContext} (so\ndescendants resolve it with `skipSelf`) and registers/unregisters a node reactively. It does **not**\ncreate the tree or the root context — a coordination-boundary primitive root supplies those\n(`provideFloatingTree()` inherit-or-create + `provideFloatingRootContext()`); this directive injects\nthem. With **no** enclosing tree it runs **node-optional** (`status() === 'detached'`, `node() ===\nnull`), reading its context directly — the standalone `rdxDismissableLayer` case.\n\n**Resolution (per {@link RdxFloatingParentOverride}).** Only an `inherit` node depends on the DI\nparent, so only it waits on a `pending` parent; `root` / `node` overrides are independent and register\nimmediately. The node carries the injected {@link RDX_FLOATING_ROOT_CONTEXT} (or `null` for a\ncontextless intermediate). All teardown (re-resolution **and** destroy) unregisters the node and\nreverts the handle.", + "inputs": [ + { + "name": "externalTree", + "type": "RdxFloatingTree | null", + "default": "null", + "description": "Explicit tree for detached sibling composition — Base UI's `externalTree`." + }, + { + "name": "parentOverride", + "type": "RdxFloatingParentOverride", + "default": "{ kind: 'inherit' }", + "description": "How this node's logical parent is resolved. Defaults to `inherit` (nearest DI ancestor)." + } + ], + "outputs": [] + } + ] + }, { "slug": "cropper", "section": "components", From f06502543ee4d61b6741fcfce6496692de30900a Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 11:39:26 +0300 Subject: [PATCH 06/35] feat(floating-focus-manager): rdxFloatingFocusManager skeleton --- .../__tests__/floating-focus-manager.spec.ts | 130 ++++++++++++++++++ .../floating-focus-manager/index.ts | 1 + .../floating-focus-manager/ng-package.json | 5 + .../src/floating-focus-manager.ts | 109 +++++++++++++++ skills/radix-ng/references/api-contract.json | 42 ++++++ tsconfig.base.json | 1 + 6 files changed, 288 insertions(+) create mode 100644 packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts create mode 100644 packages/primitives/floating-focus-manager/index.ts create mode 100644 packages/primitives/floating-focus-manager/ng-package.json create mode 100644 packages/primitives/floating-focus-manager/src/floating-focus-manager.ts diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts new file mode 100644 index 00000000..08338963 --- /dev/null +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { Component, ElementRef, signal, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { RdxFloatingFocusManager, resolveFocusTarget, resolveInitialFocus } from '../src/floating-focus-manager'; + +const flush = (): Promise => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); + +@Component({ + imports: [RdxFloatingFocusManager], + template: ` +
+ + +
+ ` +}) +class ManagerHost { + readonly modal = signal(false); + readonly enabled = signal(true); + readonly scope = viewChild.required('scope', { read: ElementRef }); + readonly a = viewChild.required('a', { read: ElementRef }); + readonly b = viewChild.required('b', { read: ElementRef }); +} + +describe('RdxFloatingFocusManager (skeleton)', () => { + const appended: Element[] = []; + + afterEach(() => appended.splice(0).forEach((el) => el.remove())); + beforeEach(() => TestBed.resetTestingModule()); + + // ─── modal → RdxFocusScope.trapped composition ──────────────────────────── + + it('traps focus (via composed RdxFocusScope) when modal and enabled', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.modal.set(true); + fixture.autoDetectChanges(); + await flush(); + + const inside = fixture.componentInstance.a().nativeElement as HTMLElement; + inside.focus(); + outside.focus(); // native focusout (relatedTarget=outside) → the trap pulls focus back inside + + expect(document.activeElement).toBe(inside); + }); + + it('does NOT trap when non-modal (modal = false)', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + // modal stays false + fixture.autoDetectChanges(); + await flush(); + + const inside = fixture.componentInstance.a().nativeElement as HTMLElement; + inside.focus(); + outside.focus(); + + expect(document.activeElement).toBe(outside); // focus is free to leave + }); + + it('does NOT trap when disabled, even if modal (manager off)', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.modal.set(true); + fixture.componentInstance.enabled.set(false); + fixture.autoDetectChanges(); + await flush(); + + const inside = fixture.componentInstance.a().nativeElement as HTMLElement; + inside.focus(); + outside.focus(); + + expect(document.activeElement).toBe(outside); + }); + + it('reactively re-enables the trap when modal flips true', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.autoDetectChanges(); + await flush(); + + fixture.componentInstance.modal.set(true); + await flush(); + + // focus a fresh inside element (B, not the auto-focused A) so the freshly-attached trap records + // it via `focusin`, then verify focus leaving is pulled back + const inside = fixture.componentInstance.b().nativeElement as HTMLElement; + inside.focus(); + outside.focus(); + + expect(document.activeElement).toBe(inside); + }); + + // ─── policy resolvers (§2 contract) ─────────────────────────────────────── + + describe('policy resolvers', () => { + it('resolveFocusTarget handles element, getter, and null', () => { + const el = document.createElement('button'); + expect(resolveFocusTarget(el)).toBe(el); + expect(resolveFocusTarget(() => el)).toBe(el); + expect(resolveFocusTarget(null)).toBeNull(); + expect(resolveFocusTarget(() => null)).toBeNull(); + }); + + it('resolveInitialFocus passes the open interaction type to a callback', () => { + const keyboardEl = document.createElement('button'); + const pointerEl = document.createElement('button'); + const policy = (type: 'mouse' | 'touch' | 'pen' | 'keyboard' | '' | null) => + type === 'keyboard' ? keyboardEl : pointerEl; + + expect(resolveInitialFocus(policy, 'keyboard')).toBe(keyboardEl); + expect(resolveInitialFocus(policy, 'mouse')).toBe(pointerEl); + // a plain target ignores the interaction type + expect(resolveInitialFocus(keyboardEl, 'mouse')).toBe(keyboardEl); + }); + }); +}); diff --git a/packages/primitives/floating-focus-manager/index.ts b/packages/primitives/floating-focus-manager/index.ts new file mode 100644 index 00000000..be566156 --- /dev/null +++ b/packages/primitives/floating-focus-manager/index.ts @@ -0,0 +1 @@ +export * from './src/floating-focus-manager'; diff --git a/packages/primitives/floating-focus-manager/ng-package.json b/packages/primitives/floating-focus-manager/ng-package.json new file mode 100644 index 00000000..bebf62dc --- /dev/null +++ b/packages/primitives/floating-focus-manager/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts new file mode 100644 index 00000000..f8b34971 --- /dev/null +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -0,0 +1,109 @@ +import { + booleanAttribute, + computed, + Directive, + effect, + inject, + input, + Provider, + signal, + WritableSignal +} from '@angular/core'; +import { BooleanInput } from '@radix-ng/primitives/core'; +import { + provideRdxFocusScopeConfig, + RdxFocusScope, + RdxFocusScopeConfig, + RdxFocusScopeConfigToken +} from '@radix-ng/primitives/focus-scope'; + +/** + * How a popup was opened / closed (Base UI `InteractionType`). `null` = a **programmatic** open (prefer + * the previously-focused element); `''` = an **unknown** interaction. The two are deliberately distinct + * (Base UI keys `preferPreviousFocus = openInteractionType == null` off exactly this). + */ +export type RdxInteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard' | '' | null; + +/** A focus target: an element, a getter, or `null`. */ +export type RdxFocusTarget = HTMLElement | (() => HTMLElement | null) | null; + +/** `initialFocus` policy (ADR 0017 §2) — a target or a callback receiving the **open** interaction type. */ +export type RdxInitialFocus = RdxFocusTarget | ((openInteractionType: RdxInteractionType) => RdxFocusTarget); + +/** `returnFocus` policy (ADR 0017 §2) — a target/boolean or a callback receiving the **close** interaction type. */ +export type RdxReturnFocus = + | RdxFocusTarget + | boolean + | ((closeInteractionType: RdxInteractionType) => RdxFocusTarget | boolean); + +/** Resolves an {@link RdxFocusTarget} (element | getter | null) to a concrete element. */ +export function resolveFocusTarget(target: RdxFocusTarget): HTMLElement | null { + return typeof target === 'function' ? target() : target; +} + +/** + * Resolves an {@link RdxInitialFocus} policy against how the popup opened. + */ +export function resolveInitialFocus( + policy: RdxInitialFocus, + openInteractionType: RdxInteractionType +): HTMLElement | null { + return resolveFocusTarget(typeof policy === 'function' ? policy(openInteractionType) : policy); +} + +/** + * Provides a {@link RdxFocusScopeConfig} whose `trapped` is a **writable** signal, so the enclosing + * {@link RdxFloatingFocusManager} can drive it from its `modal`/`enabled` policy after construction. The + * factory has **no** dependency on the manager instance, so it cannot deadlock the host-directive + * construction order (the manager later injects this same config and writes the signal). + */ +function provideManagedFocusScopeConfig(): Provider { + return provideRdxFocusScopeConfig((): RdxFocusScopeConfig => ({ trapped: signal(false) })); +} + +/** + * `RdxFloatingFocusManager` (ADR 0017 Phase 1b skeleton) — the Angular counterpart of Base UI's + * `FloatingFocusManager`. It is a **coordinator** that composes three low-level focus parts (it never + * inherits them, which would re-fuse trap + popup policy): the **reworked {@link RdxFocusScope}** (the + * trap, via `hostDirectives`), the portal-focus bridge, and owner-`Document` guards. Per ADR 0017 §1/§2 + * its policies are **independent**, none derived from `modal`. + * + * **This skeleton wires the composition + lifecycle gates:** + * - `enabled` — the manager's active-ness (`mounted && !hover-open`). When off, **no trap** (and, later, + * no aria-hidden / no marker — Phase 2). + * - `modal` → `RdxFocusScope.trapped`: the effective trap is `enabled() && modal()`, pushed into the + * composed focus scope through its config token (the composition seam). + * - `loop` is forwarded to `RdxFocusScope`. + * - `initialFocus` / `returnFocus` are **typed** here (the §2 policy contract, incl. the interaction-type + * callback forms); their full orchestration + the portal-bridge wiring + close-on-focus-out land in + * later phases. + */ +@Directive({ + selector: '[rdxFloatingFocusManager]', + exportAs: 'rdxFloatingFocusManager', + hostDirectives: [{ directive: RdxFocusScope, inputs: ['loop'] }], + providers: [provideManagedFocusScopeConfig()] +}) +export class RdxFloatingFocusManager { + /** Manager active-ness (ADR 0017 §2): the popup is mounted **and** not hover-opened. */ + readonly enabled = input(true, { transform: booleanAttribute }); + + /** Modal popup → focus trap. Combined with `enabled` to drive the composed `RdxFocusScope`. */ + readonly modal = input(false, { transform: booleanAttribute }); + + /** Where focus goes when the popup opens (ADR 0017 §2). */ + readonly initialFocus = input(null); + + /** Where focus returns when the popup closes (ADR 0017 §2). */ + readonly returnFocus = input(true); + + /** The effective trap state the composed `RdxFocusScope` reads via its config token. */ + readonly trapped = computed(() => this.enabled() && this.modal()); + + // The config this directive provides — its `trapped` signal is writable so we can drive it. + private readonly focusScopeConfig = inject(RdxFocusScopeConfigToken) as { trapped: WritableSignal }; + + constructor() { + effect(() => this.focusScopeConfig.trapped.set(this.trapped())); + } +} diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index adea1bc7..7bd3d2dc 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -3022,6 +3022,48 @@ } ] }, + { + "slug": "floating-focus-manager", + "section": "components", + "parts": [ + { + "directive": "RdxFloatingFocusManager", + "selector": "[rdxFloatingFocusManager]", + "exportAs": "rdxFloatingFocusManager", + "description": "`RdxFloatingFocusManager` (ADR 0017 Phase 1b skeleton) — the Angular counterpart of Base UI's\n`FloatingFocusManager`. It is a **coordinator** that composes three low-level focus parts (it never\ninherits them, which would re-fuse trap + popup policy): the **reworked {@link RdxFocusScope}** (the\ntrap, via `hostDirectives`), the portal-focus bridge, and owner-`Document` guards. Per ADR 0017 §1/§2\nits policies are **independent**, none derived from `modal`.\n\n**This skeleton wires the composition + lifecycle gates:**\n- `enabled` — the manager's active-ness (`mounted && !hover-open`). When off, **no trap** (and, later,\n no aria-hidden / no marker — Phase 2).\n- `modal` → `RdxFocusScope.trapped`: the effective trap is `enabled() && modal()`, pushed into the\n composed focus scope through its config token (the composition seam).\n- `loop` is forwarded to `RdxFocusScope`.\n- `initialFocus` / `returnFocus` are **typed** here (the §2 policy contract, incl. the interaction-type\n callback forms); their full orchestration + the portal-bridge wiring + close-on-focus-out land in\n later phases.", + "hostDirectives": [ + "RdxFocusScope" + ], + "inputs": [ + { + "name": "enabled", + "type": "boolean", + "default": "true", + "description": "Manager active-ness (ADR 0017 §2): the popup is mounted **and** not hover-opened." + }, + { + "name": "initialFocus", + "type": "RdxInitialFocus", + "default": "null", + "description": "Where focus goes when the popup opens (ADR 0017 §2)." + }, + { + "name": "modal", + "type": "boolean", + "default": "false", + "description": "Modal popup → focus trap. Combined with `enabled` to drive the composed `RdxFocusScope`." + }, + { + "name": "returnFocus", + "type": "RdxReturnFocus", + "default": "true", + "description": "Where focus returns when the popup closes (ADR 0017 §2)." + } + ], + "outputs": [] + } + ] + }, { "slug": "focus-guards", "section": "components", diff --git a/tsconfig.base.json b/tsconfig.base.json index de416afb..46953dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,6 +41,7 @@ "@radix-ng/primitives/label": ["packages/primitives/label/index.ts"], "@radix-ng/primitives/focus-scope": ["packages/primitives/focus-scope/index.ts"], "@radix-ng/primitives/focus-guards": ["packages/primitives/focus-guards/index.ts"], + "@radix-ng/primitives/floating-focus-manager": ["packages/primitives/floating-focus-manager/index.ts"], "@radix-ng/primitives/menu": ["packages/primitives/menu/index.ts"], "@radix-ng/primitives/menubar": ["packages/primitives/menubar/index.ts"], "@radix-ng/primitives/meter": ["packages/primitives/meter/index.ts"], From 6a7c6531d51b8cb8b2da7c0af313ab3a387e4bd1 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 11:48:46 +0300 Subject: [PATCH 07/35] feat(floating-focus-manager): markOthers aria-hidden + marker passes --- .../__tests__/floating-focus-manager.spec.ts | 48 +++++ .../__tests__/mark-others.spec.ts | 112 ++++++++++++ .../src/floating-focus-manager.ts | 29 +++ .../floating-focus-manager/src/mark-others.ts | 171 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 packages/primitives/floating-focus-manager/__tests__/mark-others.spec.ts create mode 100644 packages/primitives/floating-focus-manager/src/mark-others.ts diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 08338963..734e07d8 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -3,6 +3,7 @@ import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { RdxFloatingFocusManager, resolveFocusTarget, resolveInitialFocus } from '../src/floating-focus-manager'; +import { RDX_FLOATING_MARKER } from '../src/mark-others'; const flush = (): Promise => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); @@ -104,6 +105,53 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(document.activeElement).toBe(inside); }); + // ─── markOthers passes (ADR 0017 §3) ───────────────────────────────────── + + it('marks outside elements while active, but does NOT aria-hide them when non-modal', async () => { + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + appended.push(sibling); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.autoDetectChanges(); + await flush(); + + expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + expect(sibling.getAttribute('aria-hidden')).toBeNull(); // non-modal → no a11y isolation + }); + + it('aria-hides outside elements when modal', async () => { + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + appended.push(sibling); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.modal.set(true); + fixture.autoDetectChanges(); + await flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true'); + expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); // marker still applied (active) + }); + + it('clears the marks when the manager is disabled', async () => { + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + appended.push(sibling); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.modal.set(true); + fixture.autoDetectChanges(); + await flush(); + expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + + fixture.componentInstance.enabled.set(false); + await flush(); + + expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + expect(sibling.getAttribute('aria-hidden')).toBeNull(); + }); + // ─── policy resolvers (§2 contract) ─────────────────────────────────────── describe('policy resolvers', () => { diff --git a/packages/primitives/floating-focus-manager/__tests__/mark-others.spec.ts b/packages/primitives/floating-focus-manager/__tests__/mark-others.spec.ts new file mode 100644 index 00000000..7900b1b6 --- /dev/null +++ b/packages/primitives/floating-focus-manager/__tests__/mark-others.spec.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { markOthers, RDX_FLOATING_MARKER } from '../src/mark-others'; + +describe('markOthers', () => { + const undos: Array<() => void> = []; + const appended: Element[] = []; + + function tree(): { popup: HTMLElement; s1: HTMLElement; s2: HTMLElement; container: HTMLElement } { + const root = document.createElement('div'); + root.innerHTML = ` +
s1
+
+
s2
+ `; + document.body.appendChild(root); + appended.push(root); + return { + popup: root.querySelector('#popup')!, + container: root.querySelector('#container')!, + s1: root.querySelector('#s1')!, + s2: root.querySelector('#s2')! + }; + } + + function mark(avoid: Element[], options?: Parameters[1]): void { + undos.push(markOthers(avoid, options)); + } + + afterEach(() => { + undos.splice(0).forEach((undo) => undo()); + appended.splice(0).forEach((el) => el.remove()); + }); + + it('marks elements outside the kept subtree, not the popup or its ancestors', () => { + const { popup, container, s1, s2 } = tree(); + + mark([popup]); + + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + expect(s2.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + // the popup and its ancestor container are inside the kept path → untouched + expect(popup.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + expect(container.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + }); + + it('aria-hides outside elements when ariaHidden is set', () => { + const { popup, s1 } = tree(); + + mark([popup], { ariaHidden: true, mark: false }); + + expect(s1.getAttribute('aria-hidden')).toBe('true'); + // mark:false → no marker attribute + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + }); + + it('undo removes the attributes it applied', () => { + const { popup, s1 } = tree(); + + const undo = markOthers([popup]); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + + undo(); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + }); + + it('ref-counts overlapping calls — clears only when the last undo runs', () => { + const { popup, s1 } = tree(); + + const undoA = markOthers([popup]); + const undoB = markOthers([popup]); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + + undoA(); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); // still held by B + + undoB(); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + }); + + it('leaves a pre-existing aria-hidden element hidden after undo', () => { + const { popup, s1 } = tree(); + s1.setAttribute('aria-hidden', 'true'); // already hidden by the app + + const undo = markOthers([popup], { ariaHidden: true, mark: false }); + expect(s1.getAttribute('aria-hidden')).toBe('true'); + + undo(); + // we did not own it, so it stays hidden + expect(s1.getAttribute('aria-hidden')).toBe('true'); + }); + + it('keeps aria-live regions announceable (does not aria-hide them)', () => { + const root = document.createElement('div'); + root.innerHTML = ` +
live
+ + `; + document.body.appendChild(root); + appended.push(root); + + mark([root.querySelector('#popup')!], { ariaHidden: true, mark: false }); + + expect(root.querySelector('#live')!.getAttribute('aria-hidden')).toBeNull(); + }); + + it('is a no-op for an empty avoid list', () => { + const { s1 } = tree(); + markOthers([])(); + expect(s1.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + }); +}); diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index f8b34971..9a6510a3 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -1,10 +1,13 @@ +import { isPlatformBrowser } from '@angular/common'; import { booleanAttribute, computed, Directive, effect, + ElementRef, inject, input, + PLATFORM_ID, Provider, signal, WritableSignal @@ -16,6 +19,7 @@ import { RdxFocusScopeConfig, RdxFocusScopeConfigToken } from '@radix-ng/primitives/focus-scope'; +import { markOthers } from './mark-others'; /** * How a popup was opened / closed (Base UI `InteractionType`). `null` = a **programmatic** open (prefer @@ -103,7 +107,32 @@ export class RdxFloatingFocusManager { // The config this directive provides — its `trapped` signal is writable so we can drive it. private readonly focusScopeConfig = inject(RdxFocusScopeConfigToken) as { trapped: WritableSignal }; + private readonly host = inject(ElementRef).nativeElement as HTMLElement; + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + constructor() { effect(() => this.focusScopeConfig.trapped.set(this.trapped())); + + if (!this.isBrowser) { + return; // SSR: no DOM marking. + } + + // Marker pass (ADR 0017 §3) — applied to outside elements whenever the manager is **active**, + // independent of `modal`. Read by ADR 0015's outside-press guard. + effect((onCleanup) => { + if (!this.enabled()) { + return; + } + onCleanup(markOthers([this.host], { ariaHidden: false, mark: true })); + }); + + // Accessibility-isolation pass (ADR 0017 §3) — `aria-hidden` outside elements, but **only** for a + // modal (later: typeable-combobox) popup, so Select/Menu-root get none. + effect((onCleanup) => { + if (!this.enabled() || !this.modal()) { + return; + } + onCleanup(markOthers([this.host], { ariaHidden: true, mark: false })); + }); } } diff --git a/packages/primitives/floating-focus-manager/src/mark-others.ts b/packages/primitives/floating-focus-manager/src/mark-others.ts new file mode 100644 index 00000000..c15ce330 --- /dev/null +++ b/packages/primitives/floating-focus-manager/src/mark-others.ts @@ -0,0 +1,171 @@ +/** + * `markOthers` — the Angular port of Base UI's `floating-ui-react/utils/markOthers` (ADR 0017 §3). It + * isolates a popup from the rest of the page by walking the owner `Document`'s `` and applying an + * attribute to every element **outside** the kept (`avoidElements`) subtree — siblings of the popup's + * ancestor chain — leaving the popup, its ancestors, and any `[aria-live]` region untouched. + * + * Two independent passes (Base UI makes two separate calls, never bundling them): + * - **`ariaHidden`** — `aria-hidden="true"` for AT isolation (applied only for modal / typeable popups). + * - **`mark`** — a neutral marker attribute ({@link RDX_FLOATING_MARKER}) applied whenever the focus + * manager is active; read by ADR 0015's outside-press guard to detect third-party-injected subtrees. + * + * Per-**element** ref-counting (`WeakMap`) lets overlapping popups compose: an element + * marked by two popups is only cleared when both undo. An element that was **already** `aria-hidden` + * before the call is recorded and left in place on undo. `inert` is deliberately **not** ported — no Base + * UI focus-manager consumer passes it (ADR 0017 §3). + * + * @returns an `Undo` that reverses exactly what this call applied. + */ +export type Undo = () => void; + +export interface MarkOthersOptions { + /** Apply `aria-hidden="true"` to outside elements (AT isolation). */ + ariaHidden?: boolean; + /** Apply the neutral {@link RDX_FLOATING_MARKER} to outside elements. Default `true`. */ + mark?: boolean; +} + +/** The neutral "outside the active floating layer" marker (Base UI `data-base-ui-inert`). */ +export const RDX_FLOATING_MARKER = 'data-rdx-floating-inert'; + +const ARIA_HIDDEN = 'aria-hidden'; + +/** Per-element ref-counts. Keyed by element, so they are naturally per-`Document`. */ +let ariaHiddenCounters = new WeakMap(); +let markerCounters = new WeakMap(); +/** Elements that were already `aria-hidden` before we touched them — left in place on undo. */ +let preExistingHidden = new WeakSet(); +let lockCount = 0; + +function unwrapHost(node: Node | null): Element | null { + if (!node) { + return null; + } + return node instanceof ShadowRoot ? node.host : unwrapHost(node.parentNode); +} + +/** Maps each target to the element actually inside `parent` (piercing shadow hosts), dropping the rest. */ +function correctElements(parent: HTMLElement, targets: Element[]): Element[] { + return targets + .map((target) => { + if (parent.contains(target)) { + return target; + } + const host = unwrapHost(target); + return host && parent.contains(host) ? host : null; + }) + .filter((element): element is Element => element != null); +} + +/** The set of nodes on the path from each target up to the root — the "keep" subtree. */ +function buildKeepSet(targets: Element[]): Set { + const keep = new Set(); + targets.forEach((target) => { + let node: Node | null = target; + while (node && !keep.has(node)) { + keep.add(node); + node = node.parentNode; + } + }); + return keep; +} + +/** Collects every element outside the kept subtree (a sibling of the kept ancestor chain). */ +function collectOutsideElements(root: HTMLElement, keep: Set, stop: Set): Element[] { + const outside: Element[] = []; + const walk = (parent: Element | null): void => { + if (!parent || stop.has(parent)) { + return; + } + Array.from(parent.children).forEach((node) => { + if (node.nodeName.toLowerCase() === 'script') { + return; + } + if (keep.has(node)) { + walk(node); + } else { + outside.push(node); + } + }); + }; + walk(root); + return outside; +} + +export function markOthers(avoidElements: Element[], options: MarkOthersOptions = {}): Undo { + const { ariaHidden = false, mark = true } = options; + const first = avoidElements[0]; + if (!first) { + return () => {}; + } + const body = first.ownerDocument.body; + const avoid = correctElements(body, avoidElements); + + const hiddenElements: Element[] = []; + const markedElements: Element[] = []; + + if (ariaHidden) { + // `aria-live` regions stay announceable, so keep them out of the hidden set too. + const live = correctElements(body, Array.from(body.querySelectorAll('[aria-live]'))); + const controlElements = avoid.concat(live); + const targets = collectOutsideElements(body, buildKeepSet(controlElements), new Set(controlElements)); + + targets.forEach((node) => { + const attr = node.getAttribute(ARIA_HIDDEN); + const alreadyHidden = attr !== null && attr !== 'false'; + const count = (ariaHiddenCounters.get(node) ?? 0) + 1; + ariaHiddenCounters.set(node, count); + hiddenElements.push(node); + + if (count === 1 && alreadyHidden) { + preExistingHidden.add(node); + } + if (!alreadyHidden) { + node.setAttribute(ARIA_HIDDEN, 'true'); + } + }); + } + + if (mark) { + const targets = collectOutsideElements(body, buildKeepSet(avoid), new Set(avoid)); + targets.forEach((node) => { + const count = (markerCounters.get(node) ?? 0) + 1; + markerCounters.set(node, count); + markedElements.push(node); + if (count === 1) { + node.setAttribute(RDX_FLOATING_MARKER, ''); + } + }); + } + + lockCount += 1; + + return () => { + hiddenElements.forEach((element) => { + const count = (ariaHiddenCounters.get(element) ?? 0) - 1; + ariaHiddenCounters.set(element, count); + if (count === 0) { + if (!preExistingHidden.has(element)) { + element.removeAttribute(ARIA_HIDDEN); + } + preExistingHidden.delete(element); + } + }); + + markedElements.forEach((element) => { + const count = (markerCounters.get(element) ?? 0) - 1; + markerCounters.set(element, count); + if (count === 0) { + element.removeAttribute(RDX_FLOATING_MARKER); + } + }); + + lockCount -= 1; + if (lockCount === 0) { + // No active locks anywhere — drop the ref-count tables so detached elements can be GC'd. + ariaHiddenCounters = new WeakMap(); + markerCounters = new WeakMap(); + preExistingHidden = new WeakSet(); + } + }; +} From 506b35b18bfcc9cb89ac202a8049e970620898af Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 11:55:00 +0300 Subject: [PATCH 08/35] feat(floating-focus-manager): close-on-focus-out reading the shared tree --- .../__tests__/floating-focus-manager.spec.ts | 111 ++++++++++++++++- .../src/floating-focus-manager.ts | 114 +++++++++++++++++- packages/primitives/focus-scope/index.ts | 1 + 3 files changed, 223 insertions(+), 3 deletions(-) diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 734e07d8..51676e89 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { FOCUS_GUARD_ATTR } from '@radix-ng/primitives/focus-scope'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { RdxFloatingFocusManager, resolveFocusTarget, resolveInitialFocus } from '../src/floating-focus-manager'; import { RDX_FLOATING_MARKER } from '../src/mark-others'; @@ -10,7 +11,13 @@ const flush = (): Promise => new Promise((resolve) => requestAnimationFram @Component({ imports: [RdxFloatingFocusManager], template: ` -
+
@@ -19,9 +26,11 @@ const flush = (): Promise => new Promise((resolve) => requestAnimationFram class ManagerHost { readonly modal = signal(false); readonly enabled = signal(true); + readonly closeOnFocusOut = signal(true); readonly scope = viewChild.required('scope', { read: ElementRef }); readonly a = viewChild.required('a', { read: ElementRef }); readonly b = viewChild.required('b', { read: ElementRef }); + readonly manager = viewChild.required(RdxFloatingFocusManager); } describe('RdxFloatingFocusManager (skeleton)', () => { @@ -152,6 +161,106 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(sibling.getAttribute('aria-hidden')).toBeNull(); }); + // ─── close-on-focus-out (ADR 0017 §3) ──────────────────────────────────── + + function focusOutTo(host: HTMLElement, relatedTarget: EventTarget | null): void { + host.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget })); + } + + async function setupManager(): Promise<{ + host: HTMLElement; + emitted: () => boolean; + fixture: ReturnType>; + }> { + const fixture = TestBed.createComponent(ManagerHost); + fixture.autoDetectChanges(); + await flush(); + let emitted = false; + fixture.componentInstance.manager().focusOut.subscribe(() => (emitted = true)); + return { host: fixture.componentInstance.scope().nativeElement, emitted: () => emitted, fixture }; + } + + it('emits focusOut when a non-modal popup loses focus to an unrelated node', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const { host, emitted } = await setupManager(); + focusOutTo(host, outside); + + expect(emitted()).toBe(true); + }); + + it('does NOT emit when focus stays inside the popup', async () => { + const { host, emitted, fixture } = await setupManager(); + focusOutTo(host, fixture.componentInstance.a().nativeElement); + expect(emitted()).toBe(false); + }); + + it('does NOT emit for a modal popup (the trap keeps focus in)', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.modal.set(true); + fixture.autoDetectChanges(); + await flush(); + let emitted = false; + fixture.componentInstance.manager().focusOut.subscribe(() => (emitted = true)); + + focusOutTo(fixture.componentInstance.scope().nativeElement, outside); + expect(emitted).toBe(false); + }); + + it('does NOT emit when relatedTarget is null (tab-away / window blur)', async () => { + const { host, emitted } = await setupManager(); + focusOutTo(host, null); + expect(emitted()).toBe(false); + }); + + it('does NOT emit when relatedTarget is a focus guard', async () => { + const guard = document.createElement('span'); + guard.setAttribute(FOCUS_GUARD_ATTR, ''); + document.body.appendChild(guard); + appended.push(guard); + + const { host, emitted } = await setupManager(); + focusOutTo(host, guard); + expect(emitted()).toBe(false); + }); + + it('does NOT emit during a pointer press (a drag must not close)', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const { host, emitted } = await setupManager(); + document.dispatchEvent(new Event('pointerdown')); + focusOutTo(host, outside); + expect(emitted()).toBe(false); + + document.dispatchEvent(new Event('pointerup')); + focusOutTo(host, outside); + expect(emitted()).toBe(true); // after the press ends, focus-out closes again + }); + + it('does NOT emit when closeOnFocusOut is false', async () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ManagerHost); + fixture.componentInstance.closeOnFocusOut.set(false); + fixture.autoDetectChanges(); + await flush(); + let emitted = false; + fixture.componentInstance.manager().focusOut.subscribe(() => (emitted = true)); + + focusOutTo(fixture.componentInstance.scope().nativeElement, outside); + expect(emitted).toBe(false); + }); + // ─── policy resolvers (§2 contract) ─────────────────────────────────────── describe('policy resolvers', () => { diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index 9a6510a3..d17046c0 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -2,18 +2,27 @@ import { isPlatformBrowser } from '@angular/common'; import { booleanAttribute, computed, + DestroyRef, Directive, effect, ElementRef, inject, input, + output, PLATFORM_ID, Provider, signal, WritableSignal } from '@angular/core'; -import { BooleanInput } from '@radix-ng/primitives/core'; import { + BooleanInput, + RDX_FLOATING_REGISTRATION, + RDX_FLOATING_ROOT_CONTEXT, + RdxFloatingRootContext +} from '@radix-ng/primitives/core'; +import { + composedContains, + FOCUS_GUARD_ATTR, provideRdxFocusScopeConfig, RdxFocusScope, RdxFocusScopeConfig, @@ -101,6 +110,20 @@ export class RdxFloatingFocusManager { /** Where focus returns when the popup closes (ADR 0017 §2). */ readonly returnFocus = input(true); + /** + * Whether a **non-modal** popup closes when focus leaves to an unrelated node (Base UI + * `closeOnFocusOut`, default `true`; Dialog sets it to `!disablePointerDismissal`). Modal popups + * never close on focus-out (the trap keeps focus in). + */ + readonly closeOnFocusOut = input(true, { transform: booleanAttribute }); + + /** + * Emitted when focus leaves a non-modal popup to a node **unrelated** to the floating tree (ADR 0017 + * §3) — the consumer should close the popup. This is the focus-manager's focus-out close (it reads + * the shared tree), replacing the dismissal capability's focus-out at the ADR 0015 Phase-4 cutover. + */ + readonly focusOut = output(); + /** The effective trap state the composed `RdxFocusScope` reads via its config token. */ readonly trapped = computed(() => this.enabled() && this.modal()); @@ -110,11 +133,16 @@ export class RdxFloatingFocusManager { private readonly host = inject(ElementRef).nativeElement as HTMLElement; private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + /** The shared per-popup context (open / triggers / elements), if a primitive root provides one. */ + private readonly rootContext = inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }); + /** The registration handle for this node, used to read the shared tree (ancestors / descendants). */ + private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); + constructor() { effect(() => this.focusScopeConfig.trapped.set(this.trapped())); if (!this.isBrowser) { - return; // SSR: no DOM marking. + return; // SSR: no DOM marking / listeners. } // Marker pass (ADR 0017 §3) — applied to outside elements whenever the manager is **active**, @@ -134,5 +162,87 @@ export class RdxFloatingFocusManager { } onCleanup(markOthers([this.host], { ariaHidden: true, mark: false })); }); + + this.wireCloseOnFocusOut(); + } + + /** + * Close-on-focus-out (ADR 0017 §3): a **non-modal** active popup closes when focus moves to a node + * unrelated to the floating tree — not the popup, its trigger(s), a focus guard, or an ancestor / + * descendant popup — and not during a pointer press (a drag must not close it). Mirrors Base UI's + * `FloatingFocusManager` `!modal` branch (`movedToUnrelatedNode`). + */ + private wireCloseOnFocusOut(): void { + const ownerDocument = this.host.ownerDocument; + let pointerDown = false; + + const onPointerDown = (): void => { + pointerDown = true; + }; + const onPointerUp = (): void => { + pointerDown = false; + }; + const onFocusOut = (event: FocusEvent): void => { + if (!this.enabled() || !this.closeOnFocusOut() || this.modal() || pointerDown) { + return; + } + const relatedTarget = event.relatedTarget as Node | null; + if (!relatedTarget) { + return; // focus left to nothing (tab-away / window blur) — let the browser handle it + } + if (relatedTarget instanceof Element && relatedTarget.hasAttribute(FOCUS_GUARD_ATTR)) { + return; // moved onto a focus guard — still inside the focus system + } + if (this.isRelatedTargetInside(relatedTarget)) { + return; // moved to a related node (trigger / ancestor / descendant) — keep open + } + this.focusOut.emit(event); + }; + + ownerDocument.addEventListener('pointerdown', onPointerDown, true); + ownerDocument.addEventListener('pointerup', onPointerUp, true); + ownerDocument.addEventListener('focusout', onFocusOut, true); + + inject(DestroyRef).onDestroy(() => { + ownerDocument.removeEventListener('pointerdown', onPointerDown, true); + ownerDocument.removeEventListener('pointerup', onPointerUp, true); + ownerDocument.removeEventListener('focusout', onFocusOut, true); + }); + } + + /** Whether `relatedTarget` is inside the popup, its trigger(s), or an ancestor / descendant popup. */ + private isRelatedTargetInside(relatedTarget: Node): boolean { + const floating = this.rootContext?.floatingElement ?? this.host; + if (composedContains(floating, relatedTarget)) { + return true; + } + if (this.rootContext && this.contextContains(this.rootContext, relatedTarget)) { + return true; + } + + const node = this.registration?.node() ?? null; + if (node) { + for (const ancestor of node.tree.ancestors(node)) { + if (ancestor.context && this.contextContains(ancestor.context, relatedTarget)) { + return true; + } + } + for (const child of node.tree.children(node, { onlyOpen: true })) { + if (child.context && this.contextContains(child.context, relatedTarget)) { + return true; + } + } + } + return false; + } + + private contextContains(context: RdxFloatingRootContext, relatedTarget: Node): boolean { + if (context.floatingElement && composedContains(context.floatingElement, relatedTarget)) { + return true; + } + if (context.referenceElement && composedContains(context.referenceElement, relatedTarget)) { + return true; + } + return context.triggers.contains(relatedTarget); } } diff --git a/packages/primitives/focus-scope/index.ts b/packages/primitives/focus-scope/index.ts index 7b70d471..680cc013 100644 --- a/packages/primitives/focus-scope/index.ts +++ b/packages/primitives/focus-scope/index.ts @@ -1,3 +1,4 @@ export * from './src/focus-guards'; export * from './src/focus-scope'; export * from './src/focus-scope.config'; +export * from './src/utils'; From d7e2fed88998a6502b79a8505fa4e57a4c821434 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 13:17:44 +0300 Subject: [PATCH 09/35] feat(floating-focus-manager): initial-focus orchestration + interaction tracking --- .../__tests__/floating-focus-manager.spec.ts | 55 +++++++++++++++++- .../src/floating-focus-manager.ts | 57 +++++++++++++++++++ skills/radix-ng/references/api-contract.json | 14 ++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 51676e89..317b4205 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -3,7 +3,14 @@ import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FOCUS_GUARD_ATTR } from '@radix-ng/primitives/focus-scope'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { RdxFloatingFocusManager, resolveFocusTarget, resolveInitialFocus } from '../src/floating-focus-manager'; +import { + RdxFloatingFocusManager, + RdxInitialFocus, + RdxReturnFocus, + resolveFocusTarget, + resolveInitialFocus, + resolveReturnFocus +} from '../src/floating-focus-manager'; import { RDX_FLOATING_MARKER } from '../src/mark-others'; const flush = (): Promise => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); @@ -16,6 +23,8 @@ const flush = (): Promise => new Promise((resolve) => requestAnimationFram [modal]="modal()" [enabled]="enabled()" [closeOnFocusOut]="closeOnFocusOut()" + [initialFocus]="initialFocus()" + [returnFocus]="returnFocus()" rdxFloatingFocusManager > @@ -27,6 +36,8 @@ class ManagerHost { readonly modal = signal(false); readonly enabled = signal(true); readonly closeOnFocusOut = signal(true); + readonly initialFocus = signal(null); + readonly returnFocus = signal(true); readonly scope = viewChild.required('scope', { read: ElementRef }); readonly a = viewChild.required('a', { read: ElementRef }); readonly b = viewChild.required('b', { read: ElementRef }); @@ -261,6 +272,39 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(emitted).toBe(false); }); + // ─── initial / return focus orchestration (ADR 0017 §2) ────────────────── + + it('initialFocus overrides the scope default — focuses the specified element on open', async () => { + const fixture = TestBed.createComponent(ManagerHost); + // resolve B lazily (it exists by the time mountAutoFocus fires) + fixture.componentInstance.initialFocus.set(() => fixture.componentInstance.b().nativeElement); + fixture.autoDetectChanges(); + await flush(); + + expect(document.activeElement).toBe(fixture.componentInstance.b().nativeElement); + }); + + it('falls back to the scope default (first tabbable) when initialFocus is null', async () => { + const fixture = TestBed.createComponent(ManagerHost); + fixture.autoDetectChanges(); + await flush(); + + expect(document.activeElement).toBe(fixture.componentInstance.a().nativeElement); + }); + + it('tracks the interaction type (keyboard / pointer)', async () => { + const fixture = TestBed.createComponent(ManagerHost); + fixture.autoDetectChanges(); + await flush(); + const manager = fixture.componentInstance.manager(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + expect(manager.interactionType()).toBe('keyboard'); + + document.dispatchEvent(new Event('pointerdown')); + expect(manager.interactionType()).toBe('mouse'); + }); + // ─── policy resolvers (§2 contract) ─────────────────────────────────────── describe('policy resolvers', () => { @@ -283,5 +327,14 @@ describe('RdxFloatingFocusManager (skeleton)', () => { // a plain target ignores the interaction type expect(resolveInitialFocus(keyboardEl, 'mouse')).toBe(keyboardEl); }); + + it('resolveReturnFocus passes through booleans and resolves targets', () => { + const el = document.createElement('button'); + expect(resolveReturnFocus(true, '')).toBe(true); + expect(resolveReturnFocus(false, '')).toBe(false); + expect(resolveReturnFocus(el, '')).toBe(el); + expect(resolveReturnFocus(() => false, 'keyboard')).toBe(false); + expect(resolveReturnFocus((type) => (type === 'keyboard' ? el : false), 'keyboard')).toBe(el); + }); }); }); diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index d17046c0..81c28f34 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -64,6 +64,18 @@ export function resolveInitialFocus( return resolveFocusTarget(typeof policy === 'function' ? policy(openInteractionType) : policy); } +/** + * Resolves an {@link RdxReturnFocus} policy against how the popup closed. `false` = do not return focus; + * `true` = the default (return to the previously-focused element); an element = return there. + */ +export function resolveReturnFocus( + policy: RdxReturnFocus, + closeInteractionType: RdxInteractionType +): HTMLElement | boolean | null { + const resolved = typeof policy === 'function' ? policy(closeInteractionType) : policy; + return typeof resolved === 'boolean' ? resolved : resolveFocusTarget(resolved); +} + /** * Provides a {@link RdxFocusScopeConfig} whose `trapped` is a **writable** signal, so the enclosing * {@link RdxFloatingFocusManager} can drive it from its `modal`/`enabled` policy after construction. The @@ -138,6 +150,10 @@ export class RdxFloatingFocusManager { /** The registration handle for this node, used to read the shared tree (ancestors / descendants). */ private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); + private readonly _interactionType = signal(''); + /** How the popup was most recently interacted with — fed to the initial/return focus policy callbacks. */ + readonly interactionType = this._interactionType.asReadonly(); + constructor() { effect(() => this.focusScopeConfig.trapped.set(this.trapped())); @@ -163,7 +179,48 @@ export class RdxFloatingFocusManager { onCleanup(markOthers([this.host], { ariaHidden: true, mark: false })); }); + this.trackInteractionType(); this.wireCloseOnFocusOut(); + this.wireFocusOrchestration(); + } + + /** Records the most recent open/close interaction (pointer type or keyboard) for the focus policies. */ + private trackInteractionType(): void { + const ownerDocument = this.host.ownerDocument; + const onPointer = (event: Event): void => { + this._interactionType.set(((event as PointerEvent).pointerType || 'mouse') as RdxInteractionType); + }; + const onKey = (): void => this._interactionType.set('keyboard'); + + ownerDocument.addEventListener('pointerdown', onPointer, true); + ownerDocument.addEventListener('keydown', onKey, true); + inject(DestroyRef).onDestroy(() => { + ownerDocument.removeEventListener('pointerdown', onPointer, true); + ownerDocument.removeEventListener('keydown', onKey, true); + }); + } + + /** + * Initial-focus orchestration (ADR 0017 §2). The manager owns the focus *policy*; it intercepts the + * composed {@link RdxFocusScope}'s preventable `mountAutoFocus` (its designed extension point) and + * applies the `initialFocus` policy, falling back to the scope's first-tabbable default when the + * policy is `null`. + * + * @remarks `returnFocus` is **typed** ({@link resolveReturnFocus}) but not orchestrated here: + * overriding the scope's return-focus would mean intercepting `unmountAutoFocus`, which fires during + * teardown after the output subscription is already torn down (unreliable). A robust `returnFocus` + * override is deferred to the Phase-4 integration, where the manager can own return-focus directly. + */ + private wireFocusOrchestration(): void { + const focusScope = inject(RdxFocusScope); + + focusScope.mountAutoFocus.subscribe((event) => { + const target = resolveInitialFocus(this.initialFocus(), this._interactionType()); + if (target) { + event.preventDefault(); // override the scope's first-tabbable default + target.focus(); + } + }); } /** diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index 7bd3d2dc..673760a6 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -3035,6 +3035,12 @@ "RdxFocusScope" ], "inputs": [ + { + "name": "closeOnFocusOut", + "type": "boolean", + "default": "true", + "description": "Whether a **non-modal** popup closes when focus leaves to an unrelated node (Base UI\n`closeOnFocusOut`, default `true`; Dialog sets it to `!disablePointerDismissal`). Modal popups\nnever close on focus-out (the trap keeps focus in)." + }, { "name": "enabled", "type": "boolean", @@ -3060,7 +3066,13 @@ "description": "Where focus returns when the popup closes (ADR 0017 §2)." } ], - "outputs": [] + "outputs": [ + { + "name": "focusOut", + "type": "FocusEvent", + "description": "Emitted when focus leaves a non-modal popup to a node **unrelated** to the floating tree (ADR 0017\n§3) — the consumer should close the popup. This is the focus-manager's focus-out close (it reads\nthe shared tree), replacing the dismissal capability's focus-out at the ADR 0015 Phase-4 cutover." + } + ] } ] }, From aebcf730c43f0a4a61d8c1ed098e804c30b1a3f3 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 13:25:25 +0300 Subject: [PATCH 10/35] feat(focus-scope): tab-order navigation helpers for the portal-focus bridge --- .../__tests__/tabbable-navigation.spec.ts | 67 +++++++++++++++++++ packages/primitives/focus-scope/src/utils.ts | 55 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 packages/primitives/focus-scope/__tests__/tabbable-navigation.spec.ts diff --git a/packages/primitives/focus-scope/__tests__/tabbable-navigation.spec.ts b/packages/primitives/focus-scope/__tests__/tabbable-navigation.spec.ts new file mode 100644 index 00000000..f384ef53 --- /dev/null +++ b/packages/primitives/focus-scope/__tests__/tabbable-navigation.spec.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getNextTabbable, getPreviousTabbable, getTabbableAfterElement, getTabbableBeforeElement } from '../src/utils'; + +/** + * The tab-order navigation helpers (Base UI `tabbable.ts`) walk the whole document ``, so each + * test builds the body's tabbable set explicitly and tears it down. They power the portal-focus bridge's + * guard redirects. + */ +describe('tabbable navigation', () => { + let one: HTMLButtonElement; + let two: HTMLButtonElement; + let three: HTMLButtonElement; + + beforeEach(() => { + [one, two, three] = ['one', 'two', 'three'].map((id) => { + const button = document.createElement('button'); + button.id = id; + document.body.appendChild(button); + return button; + }); + }); + + afterEach(() => { + [one, two, three].forEach((button) => button.remove()); + }); + + describe('getNextTabbable / getPreviousTabbable (relative to the active element)', () => { + it('returns the tabbable after the focused element', () => { + two.focus(); + expect(getNextTabbable(two)).toBe(three); + }); + + it('returns the tabbable before the focused element', () => { + two.focus(); + expect(getPreviousTabbable(two)).toBe(one); + }); + + it('falls back to the reference when there is no next/previous', () => { + three.focus(); + expect(getNextTabbable(three)).toBe(three); // already last → fall back + one.focus(); + expect(getPreviousTabbable(one)).toBe(one); // already first → fall back + }); + }); + + describe('getTabbableAfterElement / getTabbableBeforeElement (relative to a reference, wrapping)', () => { + it('returns the tabbable immediately after the reference', () => { + expect(getTabbableAfterElement(one)).toBe(two); + }); + + it('returns the tabbable immediately before the reference', () => { + expect(getTabbableBeforeElement(two)).toBe(one); + }); + + it('wraps around the ends', () => { + expect(getTabbableAfterElement(three)).toBe(one); // last → first + expect(getTabbableBeforeElement(one)).toBe(three); // first → last + }); + + it('returns null for a reference that is not tabbable / not present', () => { + const detached = document.createElement('button'); + expect(getTabbableAfterElement(detached)).toBeNull(); + expect(getTabbableAfterElement(null)).toBeNull(); + }); + }); +}); diff --git a/packages/primitives/focus-scope/src/utils.ts b/packages/primitives/focus-scope/src/utils.ts index fe367c3b..03138f2c 100644 --- a/packages/primitives/focus-scope/src/utils.ts +++ b/packages/primitives/focus-scope/src/utils.ts @@ -108,6 +108,61 @@ export function getTabbableEdges(container: HTMLElement) { return [first, last] as const; } +/** Visible tabbable elements of `root` in document order (the basis for tab-order navigation). */ +function visibleTabbablesIn(root: HTMLElement): HTMLElement[] { + return getTabbableCandidates(root).filter((el) => !isHidden(el, { upTo: root })); +} + +/** The tabbable one step (`dir`) from the document's active element, within `container`. */ +function getTabbableIn(container: HTMLElement, dir: 1 | -1): HTMLElement | undefined { + const list = visibleTabbablesIn(container); + if (list.length === 0) { + return undefined; + } + const active = getActiveElement(container.ownerDocument) as HTMLElement | null; + const index = active ? list.indexOf(active) : -1; + const nextIndex = index === -1 ? (dir === 1 ? 0 : list.length - 1) : index + dir; + return list[nextIndex]; +} + +/** + * The next tabbable in the document after the current focus (Base UI `getNextTabbable`) — used by the + * portal-focus bridge's trailing guard to step focus past the popup. Falls back to `reference`. + */ +export function getNextTabbable(reference: Element | null): HTMLElement | null { + const body = (reference?.ownerDocument ?? document).body; + return getTabbableIn(body, 1) ?? (reference as HTMLElement | null); +} + +/** The previous tabbable in the document before the current focus (Base UI `getPreviousTabbable`). */ +export function getPreviousTabbable(reference: Element | null): HTMLElement | null { + const body = (reference?.ownerDocument ?? document).body; + return getTabbableIn(body, -1) ?? (reference as HTMLElement | null); +} + +/** The tabbable `dir` steps from `reference` in the document, wrapping around. */ +function getTabbableNearElement(reference: Element | null, dir: 1 | -1): HTMLElement | null { + if (!reference) { + return null; + } + const list = visibleTabbablesIn(reference.ownerDocument.body); + const index = list.indexOf(reference as HTMLElement); + if (list.length === 0 || index === -1) { + return null; + } + return list[(index + dir + list.length) % list.length]; +} + +/** The tabbable immediately after `reference` in the document, wrapping (Base UI `getTabbableAfterElement`). */ +export function getTabbableAfterElement(reference: Element | null): HTMLElement | null { + return getTabbableNearElement(reference, 1); +} + +/** The tabbable immediately before `reference` in the document, wrapping (Base UI `getTabbableBeforeElement`). */ +export function getTabbableBeforeElement(reference: Element | null): HTMLElement | null { + return getTabbableNearElement(reference, -1); +} + export function isSelectableInput(element: any): element is FocusableTarget & { select: () => void } { return element instanceof HTMLInputElement && 'select' in element; } From 38e151f9825163533f9e59107ca5ec73d782b2f2 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 14:01:12 +0300 Subject: [PATCH 11/35] =?UTF-8?q?feat(dialog):=20provide=20floating=20tree?= =?UTF-8?q?=20+=20root=20context=20=E2=80=94=20migration=20groundwork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/primitives/dialog/src/dialog-root.ts | 30 ++++++++++- .../__tests__/floating-focus-manager.spec.ts | 33 ++++++++++++ .../src/floating-focus-manager.ts | 50 ++++++++++++++++--- skills/radix-ng/references/api-contract.json | 9 ++-- 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/packages/primitives/dialog/src/dialog-root.ts b/packages/primitives/dialog/src/dialog-root.ts index aa891977..4e17ad63 100644 --- a/packages/primitives/dialog/src/dialog-root.ts +++ b/packages/primitives/dialog/src/dialog-root.ts @@ -4,6 +4,7 @@ import { DestroyRef, Directive, effect, + ElementRef, inject, input, model, @@ -15,7 +16,11 @@ import { import { BooleanInput, createContext, + createFloatingRootContext, injectId, + provideFloatingRootContext, + provideFloatingTree, + RdxFloatingRootContext, RdxTransitionStatus, useTransitionStatus } from '@radix-ng/primitives/core'; @@ -96,7 +101,13 @@ export const [injectRdxDialogRootContext, provideRdxDialogRootContext] = createC @Directive({ selector: '[rdxDialogRoot]', exportAs: 'rdxDialogRoot', - providers: [provideRdxDialogRootContext(context)] + providers: [ + provideRdxDialogRootContext(context), + // New floating foundation (ADR 0015/0017 migration). Inherit-or-create tree so a nested dialog + // shares its parent's tree; the per-popup root context bridges open / triggers / reference. + provideFloatingTree(), + provideFloatingRootContext(() => inject(RdxDialogRoot).floatingContext) + ] }) export class RdxDialogRoot { private readonly destroyRef = inject(DestroyRef); @@ -180,7 +191,20 @@ export class RdxDialogRoot { () => this.disablePointerDismissal() || this.variant.forcePointerDismissalDisabled ); + /** + * The shared per-popup floating context (ADR 0015 §1) — `open` mirrors the dialog's open state, the + * trigger registry is bridged from {@link registerTrigger}, and the reference / floating elements are + * set by the trigger / popup. The new dismissal + focus engines read this once the popup migrates. + */ + readonly floatingContext: RdxFloatingRootContext = createFloatingRootContext({ + ownerDocument: inject(ElementRef).nativeElement.ownerDocument, + open: () => this.open() + }); + constructor() { + // Keep the floating context's reference element in sync with the active trigger. + effect(() => this.floatingContext.setReferenceElement(this.trigger() ?? null)); + let previousOpen = this.open(); effect(() => { @@ -283,6 +307,9 @@ export class RdxDialogRoot { registerTrigger(id: string, trigger: HTMLElement, payload: () => unknown) { this.registeredTriggers.set(id, { element: trigger, payload }); this.triggers.update((triggers) => (triggers.includes(trigger) ? triggers : [...triggers, trigger])); + // Bridge into the floating context's trigger registry — the new dismissal/focus engines read it + // for inside-element checks (a press/focus on the trigger counts as inside, ADR 0015 §2). + this.floatingContext.triggers.add(trigger); if (this.triggerId() === id || (!this.trigger() && this.triggerId() === null)) { this.trigger.set(trigger); @@ -295,6 +322,7 @@ export class RdxDialogRoot { } this.triggers.update((triggers) => triggers.filter((candidate) => candidate !== trigger)); + this.floatingContext.triggers.delete(trigger); if (!this.destroyRef.destroyed && this.trigger() === trigger) { const next = this.registeredTriggers.entries().next().value; diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 317b4205..4e49ff55 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { FOCUS_GUARD_ATTR } from '@radix-ng/primitives/focus-scope'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + provideFloatingFocusManagerConfig, RdxFloatingFocusManager, RdxInitialFocus, RdxReturnFocus, @@ -125,6 +126,38 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(document.activeElement).toBe(inside); }); + it('a primitive config drives the gates when the matching input is unset', async () => { + @Component({ + imports: [RdxFloatingFocusManager], + providers: [provideFloatingFocusManagerConfig(() => ({ modal: () => true }))], + template: ` +
+ +
+ ` + }) + class ConfigHost { + readonly scope = viewChild.required('scope', { read: ElementRef }); + readonly a = viewChild.required('a', { read: ElementRef }); + } + + const outside = document.createElement('button'); + document.body.appendChild(outside); + appended.push(outside); + + const fixture = TestBed.createComponent(ConfigHost); + fixture.autoDetectChanges(); + await flush(); + + const inside = fixture.componentInstance.a().nativeElement as HTMLElement; + inside.focus(); + outside.focus(); + + // config `modal: () => true` (no `[modal]` input) → the manager traps focus + expect(document.activeElement).toBe(inside); + outside.remove(); + }); + // ─── markOthers passes (ADR 0017 §3) ───────────────────────────────────── it('marks outside elements while active, but does NOT aria-hide them when non-modal', async () => { diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index 81c28f34..dbe714c6 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -7,6 +7,7 @@ import { effect, ElementRef, inject, + InjectionToken, input, output, PLATFORM_ID, @@ -76,6 +77,31 @@ export function resolveReturnFocus( return typeof resolved === 'boolean' ? resolved : resolveFocusTarget(resolved); } +/** + * DI seam for a **composing primitive** (Dialog / Popover / Menu) to drive the manager's gates from its + * own root context instead of template-bound inputs (which a primitive cannot set). Each field falls + * back to the manager's input, then to the documented default. Mirrors {@link RdxFocusScopeConfig}. + */ +export interface RdxFloatingFocusManagerConfig { + modal?: () => boolean; + enabled?: () => boolean; + closeOnFocusOut?: () => boolean; +} + +export const RDX_FLOATING_FOCUS_MANAGER_CONFIG = new InjectionToken( + 'RdxFloatingFocusManagerConfig' +); + +/** Provides a {@link RdxFloatingFocusManagerConfig} for an enclosing primitive's focus manager. */ +export function provideFloatingFocusManagerConfig(factory: () => RdxFloatingFocusManagerConfig): Provider { + return { provide: RDX_FLOATING_FOCUS_MANAGER_CONFIG, useFactory: factory }; +} + +/** Coerces a boolean-ish input while preserving `undefined` ("not set" → fall back to the config). */ +function coerceOptionalBoolean(value: BooleanInput | undefined): boolean | undefined { + return value === undefined ? undefined : booleanAttribute(value); +} + /** * Provides a {@link RdxFocusScopeConfig} whose `trapped` is a **writable** signal, so the enclosing * {@link RdxFloatingFocusManager} can drive it from its `modal`/`enabled` policy after construction. The @@ -111,10 +137,10 @@ function provideManagedFocusScopeConfig(): Provider { }) export class RdxFloatingFocusManager { /** Manager active-ness (ADR 0017 §2): the popup is mounted **and** not hover-opened. */ - readonly enabled = input(true, { transform: booleanAttribute }); + readonly enabled = input(undefined, { transform: coerceOptionalBoolean }); /** Modal popup → focus trap. Combined with `enabled` to drive the composed `RdxFocusScope`. */ - readonly modal = input(false, { transform: booleanAttribute }); + readonly modal = input(undefined, { transform: coerceOptionalBoolean }); /** Where focus goes when the popup opens (ADR 0017 §2). */ readonly initialFocus = input(null); @@ -127,7 +153,7 @@ export class RdxFloatingFocusManager { * `closeOnFocusOut`, default `true`; Dialog sets it to `!disablePointerDismissal`). Modal popups * never close on focus-out (the trap keeps focus in). */ - readonly closeOnFocusOut = input(true, { transform: booleanAttribute }); + readonly closeOnFocusOut = input(undefined, { transform: coerceOptionalBoolean }); /** * Emitted when focus leaves a non-modal popup to a node **unrelated** to the floating tree (ADR 0017 @@ -136,8 +162,18 @@ export class RdxFloatingFocusManager { */ readonly focusOut = output(); + /** Optional DI config a composing primitive provides to drive the gates (input wins over config). */ + private readonly config = inject(RDX_FLOATING_FOCUS_MANAGER_CONFIG, { optional: true }); + + /** Effective gates: `input ?? config ?? default`. */ + readonly effectiveEnabled = computed(() => this.enabled() ?? this.config?.enabled?.() ?? true); + readonly effectiveModal = computed(() => this.modal() ?? this.config?.modal?.() ?? false); + readonly effectiveCloseOnFocusOut = computed( + () => this.closeOnFocusOut() ?? this.config?.closeOnFocusOut?.() ?? true + ); + /** The effective trap state the composed `RdxFocusScope` reads via its config token. */ - readonly trapped = computed(() => this.enabled() && this.modal()); + readonly trapped = computed(() => this.effectiveEnabled() && this.effectiveModal()); // The config this directive provides — its `trapped` signal is writable so we can drive it. private readonly focusScopeConfig = inject(RdxFocusScopeConfigToken) as { trapped: WritableSignal }; @@ -164,7 +200,7 @@ export class RdxFloatingFocusManager { // Marker pass (ADR 0017 §3) — applied to outside elements whenever the manager is **active**, // independent of `modal`. Read by ADR 0015's outside-press guard. effect((onCleanup) => { - if (!this.enabled()) { + if (!this.effectiveEnabled()) { return; } onCleanup(markOthers([this.host], { ariaHidden: false, mark: true })); @@ -173,7 +209,7 @@ export class RdxFloatingFocusManager { // Accessibility-isolation pass (ADR 0017 §3) — `aria-hidden` outside elements, but **only** for a // modal (later: typeable-combobox) popup, so Select/Menu-root get none. effect((onCleanup) => { - if (!this.enabled() || !this.modal()) { + if (!this.effectiveEnabled() || !this.effectiveModal()) { return; } onCleanup(markOthers([this.host], { ariaHidden: true, mark: false })); @@ -240,7 +276,7 @@ export class RdxFloatingFocusManager { pointerDown = false; }; const onFocusOut = (event: FocusEvent): void => { - if (!this.enabled() || !this.closeOnFocusOut() || this.modal() || pointerDown) { + if (!this.effectiveEnabled() || !this.effectiveCloseOnFocusOut() || this.effectiveModal() || pointerDown) { return; } const relatedTarget = event.relatedTarget as Node | null; diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index 673760a6..cbdf32cb 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -3037,14 +3037,12 @@ "inputs": [ { "name": "closeOnFocusOut", - "type": "boolean", - "default": "true", + "default": "undefined", "description": "Whether a **non-modal** popup closes when focus leaves to an unrelated node (Base UI\n`closeOnFocusOut`, default `true`; Dialog sets it to `!disablePointerDismissal`). Modal popups\nnever close on focus-out (the trap keeps focus in)." }, { "name": "enabled", - "type": "boolean", - "default": "true", + "default": "undefined", "description": "Manager active-ness (ADR 0017 §2): the popup is mounted **and** not hover-opened." }, { @@ -3055,8 +3053,7 @@ }, { "name": "modal", - "type": "boolean", - "default": "false", + "default": "undefined", "description": "Modal popup → focus trap. Combined with `enabled` to drive the composed `RdxFocusScope`." }, { From 18bf891f5d769a9a8952963df1209c5c037457d8 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 14:20:44 +0300 Subject: [PATCH 12/35] chore: upd wip commit --- .../tests/dialog.behavior.spec.ts | 98 ++++++++++ .../primitives/dialog/src/dialog-popup.ts | 178 +++++++++--------- skills/radix-ng/references/api-contract.json | 29 ++- 3 files changed, 213 insertions(+), 92 deletions(-) diff --git a/apps/visual-regression/tests/dialog.behavior.spec.ts b/apps/visual-regression/tests/dialog.behavior.spec.ts index e9338e10..813ae4b5 100644 --- a/apps/visual-regression/tests/dialog.behavior.spec.ts +++ b/apps/visual-regression/tests/dialog.behavior.spec.ts @@ -132,6 +132,104 @@ test.describe('Dialog outside-scroll (custom scroll area)', () => { }); }); +/** + * ⚠️ **NOT YET VERIFIED — ADR 0015/0017 Phase-4 Dialog migration onto the new floating engine.** These + * are the browser checks the migrated `RdxDialogPopup` must pass before merge (jsdom cannot exercise + * focus trap / live focus / focus-out / aria-hidden). Run with `pnpm test-visual` (or the local loop + * against `:4400`). Some tests below intentionally encode **known gaps** in the first-cut wiring — see + * the `KNOWN GAP` notes; they are expected to FAIL until the wiring is fixed in the verification session. + */ +test.describe('Dialog — new floating engine migration', () => { + const closeButton = '[rdxDialogClose][aria-label="Close"]'; + + const focusInsidePopup = (page: Page) => + page.locator(popup).evaluate((el) => el.contains(el.ownerDocument.activeElement)); + + test('modal dialog moves focus into the popup on open', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + expect(await focusInsidePopup(page)).toBe(true); + }); + + test('modal dialog traps focus — Tab keeps focus inside the popup', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // Tab repeatedly: focus must cycle within the popup, never escaping to the trigger / page. + for (let i = 0; i < 8; i++) { + await page.keyboard.press('Tab'); + expect(await focusInsidePopup(page)).toBe(true); + } + }); + + test('returns focus to the trigger after closing', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + const triggerEl = page.locator(trigger).first(); + await triggerEl.click(); + await expect(page.locator(popup)).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + + await expect(triggerEl).toBeFocused(); + }); + + test('Escape and outside-press still close (dismissal regression)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + await page.locator(closeButton).click(); + await expect(page.locator(popup)).toHaveCount(0); + }); + + test('non-modal dialog closes when focus leaves to an unrelated element', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--non-modal'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // Tab focus out of the popup to a page element unrelated to the dialog → focus-out close (§3). + await page.locator('body').press('Tab'); + await page.evaluate(() => (document.activeElement as HTMLElement)?.blur()); + // (Browser session: assert the dialog closes once focus lands on an unrelated tabbable.) + await expect(page.locator(popup)).toHaveCount(0); + }); + + test('nested dialog: Escape closes only the inner dialog (deepest-first ownership)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--nested'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup).first()).toBeVisible(); + + // Open the nested dialog from inside the first. + await page.locator(popup).first().locator(trigger).first().click(); + await expect(page.locator(popup)).toHaveCount(2); + + // Escape closes the deepest (inner) layer only — the outer stays open. + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(1); + }); + + test('KNOWN GAP: the dialog backdrop must NOT be aria-hidden/marked (avoid set misses it)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // The backdrop is a second portal root (sibling of the popup), so the manager's + // markOthers avoid set = [popup] wrongly marks it. EXPECTED TO FAIL until the avoid set + // includes every dialog root (backdrop + popup). + await expect(page.locator(backdrop)).not.toHaveAttribute('aria-hidden', 'true'); + await expect(page.locator(backdrop)).not.toHaveAttribute('data-rdx-floating-inert', ''); + }); +}); + /** * The "uncontained" story renders the close button outside the visible content card while keeping it * inside the popup (and thus inside the focus trap). Guards the layout (close above the card) and that diff --git a/packages/primitives/dialog/src/dialog-popup.ts b/packages/primitives/dialog/src/dialog-popup.ts index 08868390..4752d56c 100644 --- a/packages/primitives/dialog/src/dialog-popup.ts +++ b/packages/primitives/dialog/src/dialog-popup.ts @@ -1,30 +1,62 @@ -import { computed, DestroyRef, Directive, ElementRef, inject } from '@angular/core'; +import { computed, DestroyRef, Directive, ElementRef, inject, output } from '@angular/core'; import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop'; -import { useScrollLock } from '@radix-ng/primitives/core'; -import { provideRdxDismissableLayerConfig, RdxDismissableLayer } from '@radix-ng/primitives/dismissable-layer'; -import { provideRdxFocusScopeConfig, RdxFocusScope } from '@radix-ng/primitives/focus-scope'; -import { injectRdxDialogRootContext, RdxDialogOpenChangeReason } from './dialog-root'; +import { + RDX_FLOATING_REGISTRATION, + RDX_FLOATING_ROOT_CONTEXT, + RdxFloatingNodeRegistration, + useBodyPointerEventsLock, + useScrollLock +} from '@radix-ng/primitives/core'; +import { RdxDismissableCapability } from '@radix-ng/primitives/dismissable-layer'; +import { + provideFloatingFocusManagerConfig, + RdxFloatingFocusManager +} from '@radix-ng/primitives/floating-focus-manager'; +import { RdxFocusScope } from '@radix-ng/primitives/focus-scope'; +import { injectRdxDialogRootContext } from './dialog-root'; /** * A container for the dialog contents. + * + * ⚠️ **NOT VERIFIED — ADR 0015/0017 Phase-4 migration (browser run required before merge).** This is the + * Dialog cutover onto the new floating dismissal + focus engine. **jsdom cannot validate it** (no real + * layout / focus / pointer), so the behaviors below are unproven until exercised in `apps/visual-regression` + * (Playwright). Do **not** merge on a green `dialog.spec.ts` alone. + * + * **Mapping (legacy → new):** + * - `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) + + * `RdxDismissableCapability` (Escape / outside-press; reads the root context + node). + * - `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap + + * markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`. + * - `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`. + * - focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager + * (`manager.focusOut`), per ADR 0017 §3. + * - `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine + * treats a press/focus on it as **inside** (no close-then-reopen). + * + * **Nuances to verify in the browser / AT (flagged):** + * 1. `enabled: isOpen()` releases the trap at close-start; legacy held it until unmount (the + * closed-but-mounted exit window — ADR §1). Confirm no focus jump during the exit animation. + * 2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal), while `aria-modal` is set + * only for `modal === true`. Verify AT behavior / whether to split these. + * 3. `markOthers` is NEW for Dialog — verify no double / conflicting `aria-hidden`. + * 4. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used. + * 5. `pointerDownOutside` no longer fires for presses on the trigger (now inside) — minor API shift. + * 6. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive + * nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full cutover. */ @Directive({ selector: '[rdxDialogPopup]', exportAs: 'rdxDialogPopup', - hostDirectives: [RdxDismissableLayer, RdxFocusScope], + hostDirectives: [RdxFloatingNodeRegistration, RdxFloatingFocusManager], providers: [ - provideRdxDismissableLayerConfig(() => { + provideFloatingFocusManagerConfig(() => { const rootContext = injectRdxDialogRootContext(); - - return { - disableOutsidePointerEvents: computed(() => rootContext.modal() === true) - }; - }), - provideRdxFocusScopeConfig(() => { - const rootContext = injectRdxDialogRootContext(); - return { - trapped: computed(() => rootContext.modal() === 'trap-focus' || rootContext.modal() === true) + // Trap for a modal or trap-focus dialog; off when the dialog is closed (still mounted). + modal: () => rootContext.modal() === true || rootContext.modal() === 'trap-focus', + enabled: () => rootContext.isOpen(), + closeOnFocusOut: () => !rootContext.disablePointerDismissal() }; }) ], @@ -45,97 +77,67 @@ import { injectRdxDialogRootContext, RdxDialogOpenChangeReason } from './dialog- }) export class RdxDialogPopup { protected readonly rootContext = injectRdxDialogRootContext(); - private readonly dismissableLayer = inject(RdxDismissableLayer); + private readonly host = inject>(ElementRef).nativeElement; + private readonly floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT); + private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); + private readonly focusManager = inject(RdxFloatingFocusManager); private readonly focusScope = inject(RdxFocusScope); - private dismissDetails: { reason: RdxDialogOpenChangeReason; event: Event } = { - reason: 'none', - event: new Event('dialog.dismiss') - }; - /** - * Event handler called when the escape key is down. Can be prevented. - */ - readonly escapeKeyDown = outputFromObservable(outputToObservable(this.dismissableLayer.escapeKeyDown)); + /** Event handler called when the escape key is down. Can be prevented. */ + readonly escapeKeyDown = output(); - /** - * Event handler called when a pointerdown event happens outside of the popup. Can be prevented. - */ - readonly pointerDownOutside = outputFromObservable(outputToObservable(this.dismissableLayer.pointerDownOutside)); + /** Event handler called when a pointerdown event happens outside of the popup. Can be prevented. */ + readonly pointerDownOutside = output(); - /** - * Event handler called when focus moves outside of the popup. Can be prevented. - */ - readonly focusOutside = outputFromObservable(outputToObservable(this.dismissableLayer.focusOutside)); + /** Event handler called when focus moves outside of the popup. Can be prevented. */ + readonly focusOutside = output(); - /** - * Event handler called when an interaction happens outside of the popup. Can be prevented. - */ - readonly interactOutside = outputFromObservable(outputToObservable(this.dismissableLayer.interactOutside)); + /** Event handler called when an interaction (pointer / focus) happens outside of the popup. */ + readonly interactOutside = output(); - /** - * Event handler called before focus moves into the popup. Can be prevented. - */ + /** Event handler called before focus moves into the popup. Can be prevented. */ readonly openAutoFocus = outputFromObservable(outputToObservable(this.focusScope.mountAutoFocus)); - /** - * Event handler called before focus returns after the popup is removed. Can be prevented. - */ + /** Event handler called before focus returns after the popup is removed. Can be prevented. */ readonly closeAutoFocus = outputFromObservable(outputToObservable(this.focusScope.unmountAutoFocus)); constructor() { - // Lock for the popup's whole mounted lifetime, not just while `isOpen()`. The popup is kept - // mounted by the portal-presence machine until the exit `@keyframes` finish, then destroyed - // (firing `useScrollLock`'s `onDestroy` unlock). Gating on `isOpen()` would restore the page - // scrollbar the instant the dialog starts closing, reflowing the page by the scrollbar width - // mid-animation — a visible judder. Holding the lock until unmount defers that to after the - // animation, when the dialog is already gone. - useScrollLock(computed(() => this.rootContext.modal() === true)); + // The popup element is this layer's floating element (inside-surface for containment checks). + this.floatingContext.setFloatingElement(this.host); - const unregisterTransitionElement = this.rootContext.registerTransitionElement( - inject>(ElementRef).nativeElement - ); + // Lock scroll / outside pointer events for a full modal, held for the whole mounted lifetime (not + // just while open) so the page doesn't reflow by the scrollbar width mid-exit-animation. + const isFullyModal = computed(() => this.rootContext.modal() === true); + useScrollLock(isFullyModal); + useBodyPointerEventsLock(isFullyModal); + const unregisterTransitionElement = this.rootContext.registerTransitionElement(this.host); inject(DestroyRef).onDestroy(unregisterTransitionElement); - this.dismissableLayer.pointerDownOutside.subscribe((event) => { - this.dismissDetails = { reason: 'outside-press', event }; - - // A pointerdown on the trigger is an "outside" press relative to the portaled popup. - // Let the trigger's own click toggle the dialog instead of dismissing here (which would - // close and then immediately reopen). - if (this.isEventOnTrigger(event)) { - event.preventDefault(); - } - }); - - this.dismissableLayer.focusOutside.subscribe((event) => { - this.dismissDetails = { reason: 'focus-out', event }; - - if (this.isEventOnTrigger(event)) { - event.preventDefault(); + // Dismissal: Escape always closes; outside-press only when pointer dismissal is enabled. Focus-out + // is owned by the focus manager (below), so the capability's own focus-out is disabled. + new RdxDismissableCapability(this.floatingContext, () => this.registration?.node() ?? null, { + escapeKey: () => true, + outsidePress: () => !this.rootContext.disablePointerDismissal(), + focusOutside: () => false, + onEscapeKeyDown: (event) => this.escapeKeyDown.emit(event), + onPointerDownOutside: (event) => { + this.pointerDownOutside.emit(event); + this.interactOutside.emit(event); + }, + onDismiss: (reason, event) => { + this.rootContext.close(reason === 'escape-key' ? 'escape-key' : 'outside-press', event); } }); - this.dismissableLayer.escapeKeyDown.subscribe((event) => { - this.dismissDetails = { reason: 'escape-key', event }; - }); - - this.dismissableLayer.dismiss.subscribe(() => { - const { reason, event } = this.dismissDetails; - this.dismissDetails = { reason: 'none', event: new Event('dialog.dismiss') }; - - // When pointer dismissal is disabled, keep the dialog open on outside interactions. - // Escape always closes (standard a11y behavior). - if ((reason === 'outside-press' || reason === 'focus-out') && this.rootContext.disablePointerDismissal()) { - return; + // Focus-out close (ADR 0017 §3) — the manager emits when focus leaves a non-modal dialog to an + // unrelated node; re-expose as `focusOutside` (preventable) and close unless vetoed. + this.focusManager.focusOut.subscribe((event) => { + this.focusOutside.emit(event); + this.interactOutside.emit(event); + if (!event.defaultPrevented) { + this.rootContext.close('focus-out', event); } - - this.rootContext.close(reason, event); }); } - - private isEventOnTrigger(event: Event): boolean { - const target = event.target as Node | null; - return !!target && this.rootContext.triggers().some((trigger) => trigger.contains(target)); - } } diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index cbdf32cb..bb27af6f 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -2273,13 +2273,34 @@ "directive": "RdxDialogPopup", "selector": "[rdxDialogPopup]", "exportAs": "rdxDialogPopup", - "description": "A container for the dialog contents.", + "description": "A container for the dialog contents.\n\n⚠️ **NOT VERIFIED — ADR 0015/0017 Phase-4 migration (browser run required before merge).** This is the\nDialog cutover onto the new floating dismissal + focus engine. **jsdom cannot validate it** (no real\nlayout / focus / pointer), so the behaviors below are unproven until exercised in `apps/visual-regression`\n(Playwright). Do **not** merge on a green `dialog.spec.ts` alone.\n\n**Mapping (legacy → new):**\n- `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) +\n `RdxDismissableCapability` (Escape / outside-press; reads the root context + node).\n- `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap +\n markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`.\n- `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`.\n- focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager\n (`manager.focusOut`), per ADR 0017 §3.\n- `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine\n treats a press/focus on it as **inside** (no close-then-reopen).\n\n**Nuances to verify in the browser / AT (flagged):**\n1. `enabled: isOpen()` releases the trap at close-start; legacy held it until unmount (the\n closed-but-mounted exit window — ADR §1). Confirm no focus jump during the exit animation.\n2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal), while `aria-modal` is set\n only for `modal === true`. Verify AT behavior / whether to split these.\n3. `markOthers` is NEW for Dialog — verify no double / conflicting `aria-hidden`.\n4. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used.\n5. `pointerDownOutside` no longer fires for presses on the trigger (now inside) — minor API shift.\n6. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive\n nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full cutover.", "hostDirectives": [ - "RdxDismissableLayer", - "RdxFocusScope" + "RdxFloatingNodeRegistration", + "RdxFloatingFocusManager" ], "inputs": [], - "outputs": [] + "outputs": [ + { + "name": "escapeKeyDown", + "type": "KeyboardEvent", + "description": "Event handler called when the escape key is down. Can be prevented." + }, + { + "name": "focusOutside", + "type": "FocusEvent", + "description": "Event handler called when focus moves outside of the popup. Can be prevented." + }, + { + "name": "interactOutside", + "type": "PointerEvent | FocusEvent", + "description": "Event handler called when an interaction (pointer / focus) happens outside of the popup." + }, + { + "name": "pointerDownOutside", + "type": "PointerEvent", + "description": "Event handler called when a pointerdown event happens outside of the popup. Can be prevented." + } + ] }, { "directive": "RdxDialogPortal", From 70e085d1d632d1c667984c1ca6cf473df9fb9981 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 16:53:05 +0300 Subject: [PATCH 13/35] fix(floating): markOthers keeps all owned floating roots, not just the host --- .../tests/dialog.behavior.spec.ts | 7 ++-- .../core/__tests__/floating-tree.spec.ts | 21 ++++++++++++ .../src/floating/floating-root-context.ts | 30 ++++++++++++++++- .../primitives/dialog/src/dialog-backdrop.ts | 15 ++++++++- .../__tests__/floating-focus-manager.spec.ts | 32 +++++++++++++++++++ .../src/floating-focus-manager.ts | 14 ++++++-- 6 files changed, 111 insertions(+), 8 deletions(-) diff --git a/apps/visual-regression/tests/dialog.behavior.spec.ts b/apps/visual-regression/tests/dialog.behavior.spec.ts index 813ae4b5..dab4cd84 100644 --- a/apps/visual-regression/tests/dialog.behavior.spec.ts +++ b/apps/visual-regression/tests/dialog.behavior.spec.ts @@ -217,14 +217,13 @@ test.describe('Dialog — new floating engine migration', () => { await expect(page.locator(popup)).toHaveCount(1); }); - test('KNOWN GAP: the dialog backdrop must NOT be aria-hidden/marked (avoid set misses it)', async ({ page }) => { + test('does not aria-hide / mark the dialog backdrop (it is an owned root)', async ({ page }) => { await gotoStory(page, 'primitives-dialog--default'); await page.locator(trigger).first().click(); await expect(page.locator(popup)).toBeVisible(); - // The backdrop is a second portal root (sibling of the popup), so the manager's - // markOthers avoid set = [popup] wrongly marks it. EXPECTED TO FAIL until the avoid set - // includes every dialog root (backdrop + popup). + // The backdrop is a second portal root (sibling of the popup); it registers as an owned floating + // element, so the manager's markOthers keep-set covers it (Fix #1). await expect(page.locator(backdrop)).not.toHaveAttribute('aria-hidden', 'true'); await expect(page.locator(backdrop)).not.toHaveAttribute('data-rdx-floating-inert', ''); }); diff --git a/packages/primitives/core/__tests__/floating-tree.spec.ts b/packages/primitives/core/__tests__/floating-tree.spec.ts index 9218a1d2..a0a4418d 100644 --- a/packages/primitives/core/__tests__/floating-tree.spec.ts +++ b/packages/primitives/core/__tests__/floating-tree.spec.ts @@ -348,6 +348,27 @@ describe('RdxFloatingRootContext', () => { expect(() => ctx.setFloatingElement(foreign)).toThrow(/ownerDocument/i); }); + it('tracks all owned root elements in floatingElements (popup + extras), with cleanup', () => { + const ctx = new RdxFloatingRootContext({ ownerDocument: document, open: () => true }); + const popup = document.createElement('div'); + const backdrop = document.createElement('div'); + + ctx.setFloatingElement(popup); // the popup is also an owned root + ctx.addFloatingElement(backdrop); // an extra root (e.g. a dialog backdrop) + + expect([...ctx.floatingElements]).toEqual(expect.arrayContaining([popup, backdrop])); + expect(ctx.floatingElements.size).toBe(2); + + // replacing the popup drops the old one from the set + const newPopup = document.createElement('div'); + ctx.setFloatingElement(newPopup); + expect(ctx.floatingElements.has(popup)).toBe(false); + expect(ctx.floatingElements.has(newPopup)).toBe(true); + + ctx.removeFloatingElement(backdrop); + expect(ctx.floatingElements.has(backdrop)).toBe(false); + }); + it('createFloatingRootContext builds a node-optional context (getEmptyRootContext analog)', () => { const ctx = createFloatingRootContext({ ownerDocument: document }); diff --git a/packages/primitives/core/src/floating/floating-root-context.ts b/packages/primitives/core/src/floating/floating-root-context.ts index 8109a69f..a5713108 100644 --- a/packages/primitives/core/src/floating/floating-root-context.ts +++ b/packages/primitives/core/src/floating/floating-root-context.ts @@ -45,6 +45,7 @@ export class RdxFloatingRootContext { private floatingElementRef: HTMLElement | null = null; private referenceElementRef: Element | null = null; + private readonly floatingElementsRef = new Set(); constructor(init: RdxFloatingRootContextInit) { this.ownerDocument = init.ownerDocument; @@ -67,10 +68,37 @@ export class RdxFloatingRootContext { return this.referenceElementRef; } - /** Assigns the floating element, validating it shares this context's `ownerDocument`. */ + /** + * **All** of this layer's own root elements — the popup plus any extra roots a primitive owns (e.g. + * a Dialog backdrop relocated as a separate body sibling). This is the `markOthers` **keep-set**: an + * isolation pass must never `aria-hidden` / mark the layer's own DOM. Distinct from + * {@link floatingElement} (the single popup, used for press / focus containment). + */ + get floatingElements(): ReadonlySet { + return this.floatingElementsRef; + } + + /** Assigns the floating (popup) element, validating it shares this context's `ownerDocument`. */ setFloatingElement(element: HTMLElement | null): void { this.assertSameDocument(element); + if (this.floatingElementRef) { + this.floatingElementsRef.delete(this.floatingElementRef); + } this.floatingElementRef = element; + if (element) { + this.floatingElementsRef.add(element); + } + } + + /** Registers an additional owned root element (e.g. a backdrop) into {@link floatingElements}. */ + addFloatingElement(element: Element): void { + this.assertSameDocument(element); + this.floatingElementsRef.add(element); + } + + /** Removes a previously {@link addFloatingElement | added} owned root element. */ + removeFloatingElement(element: Element): void { + this.floatingElementsRef.delete(element); } /** Assigns the reference element, validating it shares this context's `ownerDocument`. */ diff --git a/packages/primitives/dialog/src/dialog-backdrop.ts b/packages/primitives/dialog/src/dialog-backdrop.ts index 04ea82f8..aadf3960 100644 --- a/packages/primitives/dialog/src/dialog-backdrop.ts +++ b/packages/primitives/dialog/src/dialog-backdrop.ts @@ -1,4 +1,5 @@ -import { Directive } from '@angular/core'; +import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; +import { RDX_FLOATING_ROOT_CONTEXT } from '@radix-ng/primitives/core'; import { injectRdxDialogRootContext } from './dialog-root'; /** @@ -19,4 +20,16 @@ import { injectRdxDialogRootContext } from './dialog-root'; }) export class RdxDialogBackdrop { protected readonly rootContext = injectRdxDialogRootContext(); + + constructor() { + // The backdrop is a second portal root (a body sibling of the popup). Register it as an owned + // floating element so the focus manager's `markOthers` keeps it — otherwise it would be wrongly + // aria-hidden / marked as outside content (ADR 0017 §3). + const floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }); + if (floatingContext) { + const host = inject>(ElementRef).nativeElement; + floatingContext.addFloatingElement(host); + inject(DestroyRef).onDestroy(() => floatingContext.removeFloatingElement(host)); + } + } } diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 4e49ff55..595fd315 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { createFloatingRootContext, provideFloatingRootContext } from '@radix-ng/primitives/core'; import { FOCUS_GUARD_ATTR } from '@radix-ng/primitives/focus-scope'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -187,6 +188,37 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); // marker still applied (active) }); + it('keeps additional owned root elements from the context (e.g. a Dialog backdrop)', async () => { + const backdrop = document.createElement('div'); + document.body.appendChild(backdrop); + appended.push(backdrop); + const unrelated = document.createElement('div'); + document.body.appendChild(unrelated); + appended.push(unrelated); + + const context = createFloatingRootContext({ ownerDocument: document, open: () => true }); + context.addFloatingElement(backdrop); + + @Component({ + imports: [RdxFloatingFocusManager], + providers: [provideFloatingRootContext(() => context)], + template: ` +
+ ` + }) + class BackdropHost { + readonly scope = viewChild.required('scope', { read: ElementRef }); + } + + const fixture = TestBed.createComponent(BackdropHost); + fixture.autoDetectChanges(); + await flush(); + + // The backdrop is an owned root → NOT marked; an unrelated sibling IS. + expect(backdrop.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); + expect(unrelated.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); + }); + it('clears the marks when the manager is disabled', async () => { const sibling = document.createElement('div'); document.body.appendChild(sibling); diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index dbe714c6..f1dccf3a 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -203,7 +203,7 @@ export class RdxFloatingFocusManager { if (!this.effectiveEnabled()) { return; } - onCleanup(markOthers([this.host], { ariaHidden: false, mark: true })); + onCleanup(markOthers(this.avoidElements(), { ariaHidden: false, mark: true })); }); // Accessibility-isolation pass (ADR 0017 §3) — `aria-hidden` outside elements, but **only** for a @@ -212,7 +212,7 @@ export class RdxFloatingFocusManager { if (!this.effectiveEnabled() || !this.effectiveModal()) { return; } - onCleanup(markOthers([this.host], { ariaHidden: true, mark: false })); + onCleanup(markOthers(this.avoidElements(), { ariaHidden: true, mark: false })); }); this.trackInteractionType(); @@ -303,6 +303,16 @@ export class RdxFloatingFocusManager { }); } + /** + * The `markOthers` keep-set: this manager's own host **plus** any extra root elements the layer owns + * (e.g. a Dialog backdrop relocated as a separate body sibling, registered on the root context). Using + * only `[host]` would wrongly mark those sibling roots as outside content. + */ + private avoidElements(): Element[] { + const extra = this.rootContext ? [...this.rootContext.floatingElements] : []; + return [this.host, ...extra.filter((element) => element !== this.host)]; + } + /** Whether `relatedTarget` is inside the popup, its trigger(s), or an ancestor / descendant popup. */ private isRelatedTargetInside(relatedTarget: Node): boolean { const floating = this.rootContext?.floatingElement ?? this.host; From 3d924d5e530a4c2867eb86ad8763f7326f305426 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 17:14:06 +0300 Subject: [PATCH 14/35] feat(dialog): migrate to the new floating dismissal + focus engine --- .../tests/dialog.behavior.spec.ts | 12 ++++--- .../primitives/dialog/src/dialog-popup.ts | 33 ++++++++++--------- skills/radix-ng/references/api-contract.json | 2 +- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/apps/visual-regression/tests/dialog.behavior.spec.ts b/apps/visual-regression/tests/dialog.behavior.spec.ts index dab4cd84..61fe8790 100644 --- a/apps/visual-regression/tests/dialog.behavior.spec.ts +++ b/apps/visual-regression/tests/dialog.behavior.spec.ts @@ -196,10 +196,14 @@ test.describe('Dialog — new floating engine migration', () => { await page.locator(trigger).first().click(); await expect(page.locator(popup)).toBeVisible(); - // Tab focus out of the popup to a page element unrelated to the dialog → focus-out close (§3). - await page.locator('body').press('Tab'); - await page.evaluate(() => (document.activeElement as HTMLElement)?.blur()); - // (Browser session: assert the dialog closes once focus lands on an unrelated tabbable.) + // Move focus to a real element OUTSIDE the dialog (relatedTarget set, unrelated node) → the + // focus manager's focus-out close fires (§3). A null relatedTarget (bare blur) does NOT close. + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'rdx-focus-out-target'; + document.body.appendChild(button); + button.focus(); + }); await expect(page.locator(popup)).toHaveCount(0); }); diff --git a/packages/primitives/dialog/src/dialog-popup.ts b/packages/primitives/dialog/src/dialog-popup.ts index 4752d56c..295f14a5 100644 --- a/packages/primitives/dialog/src/dialog-popup.ts +++ b/packages/primitives/dialog/src/dialog-popup.ts @@ -18,32 +18,32 @@ import { injectRdxDialogRootContext } from './dialog-root'; /** * A container for the dialog contents. * - * ⚠️ **NOT VERIFIED — ADR 0015/0017 Phase-4 migration (browser run required before merge).** This is the - * Dialog cutover onto the new floating dismissal + focus engine. **jsdom cannot validate it** (no real - * layout / focus / pointer), so the behaviors below are unproven until exercised in `apps/visual-regression` - * (Playwright). Do **not** merge on a green `dialog.spec.ts` alone. + * **ADR 0015/0017 Phase-4 migration — Dialog is the PILOT cutover onto the new floating dismissal + + * focus engine. Browser-verified** by `apps/visual-regression/tests/dialog.behavior.spec.ts` (trap, + * initial / return focus, Escape / outside-press / focus-out dismissal, nested-Escape deepest-first, + * backdrop-not-marked). * * **Mapping (legacy → new):** * - `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) + * `RdxDismissableCapability` (Escape / outside-press; reads the root context + node). * - `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap + * markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`. - * - `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`. + * - `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`; the popup re-enables its + * own `pointer-events: auto` while modal (else `body { pointer-events: none }` makes it unclickable). * - focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager * (`manager.focusOut`), per ADR 0017 §3. * - `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine * treats a press/focus on it as **inside** (no close-then-reopen). * - * **Nuances to verify in the browser / AT (flagged):** - * 1. `enabled: isOpen()` releases the trap at close-start; legacy held it until unmount (the - * closed-but-mounted exit window — ADR §1). Confirm no focus jump during the exit animation. - * 2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal), while `aria-modal` is set - * only for `modal === true`. Verify AT behavior / whether to split these. - * 3. `markOthers` is NEW for Dialog — verify no double / conflicting `aria-hidden`. - * 4. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used. - * 5. `pointerDownOutside` no longer fires for presses on the trigger (now inside) — minor API shift. - * 6. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive - * nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full cutover. + * **Remaining open items (not blockers; tracked for the full cutover):** + * 1. `enabled: isOpen()` releases the trap at close-start vs legacy holding it until unmount — verified + * OK (return-focus + exit-animation tests pass), but the manager's single `enabled` can't yet split + * trap(mounted) from marker/focus-out(open). + * 2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal) while `aria-modal` is set + * only for `modal === true` — decide whether to split (AT review). + * 3. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used. + * 4. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive + * nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full Phase-4 cutover. */ @Directive({ selector: '[rdxDialogPopup]', @@ -61,6 +61,9 @@ import { injectRdxDialogRootContext } from './dialog-root'; }) ], host: { + // While a full modal locks `body { pointer-events: none }` (useBodyPointerEventsLock), the popup + // must opt back IN so its content stays interactive (close button, inputs, nested triggers). + '[style.pointer-events]': 'rootContext.modal() === true ? "auto" : null', '[attr.role]': 'rootContext.role', '[attr.aria-modal]': 'rootContext.modal() === true ? "true" : undefined', '[attr.aria-describedby]': 'rootContext.descriptionId()', diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index bb27af6f..e8db2aa1 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -2273,7 +2273,7 @@ "directive": "RdxDialogPopup", "selector": "[rdxDialogPopup]", "exportAs": "rdxDialogPopup", - "description": "A container for the dialog contents.\n\n⚠️ **NOT VERIFIED — ADR 0015/0017 Phase-4 migration (browser run required before merge).** This is the\nDialog cutover onto the new floating dismissal + focus engine. **jsdom cannot validate it** (no real\nlayout / focus / pointer), so the behaviors below are unproven until exercised in `apps/visual-regression`\n(Playwright). Do **not** merge on a green `dialog.spec.ts` alone.\n\n**Mapping (legacy → new):**\n- `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) +\n `RdxDismissableCapability` (Escape / outside-press; reads the root context + node).\n- `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap +\n markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`.\n- `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`.\n- focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager\n (`manager.focusOut`), per ADR 0017 §3.\n- `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine\n treats a press/focus on it as **inside** (no close-then-reopen).\n\n**Nuances to verify in the browser / AT (flagged):**\n1. `enabled: isOpen()` releases the trap at close-start; legacy held it until unmount (the\n closed-but-mounted exit window — ADR §1). Confirm no focus jump during the exit animation.\n2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal), while `aria-modal` is set\n only for `modal === true`. Verify AT behavior / whether to split these.\n3. `markOthers` is NEW for Dialog — verify no double / conflicting `aria-hidden`.\n4. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used.\n5. `pointerDownOutside` no longer fires for presses on the trigger (now inside) — minor API shift.\n6. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive\n nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full cutover.", + "description": "A container for the dialog contents.\n\n**ADR 0015/0017 Phase-4 migration — Dialog is the PILOT cutover onto the new floating dismissal +\nfocus engine. Browser-verified** by `apps/visual-regression/tests/dialog.behavior.spec.ts` (trap,\ninitial / return focus, Escape / outside-press / focus-out dismissal, nested-Escape deepest-first,\nbackdrop-not-marked).\n\n**Mapping (legacy → new):**\n- `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) +\n `RdxDismissableCapability` (Escape / outside-press; reads the root context + node).\n- `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap +\n markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`.\n- `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`; the popup re-enables its\n own `pointer-events: auto` while modal (else `body { pointer-events: none }` makes it unclickable).\n- focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager\n (`manager.focusOut`), per ADR 0017 §3.\n- `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine\n treats a press/focus on it as **inside** (no close-then-reopen).\n\n**Remaining open items (not blockers; tracked for the full cutover):**\n1. `enabled: isOpen()` releases the trap at close-start vs legacy holding it until unmount — verified\n OK (return-focus + exit-animation tests pass), but the manager's single `enabled` can't yet split\n trap(mounted) from marker/focus-out(open).\n2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal) while `aria-modal` is set\n only for `modal === true` — decide whether to split (AT review).\n3. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used.\n4. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive\n nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full Phase-4 cutover.", "hostDirectives": [ "RdxFloatingNodeRegistration", "RdxFloatingFocusManager" From 10eea66fc5c6c3024cfc44ee9f0b7dd10197fda8 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 18:53:47 +0300 Subject: [PATCH 15/35] feat(floating): migrate tooltip & preview-card, align dialog with Base UI --- .../tests/dialog.behavior.spec.ts | 94 ++++++++++- .../tests/popover.behavior.spec.ts | 52 +++++++ .../tests/preview-card.behavior.spec.ts | 33 ++++ .../tests/tooltip.behavior.spec.ts | 33 ++++ .../dialog/__tests__/dialog.spec.ts | 8 +- .../primitives/dialog/src/dialog-backdrop.ts | 13 +- .../primitives/dialog/src/dialog-popup.ts | 61 ++++++-- .../__tests__/dismissable-capability.spec.ts | 60 +++++++ .../src/dismissable-capability.ts | 86 ++++++++-- .../__tests__/floating-focus-manager.spec.ts | 7 +- .../src/floating-focus-manager.ts | 31 +++- .../floating-focus-manager/src/mark-others.ts | 99 +++++++----- .../primitives/focus-scope/src/focus-scope.ts | 17 +- .../popover/__tests__/popover.spec.ts | 59 ++++--- .../popover/src/popover-backdrop.ts | 14 +- .../primitives/popover/src/popover-popup.ts | 147 +++++++++--------- .../primitives/popover/src/popover-root.ts | 25 ++- .../preview-card/src/preview-card-popup.ts | 94 ++++++----- .../preview-card/src/preview-card-root.ts | 34 +++- .../tooltip/src/tooltip-positioner.ts | 43 +++-- packages/primitives/tooltip/src/tooltip.ts | 35 ++++- 21 files changed, 798 insertions(+), 247 deletions(-) diff --git a/apps/visual-regression/tests/dialog.behavior.spec.ts b/apps/visual-regression/tests/dialog.behavior.spec.ts index 61fe8790..e0e646be 100644 --- a/apps/visual-regression/tests/dialog.behavior.spec.ts +++ b/apps/visual-regression/tests/dialog.behavior.spec.ts @@ -48,12 +48,14 @@ test.describe('Dialog structural portal', () => { await expect(page.locator(popup)).toHaveCount(0); }); - test('holds the scroll lock through the exit animation, releasing it only on unmount', async ({ page }) => { + test('releases the scroll lock at close-start, before the exit animation finishes (Base UI parity)', async ({ + page + }) => { await gotoStory(page, 'primitives-dialog--default'); // Slow the 150ms exit keyframes to a comfortable window so the transitional state below is - // observable without a race (the bug — releasing the lock the instant `isOpen` flips — would - // restore `overflow` synchronously, well before the animation finishes). + // observable without a race: the lock must already be released while the popup is still + // mounted-and-animating-out (`open && modal` gating, not mounted-lifetime gating). await page.addStyleTag({ content: `[rdxDialogBackdrop][data-closed], [rdxDialogPopup][data-closed] { animation-duration: 2000ms !important; }` }); @@ -67,12 +69,13 @@ test.describe('Dialog structural portal', () => { await page.locator('[rdxDialogClose][aria-label="Close"]').click(); - // Mid-exit: the popup is closing but still mounted — the scroll lock must stay held so the - // page scrollbar doesn't reappear and reflow the page (the judder) while the dialog animates. + // Mid-exit: the popup is closing but still mounted, yet the scroll lock is already released + // (Base UI gates it on `open && modal === true`). `useScrollLock` compensates the scrollbar + // width with padding, so no content reflow accompanies the release. await expect(page.locator(popup)).toHaveAttribute('data-closed', ''); - expect(await htmlOverflow()).toBe('hidden'); + expect(await htmlOverflow()).toBe(''); - // Only once the view unmounts does the lock release and the original overflow return. + // Still released after unmount. await expect(page.locator(popup)).toHaveCount(0); expect(await htmlOverflow()).toBe(''); }); @@ -207,6 +210,27 @@ test.describe('Dialog — new floating engine migration', () => { await expect(page.locator(popup)).toHaveCount(0); }); + test('an outside press onto an interactive element keeps focus there, not back on the trigger (finding #3)', async ({ + page + }) => { + await gotoStory(page, 'primitives-dialog--non-modal'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // A real focusable control outside the non-modal dialog. Pressing it dismisses the dialog AND + // moves focus onto it — the return-focus must not yank focus back to the trigger. + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'rdx-outside-target'; + button.textContent = 'Outside'; + document.body.appendChild(button); + }); + await page.locator('#rdx-outside-target').click(); + + await expect(page.locator(popup)).toHaveCount(0); + await expect(page.locator('#rdx-outside-target')).toBeFocused(); + }); + test('nested dialog: Escape closes only the inner dialog (deepest-first ownership)', async ({ page }) => { await gotoStory(page, 'primitives-dialog--nested'); await page.locator(trigger).first().click(); @@ -221,6 +245,21 @@ test.describe('Dialog — new floating engine migration', () => { await expect(page.locator(popup)).toHaveCount(1); }); + test('nested dialog: an outside press closes only the topmost dialog (finding #1)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--nested'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup).first()).toBeVisible(); + + await page.locator(popup).first().locator(trigger).first().click(); + await expect(page.locator(popup)).toHaveCount(2); + + // A press in the far corner lands on the (parent) backdrop. Only the topmost (inner) dialog + // dismisses — the parent, which has an open nested dialog, must NOT self-close. + await page.mouse.click(5, 5); + await expect(page.locator(popup)).toHaveCount(1); + await expect(page.locator(popup)).toBeVisible(); + }); + test('does not aria-hide / mark the dialog backdrop (it is an owned root)', async ({ page }) => { await gotoStory(page, 'primitives-dialog--default'); await page.locator(trigger).first().click(); @@ -231,6 +270,47 @@ test.describe('Dialog — new floating engine migration', () => { await expect(page.locator(backdrop)).not.toHaveAttribute('aria-hidden', 'true'); await expect(page.locator(backdrop)).not.toHaveAttribute('data-rdx-floating-inert', ''); }); + + test('a modal dialog inerts outside content, not the global body pointer-events (finding #4)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // No global `body { pointer-events: none }` lock anymore — independent overlays keep working. + expect(await page.evaluate(() => document.body.style.pointerEvents)).toBe(''); + + // Outside content (the app root that holds the trigger) is a body sibling of the portal → it gets + // the real `inert` attribute: non-interactive AND removed from the a11y tree, scoped to it. + expect(await page.locator('#storybook-root').evaluate((el) => el.hasAttribute('inert'))).toBe(true); + + // On close the isolation lifts. + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + expect(await page.locator('#storybook-root').evaluate((el) => el.hasAttribute('inert'))).toBe(false); + }); + + test('the backdrop is decorative — role="presentation" (Base UI parity)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + await expect(page.locator(backdrop)).toHaveAttribute('role', 'presentation'); + }); + + test('a nested dialog renders no second backdrop (only the parent dims the page)', async ({ page }) => { + await gotoStory(page, 'primitives-dialog--nested'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup).first()).toBeVisible(); + await expect(page.locator(`${backdrop}:visible`)).toHaveCount(1); + + // Open the nested dialog from inside the parent. + await page.locator(popup).first().locator(trigger).first().click(); + await expect(page.locator(popup)).toHaveCount(2); + + // Both backdrops are in the DOM, but the nested one is `[hidden]` — exactly one stays visible. + await expect(page.locator(backdrop)).toHaveCount(2); + await expect(page.locator(`${backdrop}:visible`)).toHaveCount(1); + }); }); /** diff --git a/apps/visual-regression/tests/popover.behavior.spec.ts b/apps/visual-regression/tests/popover.behavior.spec.ts index 0eb0e7d5..ec0b38eb 100644 --- a/apps/visual-regression/tests/popover.behavior.spec.ts +++ b/apps/visual-regression/tests/popover.behavior.spec.ts @@ -45,3 +45,55 @@ test.describe('Popover structural portal', () => { await expect(page.locator(popup)).toHaveCount(0); }); }); + +/** + * ADR 0015/0017 Phase-4 migration of Popover onto the new floating engine (same pattern as Dialog). + * Browser-only: trap / live focus / dismissal need a real browser. + */ +test.describe('Popover — new floating engine migration', () => { + const focusInsidePopup = (page: Page) => + page.locator(popup).evaluate((el) => el.contains(el.ownerDocument.activeElement)); + + test('Escape closes the popover', async ({ page }) => { + await gotoStory(page, 'primitives-popover--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + }); + + test('an outside press closes the popover', async ({ page }) => { + await gotoStory(page, 'primitives-popover--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // Far top-left corner — outside the trigger and the popup. + await page.mouse.click(5, 5); + await expect(page.locator(popup)).toHaveCount(0); + }); + + test('a hover-opened popover does NOT pull focus into the popup (Base UI parity)', async ({ page }) => { + await gotoStory(page, 'primitives-popover--hover'); + await page.locator(trigger).first().hover(); + await expect(page.locator(popup)).toBeVisible(); + + // Hover-open disables the focus manager → no auto-focus into the popup. + expect(await focusInsidePopup(page)).toBe(false); + }); + + test('a modal popover traps focus once it is inside — Tab keeps it in', async ({ page }) => { + await gotoStory(page, 'primitives-popover--modal'); + await page.locator(trigger).first().click(); + await expect(page.locator(popup)).toBeVisible(); + + // Like the legacy, a positioned popover does not auto-focus into the popup on open; once focus + // is inside, the trap holds it there on Tab. (Auto-focus-on-open + Tab-from-trigger redirection + // would need the deferred portal-focus bridge / guards.) + await page.locator('[rdxPopoverClose]').focus(); + for (let i = 0; i < 4; i++) { + await page.keyboard.press('Tab'); + expect(await focusInsidePopup(page)).toBe(true); + } + }); +}); diff --git a/apps/visual-regression/tests/preview-card.behavior.spec.ts b/apps/visual-regression/tests/preview-card.behavior.spec.ts index 2932b27a..608ec6e9 100644 --- a/apps/visual-regression/tests/preview-card.behavior.spec.ts +++ b/apps/visual-regression/tests/preview-card.behavior.spec.ts @@ -18,3 +18,36 @@ test('preview-card teleports the positioner directly into with no wrapper const parentTag = await page.locator('[rdxPreviewCardPositioner]').evaluate((el) => el.parentElement?.tagName); expect(parentTag).toBe('BODY'); }); + +/** + * ADR 0015 migration of Preview Card onto the new floating dismissal engine (dismissal-only — no focus + * manager). Browser-only: real keyboard / pointer dismissal needs a real browser. + */ +test.describe('Preview Card — new floating engine migration', () => { + const trigger = '[rdxPreviewCardTrigger]'; + const popup = '[rdxPreviewCardPopup]'; + + test('Escape closes the preview-card', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(String(e))); + await gotoStory(page, 'primitives-preview-card--default'); + + await page.locator(trigger).first().hover(); + await expect(page.locator(popup)).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + expect(errors).toEqual([]); + }); + + test('an outside press closes the preview-card', async ({ page }) => { + await gotoStory(page, 'primitives-preview-card--default'); + + await page.locator(trigger).first().hover(); + await expect(page.locator(popup)).toBeVisible(); + + // Far top-left corner — outside the trigger and the popup. + await page.mouse.click(5, 5); + await expect(page.locator(popup)).toHaveCount(0); + }); +}); diff --git a/apps/visual-regression/tests/tooltip.behavior.spec.ts b/apps/visual-regression/tests/tooltip.behavior.spec.ts index 26d2b16d..9ba21d1b 100644 --- a/apps/visual-regression/tests/tooltip.behavior.spec.ts +++ b/apps/visual-regression/tests/tooltip.behavior.spec.ts @@ -19,3 +19,36 @@ test('tooltip teleports the positioner directly into with no wrapper elem const parentTag = await page.locator('[rdxTooltipPositioner]').evaluate((el) => el.parentElement?.tagName); expect(parentTag).toBe('BODY'); }); + +/** + * ADR 0015 migration of Tooltip onto the new floating dismissal engine (dismissal-only — no focus + * manager). Browser-only: real keyboard / pointer dismissal needs a real browser. + */ +test.describe('Tooltip — new floating engine migration', () => { + const trigger = '[rdxTooltipTrigger]'; + const popup = '[rdxTooltipPopup]'; + + test('Escape closes the tooltip', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(String(e))); + await gotoStory(page, 'primitives-tooltip--default'); + + await page.locator(trigger).first().hover(); + await expect(page.locator(popup)).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); + expect(errors).toEqual([]); + }); + + test('an outside press closes the tooltip', async ({ page }) => { + await gotoStory(page, 'primitives-tooltip--default'); + + await page.locator(trigger).first().hover(); + await expect(page.locator(popup)).toBeVisible(); + + // Far top-left corner — outside the trigger and the popup. + await page.mouse.click(5, 5); + await expect(page.locator(popup)).toHaveCount(0); + }); +}); diff --git a/packages/primitives/dialog/__tests__/dialog.spec.ts b/packages/primitives/dialog/__tests__/dialog.spec.ts index 25069916..a3fa624c 100644 --- a/packages/primitives/dialog/__tests__/dialog.spec.ts +++ b/packages/primitives/dialog/__tests__/dialog.spec.ts @@ -242,14 +242,20 @@ describe('Dialog', () => { expect(fixture.componentInstance.changes.at(-1)?.reason).toBe('escape-key'); }); - it('closes when a pointerdown happens outside (modal, dismissible)', async () => { + it('closes when an outside press completes (modal with backdrop → intentional, closes on click)', async () => { trigger.click(); fixture.detectChanges(); await new Promise((resolve) => setTimeout(resolve)); + // A modal dialog with a backdrop uses `intentional` outside-press (Base UI): it closes on the + // full `click`, not the bare `pointerdown` (so a text-selection drag out of the popup can't + // dismiss it). A lone pointerdown must NOT close it. document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); fixture.detectChanges(); + expect(fixture.componentInstance.open).toBe(true); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.detectChanges(); expect(fixture.componentInstance.open).toBe(false); }); diff --git a/packages/primitives/dialog/src/dialog-backdrop.ts b/packages/primitives/dialog/src/dialog-backdrop.ts index aadf3960..3231cace 100644 --- a/packages/primitives/dialog/src/dialog-backdrop.ts +++ b/packages/primitives/dialog/src/dialog-backdrop.ts @@ -1,14 +1,20 @@ -import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; -import { RDX_FLOATING_ROOT_CONTEXT } from '@radix-ng/primitives/core'; +import { booleanAttribute, DestroyRef, Directive, ElementRef, inject, input } from '@angular/core'; +import { BooleanInput, RDX_FLOATING_ROOT_CONTEXT } from '@radix-ng/primitives/core'; import { injectRdxDialogRootContext } from './dialog-root'; /** * An overlay displayed beneath the dialog popup. + * + * Decorative-only, so it carries `role="presentation"` (Base UI `DialogBackdrop`). By default a **nested** + * dialog renders no backdrop — the parent's already dims the page; stacking a second one double-darkens + * and intercepts the parent's outside-press. Set `forceRender` to opt back in. */ @Directive({ selector: '[rdxDialogBackdrop]', exportAs: 'rdxDialogBackdrop', host: { + role: 'presentation', + '[hidden]': 'rootContext.nested && !forceRender()', '[attr.data-closed]': 'rootContext.isOpen() ? undefined : ""', '[attr.data-ending-style]': 'rootContext.transitionStatus() === "ending" ? "" : undefined', '[attr.data-open]': 'rootContext.isOpen() ? "" : undefined', @@ -21,6 +27,9 @@ import { injectRdxDialogRootContext } from './dialog-root'; export class RdxDialogBackdrop { protected readonly rootContext = injectRdxDialogRootContext(); + /** Render the backdrop even for a nested dialog (off by default, matching Base UI). */ + readonly forceRender = input(false, { transform: booleanAttribute }); + constructor() { // The backdrop is a second portal root (a body sibling of the popup). Register it as an owned // floating element so the focus manager's `markOthers` keeps it — otherwise it would be wrongly diff --git a/packages/primitives/dialog/src/dialog-popup.ts b/packages/primitives/dialog/src/dialog-popup.ts index 295f14a5..83db4192 100644 --- a/packages/primitives/dialog/src/dialog-popup.ts +++ b/packages/primitives/dialog/src/dialog-popup.ts @@ -4,7 +4,6 @@ import { RDX_FLOATING_REGISTRATION, RDX_FLOATING_ROOT_CONTEXT, RdxFloatingNodeRegistration, - useBodyPointerEventsLock, useScrollLock } from '@radix-ng/primitives/core'; import { RdxDismissableCapability } from '@radix-ng/primitives/dismissable-layer'; @@ -15,6 +14,9 @@ import { import { RdxFocusScope } from '@radix-ng/primitives/focus-scope'; import { injectRdxDialogRootContext } from './dialog-root'; +/** Composite navigation keys a Dialog popup keeps to itself, so they never reach an enclosing Menu / Composite. */ +const COMPOSITE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']); + /** * A container for the dialog contents. * @@ -28,8 +30,9 @@ import { injectRdxDialogRootContext } from './dialog-root'; * `RdxDismissableCapability` (Escape / outside-press; reads the root context + node). * - `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap + * markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`. - * - `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`; the popup re-enables its - * own `pointer-events: auto` while modal (else `body { pointer-events: none }` makes it unclickable). + * - `disableOutsidePointerEvents` → the focus manager's `inert` pass marks outside elements + * non-interactive for a modal (finding #4), scoped to siblings of the popup's ancestor chain instead + * of a global `body { pointer-events: none }` lock — so the popup needs no `pointer-events: auto`. * - focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager * (`manager.focusOut`), per ADR 0017 §3. * - `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine @@ -61,9 +64,6 @@ import { injectRdxDialogRootContext } from './dialog-root'; }) ], host: { - // While a full modal locks `body { pointer-events: none }` (useBodyPointerEventsLock), the popup - // must opt back IN so its content stays interactive (close button, inputs, nested triggers). - '[style.pointer-events]': 'rootContext.modal() === true ? "auto" : null', '[attr.role]': 'rootContext.role', '[attr.aria-modal]': 'rootContext.modal() === true ? "true" : undefined', '[attr.aria-describedby]': 'rootContext.descriptionId()', @@ -75,7 +75,8 @@ import { injectRdxDialogRootContext } from './dialog-root'; '[attr.data-state]': 'rootContext.isOpen() ? "open" : "closed"', '[attr.data-nested]': 'rootContext.nested ? "" : undefined', '[attr.data-nested-dialog-open]': 'rootContext.nestedDialogOpen() ? "" : undefined', - '[id]': 'rootContext.contentId' + '[id]': 'rootContext.contentId', + '(keydown)': 'onKeyDown($event)' } }) export class RdxDialogPopup { @@ -108,20 +109,24 @@ export class RdxDialogPopup { // The popup element is this layer's floating element (inside-surface for containment checks). this.floatingContext.setFloatingElement(this.host); - // Lock scroll / outside pointer events for a full modal, held for the whole mounted lifetime (not - // just while open) so the page doesn't reflow by the scrollbar width mid-exit-animation. - const isFullyModal = computed(() => this.rootContext.modal() === true); - useScrollLock(isFullyModal); - useBodyPointerEventsLock(isFullyModal); + // Scroll lock follows Base UI (`open && modal === true`): released at close-start so the page is + // scrollable again as the exit animation plays. Background pointer/AT isolation is no longer a + // global body lock — the focus manager applies real `inert` to outside elements (finding #4). + useScrollLock(computed(() => this.rootContext.modal() === true && this.rootContext.isOpen())); const unregisterTransitionElement = this.rootContext.registerTransitionElement(this.host); inject(DestroyRef).onDestroy(unregisterTransitionElement); - // Dismissal: Escape always closes; outside-press only when pointer dismissal is enabled. Focus-out - // is owned by the focus manager (below), so the capability's own focus-out is disabled. + // Dismissal (Base UI Dialog outside-press policy, finding #1): Escape always closes; an outside + // press closes only the **topmost** dialog (a parent with an open nested dialog never self-closes) + // and only when pointer dismissal is enabled. With a backdrop the press is `intentional` (closes + // on `click`, so a text-selection drag out of the popup doesn't dismiss); without one it stays + // `sloppy` (immediate `pointerdown`). Focus-out is owned by the focus manager (below). new RdxDismissableCapability(this.floatingContext, () => this.registration?.node() ?? null, { escapeKey: () => true, - outsidePress: () => !this.rootContext.disablePointerDismissal(), + outsidePress: () => this.isTopmost() && !this.rootContext.disablePointerDismissal(), + outsidePressEvent: () => + this.rootContext.modal() === true && this.hasBackdrop() ? 'intentional' : 'sloppy', focusOutside: () => false, onEscapeKeyDown: (event) => this.escapeKeyDown.emit(event), onPointerDownOutside: (event) => { @@ -143,4 +148,30 @@ export class RdxDialogPopup { } }); } + + /** This dialog is the topmost (deepest open) one — it has no open nested dialog above it. */ + private isTopmost(): boolean { + return this.rootContext.isOpen() && !this.rootContext.nestedDialogOpen(); + } + + /** Whether this dialog owns a backdrop element (a registered root sibling that isn't the popup). */ + private hasBackdrop(): boolean { + for (const element of this.floatingContext.floatingElements) { + if (element !== this.host) { + return true; + } + } + return false; + } + + /** + * Composite navigation keys (arrows / Home / End) are kept inside the dialog (Base UI `DialogPopup`): + * a dialog opened from inside a Menu / Menubar / Composite must not let an arrow press bubble out and + * move the outer collection's active item. + */ + protected onKeyDown(event: KeyboardEvent): void { + if (COMPOSITE_KEYS.has(event.key)) { + event.stopPropagation(); + } + } } diff --git a/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts b/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts index 47df5deb..b30df163 100644 --- a/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts +++ b/packages/primitives/dismissable-layer/__tests__/dismissable-capability.spec.ts @@ -153,6 +153,66 @@ describe('RdxDismissableCapability', () => { expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); }); + it('outsidePress receives the press event and can veto per target (event-aware predicate)', async () => { + const onDismiss = vi.fn(); + const allowed = el('button'); + const blocked = el('button'); + build( + context(() => true, el()), + () => null, + onDismiss, + { outsidePress: (event) => event.target === allowed } + ); + await flush(); + + blocked.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + + allowed.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + it('outsidePressEvent "intentional" closes on click, not on the bare pointerdown', async () => { + const onDismiss = vi.fn(); + build( + context(() => true, el()), + () => null, + onDismiss, + { outsidePressEvent: () => 'intentional' } + ); + await flush(); + + const target = el('button'); + target.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expect(onDismiss).not.toHaveBeenCalled(); + + target.dispatchEvent(new Event('click', { bubbles: true })); + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + + it('outsidePressEvent map resolves per pointer type (mouse → intentional, touch → sloppy)', async () => { + const onDismiss = vi.fn(); + build( + context(() => true, el()), + () => null, + onDismiss, + { outsidePressEvent: () => ({ mouse: 'intentional', touch: 'sloppy' }) } + ); + await flush(); + + // mouse → intentional → a bare pointerdown does not close (waits for the click). + const mouse = new Event('pointerdown', { bubbles: true }); + Object.defineProperty(mouse, 'pointerType', { value: 'mouse' }); + el('button').dispatchEvent(mouse); + expect(onDismiss).not.toHaveBeenCalled(); + + // touch → sloppy → pointerdown closes immediately. + const touch = new Event('pointerdown', { bubbles: true }); + Object.defineProperty(touch, 'pointerType', { value: 'touch' }); + el('button').dispatchEvent(touch); + expect(onDismiss).toHaveBeenCalledWith('outside-press', expect.any(Event)); + }); + it('does not dismiss when the press lands inside the floating element', async () => { const onDismiss = vi.fn(); const floating = el(); diff --git a/packages/primitives/dismissable-layer/src/dismissable-capability.ts b/packages/primitives/dismissable-layer/src/dismissable-capability.ts index f030be18..9ac54649 100644 --- a/packages/primitives/dismissable-layer/src/dismissable-capability.ts +++ b/packages/primitives/dismissable-layer/src/dismissable-capability.ts @@ -10,6 +10,18 @@ export type RdxDismissReason = 'escape-key' | 'outside-press' | 'focus-outside'; * read (reactive) or a plain predicate. The `on*` pre-hooks are **preventable**: call * `event.preventDefault()` inside one to veto that dismissal (the layer then stays open). */ +/** When an outside press dismisses: `'sloppy'` closes on `pointerdown`, `'intentional'` on `click`. */ +export type RdxOutsidePressEvent = 'sloppy' | 'intentional'; + +/** + * `outsidePressEvent` config (Base UI). Either a single mode for every pointer type, or a per-pointer-type + * map (`{ mouse, touch, pen }`) resolved against the pointer type of the active press. A missing key falls + * back to `'sloppy'`. + */ +export type RdxOutsidePressEventConfig = + | RdxOutsidePressEvent + | { mouse?: RdxOutsidePressEvent; touch?: RdxOutsidePressEvent; pen?: RdxOutsidePressEvent }; + export interface RdxDismissableConfig { /** Whole-capability gate (on top of `context.open()`). Default `() => true`. */ enabled?: () => boolean; @@ -21,15 +33,20 @@ export interface RdxDismissableConfig { * Menu's `closeParentOnEsc`: a submenu's Escape also closes the parent menu). */ escapeKeyBubbles?: () => boolean; - /** Whether an outside pointer press requests dismissal. Default `() => true`. */ - outsidePress?: () => boolean; + /** + * Whether an outside pointer press requests dismissal. Default `() => true`. Receives the press + * **event**, so a layer can decide per target / button / pointer (e.g. Dialog: only the topmost + * dialog dismisses; only its own backdrop counts). + */ + outsidePress?: (event: Event) => boolean; /** * When an outside press dismisses (Base UI `outsidePressEvent`). `'sloppy'` (default) closes on * `pointerdown` — immediate, OS-like. `'intentional'` closes on `click` — requires a full * press-and-release on the same outside target, and suppresses the click when the press **started - * inside** (so selecting text and dragging out does not dismiss). + * inside** (so selecting text and dragging out does not dismiss). May be a per-pointer-type map + * resolved against the active press (`{ mouse: 'intentional', touch: 'sloppy' }`). */ - outsidePressEvent?: () => 'sloppy' | 'intentional'; + outsidePressEvent?: () => RdxOutsidePressEventConfig; /** * Whether this layer's outside-press **bubbles** to ancestor layers (Base UI `bubbles.outsidePress`). * Default `() => true` — an outside press closes the whole stack. `false` makes an open non-bubbling @@ -57,6 +74,31 @@ function isNode(target: EventTarget | null): target is Node { return target !== null && typeof (target as Node).nodeType === 'number'; } +/** + * Owner-document-safe `HTMLElement` check. A raw `target instanceof HTMLElement` is realm-sensitive — it + * returns `false` for an element from another document (iframe / popup window) because that realm has its + * own `HTMLElement` constructor. Resolve the constructor from the node's own `defaultView` (Base UI + * `isHTMLElement`). + */ +function isHTMLElement(target: EventTarget | null): target is HTMLElement { + if (!isNode(target)) { + return false; + } + const view = target.ownerDocument?.defaultView; + return view ? target instanceof view.HTMLElement : target instanceof HTMLElement; +} + +/** + * Whether `window` is a WebKit (Safari / any iOS browser) engine — its IME `compositionend`/`keydown` + * ordering needs a longer guard. Requires the `Safari` token and excludes desktop Blink (Chrome / + * Edge / Android), so jsdom (`AppleWebKit/537.36 … jsdom`, no `Safari`) is correctly **not** WebKit and + * the unit timing stays 0ms. + */ +function isWebKit(window: { navigator: Navigator }): boolean { + const ua = window.navigator.userAgent; + return /AppleWebKit/i.test(ua) && /Safari/i.test(ua) && !/Chrome|Chromium|Edg|Android/i.test(ua); +} + /** Only a primary (left / default) press dismisses — a non-primary mouse button is ignored. */ function isPrimaryButton(event: Event): boolean { return !('button' in event) || (event as MouseEvent).button === 0; @@ -72,7 +114,7 @@ function isPrimaryButton(event: Event): boolean { */ function isScrollbarPress(event: Event): boolean { const target = event.target; - if (!(target instanceof HTMLElement) || 'touches' in event || typeof (event as MouseEvent).offsetX !== 'number') { + if (!isHTMLElement(target) || 'touches' in event || typeof (event as MouseEvent).offsetX !== 'number') { return false; } const view = target.ownerDocument.defaultView; @@ -196,6 +238,18 @@ export class RdxDismissableCapability { let isComposing = false; // Press-start-inside tracking (drag-out suppression for `intentional` mode). let pressStartedInside = false; + // Pointer type of the active press, used to resolve a per-pointer-type `outsidePressEvent` map. + let currentPointerType: 'mouse' | 'touch' | 'pen' | '' = ''; + + // Resolve `outsidePressEvent` to a concrete mode for the active pointer type (pen / unknown → mouse). + const resolveOutsidePressEvent = (): RdxOutsidePressEvent => { + const value = outsidePressEvent(); + if (typeof value === 'string') { + return value; + } + const type = currentPointerType === 'pen' || currentPointerType === '' ? 'mouse' : currentPointerType; + return value[type] ?? 'sloppy'; + }; const handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !this.active() || !escapeKey() || isComposing) { @@ -221,14 +275,18 @@ export class RdxDismissableCapability { isComposing = true; }; const handleCompositionEnd = (): void => { - // Safari fires `compositionend` before `keydown`, so clear on the next tick (Base UI). - ownerWindow.setTimeout(() => { - isComposing = false; - }, 0); + // Safari fires `compositionend` before `keydown`, so clear on a later tick. 0ms/1ms are + // unreliable in Safari — WebKit needs ~5ms; other engines stay at 0ms (Base UI `useDismiss`). + ownerWindow.setTimeout( + () => { + isComposing = false; + }, + isWebKit(ownerWindow) ? 5 : 0 + ); }; const tryOutsidePress = (event: Event): void => { - if (!this.active() || !outsidePress() || !isPrimaryButton(event) || this.isInside(event.target)) { + if (!this.active() || !isPrimaryButton(event) || this.isInside(event.target) || !outsidePress(event)) { return; } if (isScrollbarPress(event)) { @@ -245,22 +303,24 @@ export class RdxDismissableCapability { } }; - // Capture-phase, so it records where the press began before any dismiss handler runs. + // Capture-phase, so it records where the press began (and its pointer type) before any dismiss + // handler runs. const handlePressStart = (event: Event): void => { pressStartedInside = this.isInside(event.target); + currentPointerType = ((event as PointerEvent).pointerType as 'mouse' | 'touch' | 'pen') || ''; }; const handlePointerCancel = (): void => { pressStartedInside = false; }; const handlePointerDown = (event: Event): void => { - if (outsidePressEvent() !== 'sloppy') { + if (resolveOutsidePressEvent() !== 'sloppy') { return; // `intentional` dismisses on click, not pointerdown } tryOutsidePress(event); }; const handleClick = (event: Event): void => { - if (outsidePressEvent() !== 'intentional') { + if (resolveOutsidePressEvent() !== 'intentional') { return; } // A press that started inside (text selection dragged out) consumes its one outside click. diff --git a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts index 595fd315..786a60ea 100644 --- a/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts +++ b/packages/primitives/floating-focus-manager/__tests__/floating-focus-manager.spec.ts @@ -174,7 +174,7 @@ describe('RdxFloatingFocusManager (skeleton)', () => { expect(sibling.getAttribute('aria-hidden')).toBeNull(); // non-modal → no a11y isolation }); - it('aria-hides outside elements when modal', async () => { + it('inerts outside elements when modal (non-interactive + a11y-hidden in one — finding #4)', async () => { const sibling = document.createElement('div'); document.body.appendChild(sibling); appended.push(sibling); @@ -184,7 +184,8 @@ describe('RdxFloatingFocusManager (skeleton)', () => { fixture.autoDetectChanges(); await flush(); - expect(sibling.getAttribute('aria-hidden')).toBe('true'); + // `inert` replaces both the body pointer-lock and the separate `aria-hidden` pass. + expect(sibling.hasAttribute('inert')).toBe(true); expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(true); // marker still applied (active) }); @@ -234,7 +235,7 @@ describe('RdxFloatingFocusManager (skeleton)', () => { await flush(); expect(sibling.hasAttribute(RDX_FLOATING_MARKER)).toBe(false); - expect(sibling.getAttribute('aria-hidden')).toBeNull(); + expect(sibling.hasAttribute('inert')).toBe(false); }); // ─── close-on-focus-out (ADR 0017 §3) ──────────────────────────────────── diff --git a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts index f1dccf3a..c65222fc 100644 --- a/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts +++ b/packages/primitives/floating-focus-manager/src/floating-focus-manager.ts @@ -206,13 +206,17 @@ export class RdxFloatingFocusManager { onCleanup(markOthers(this.avoidElements(), { ariaHidden: false, mark: true })); }); - // Accessibility-isolation pass (ADR 0017 §3) — `aria-hidden` outside elements, but **only** for a - // modal (later: typeable-combobox) popup, so Select/Menu-root get none. + // Modal isolation pass (ADR 0017 §3 / finding #4) — apply the real `inert` attribute to outside + // elements for a modal popup. `inert` is non-interactive **and** a11y-hidden in one, so it both + // replaces the old global `body { pointer-events: none }` lock (now scoped to siblings of the + // popup's ancestor chain — independent overlays at a higher layer keep working) and supplies the + // AT isolation the separate `aria-hidden` pass used to. Non-modal popups (Select / Menu root) get + // none. effect((onCleanup) => { if (!this.effectiveEnabled() || !this.effectiveModal()) { return; } - onCleanup(markOthers(this.avoidElements(), { ariaHidden: true, mark: false })); + onCleanup(markOthers(this.avoidElements(), { inert: true, mark: false })); }); this.trackInteractionType(); @@ -251,7 +255,9 @@ export class RdxFloatingFocusManager { const focusScope = inject(RdxFocusScope); focusScope.mountAutoFocus.subscribe((event) => { - const target = resolveInitialFocus(this.initialFocus(), this._interactionType()); + const interactionType = this._interactionType(); + const target = + resolveInitialFocus(this.initialFocus(), interactionType) ?? this.defaultInitialFocus(interactionType); if (target) { event.preventDefault(); // override the scope's first-tabbable default target.focus(); @@ -259,6 +265,23 @@ export class RdxFloatingFocusManager { }); } + /** + * Base UI's `defaultInitialFocus`: on a **touch** open, focus the popup itself instead of its first + * tabbable control, so a soft keyboard (Android) does not pop up over the popup. Any other interaction + * returns `null`, keeping the focus scope's first-tabbable default. The popup is made programmatically + * focusable (`tabindex="-1"`) if it isn't already. + */ + private defaultInitialFocus(interactionType: RdxInteractionType): HTMLElement | null { + if (interactionType !== 'touch') { + return null; + } + const popup = (this.rootContext?.floatingElement ?? this.host) as HTMLElement; + if (!popup.hasAttribute('tabindex')) { + popup.setAttribute('tabindex', '-1'); + } + return popup; + } + /** * Close-on-focus-out (ADR 0017 §3): a **non-modal** active popup closes when focus moves to a node * unrelated to the floating tree — not the popup, its trigger(s), a focus guard, or an ancestor / diff --git a/packages/primitives/floating-focus-manager/src/mark-others.ts b/packages/primitives/floating-focus-manager/src/mark-others.ts index c15ce330..3ebf3c40 100644 --- a/packages/primitives/floating-focus-manager/src/mark-others.ts +++ b/packages/primitives/floating-focus-manager/src/mark-others.ts @@ -5,22 +5,30 @@ * ancestor chain — leaving the popup, its ancestors, and any `[aria-live]` region untouched. * * Two independent passes (Base UI makes two separate calls, never bundling them): - * - **`ariaHidden`** — `aria-hidden="true"` for AT isolation (applied only for modal / typeable popups). + * - **control attribute** — either `inert` (the real attribute: non-interactive **and** removed from the + * a11y tree) or `aria-hidden="true"` (AT-only). `inert` takes precedence and is what replaces the + * global body pointer-lock for modal isolation (ADR 0017 §3 / finding #4): it blocks pointer + focus + * on outside content **scoped** to siblings of the popup's ancestor chain, so independent overlays at a + * higher layer are unaffected (unlike `body { pointer-events: none }`). * - **`mark`** — a neutral marker attribute ({@link RDX_FLOATING_MARKER}) applied whenever the focus * manager is active; read by ADR 0015's outside-press guard to detect third-party-injected subtrees. * - * Per-**element** ref-counting (`WeakMap`) lets overlapping popups compose: an element - * marked by two popups is only cleared when both undo. An element that was **already** `aria-hidden` - * before the call is recorded and left in place on undo. `inert` is deliberately **not** ported — no Base - * UI focus-manager consumer passes it (ADR 0017 §3). + * Per-**element**, per-**attribute** ref-counting (`WeakMap`) lets overlapping popups + * compose: an element controlled by two popups is only cleared when both undo. An element that already + * carried the control attribute before the call is recorded and left in place on undo. * * @returns an `Undo` that reverses exactly what this call applied. */ export type Undo = () => void; export interface MarkOthersOptions { - /** Apply `aria-hidden="true"` to outside elements (AT isolation). */ + /** Apply `aria-hidden="true"` to outside elements (AT-only isolation). Ignored when `inert` is set. */ ariaHidden?: boolean; + /** + * Apply the real `inert` attribute to outside elements — non-interactive **and** a11y-hidden in one. + * Takes precedence over `ariaHidden`; this is the scoped replacement for the body pointer-lock. + */ + inert?: boolean; /** Apply the neutral {@link RDX_FLOATING_MARKER} to outside elements. Default `true`. */ mark?: boolean; } @@ -28,13 +36,20 @@ export interface MarkOthersOptions { /** The neutral "outside the active floating layer" marker (Base UI `data-base-ui-inert`). */ export const RDX_FLOATING_MARKER = 'data-rdx-floating-inert'; -const ARIA_HIDDEN = 'aria-hidden'; - -/** Per-element ref-counts. Keyed by element, so they are naturally per-`Document`. */ -let ariaHiddenCounters = new WeakMap(); +/** The mutually-exclusive control attribute (Base UI: `inert` wins over `aria-hidden`). */ +type ControlAttribute = 'inert' | 'aria-hidden'; + +/** Per-element, per-attribute ref-counts. Keyed by element, so they are naturally per-`Document`. */ +const controlCounters: Record> = { + inert: new WeakMap(), + 'aria-hidden': new WeakMap() +}; +/** Elements that already carried the control attribute before we touched them — left in place on undo. */ +const preExistingControlled: Record> = { + inert: new WeakSet(), + 'aria-hidden': new WeakSet() +}; let markerCounters = new WeakMap(); -/** Elements that were already `aria-hidden` before we touched them — left in place on undo. */ -let preExistingHidden = new WeakSet(); let lockCount = 0; function unwrapHost(node: Node | null): Element | null { @@ -93,7 +108,7 @@ function collectOutsideElements(root: HTMLElement, keep: Set, stop: Set {}; @@ -101,27 +116,31 @@ export function markOthers(avoidElements: Element[], options: MarkOthersOptions const body = first.ownerDocument.body; const avoid = correctElements(body, avoidElements); - const hiddenElements: Element[] = []; + // `inert` wins over `aria-hidden` (it already removes the subtree from the a11y tree, Base UI). + const controlAttribute: ControlAttribute | null = inert ? 'inert' : ariaHidden ? 'aria-hidden' : null; + const controlledElements: Element[] = []; const markedElements: Element[] = []; - if (ariaHidden) { - // `aria-live` regions stay announceable, so keep them out of the hidden set too. + if (controlAttribute) { + const counters = controlCounters[controlAttribute]; + const preExisting = preExistingControlled[controlAttribute]; + // `aria-live` regions stay announceable, so keep them out of the controlled set too. const live = correctElements(body, Array.from(body.querySelectorAll('[aria-live]'))); const controlElements = avoid.concat(live); const targets = collectOutsideElements(body, buildKeepSet(controlElements), new Set(controlElements)); targets.forEach((node) => { - const attr = node.getAttribute(ARIA_HIDDEN); - const alreadyHidden = attr !== null && attr !== 'false'; - const count = (ariaHiddenCounters.get(node) ?? 0) + 1; - ariaHiddenCounters.set(node, count); - hiddenElements.push(node); - - if (count === 1 && alreadyHidden) { - preExistingHidden.add(node); + const attr = node.getAttribute(controlAttribute); + const already = attr !== null && attr !== 'false'; + const count = (counters.get(node) ?? 0) + 1; + counters.set(node, count); + controlledElements.push(node); + + if (count === 1 && already) { + preExisting.add(node); } - if (!alreadyHidden) { - node.setAttribute(ARIA_HIDDEN, 'true'); + if (!already) { + node.setAttribute(controlAttribute, controlAttribute === 'inert' ? '' : 'true'); } }); } @@ -141,16 +160,20 @@ export function markOthers(avoidElements: Element[], options: MarkOthersOptions lockCount += 1; return () => { - hiddenElements.forEach((element) => { - const count = (ariaHiddenCounters.get(element) ?? 0) - 1; - ariaHiddenCounters.set(element, count); - if (count === 0) { - if (!preExistingHidden.has(element)) { - element.removeAttribute(ARIA_HIDDEN); + if (controlAttribute) { + const counters = controlCounters[controlAttribute]; + const preExisting = preExistingControlled[controlAttribute]; + controlledElements.forEach((element) => { + const count = (counters.get(element) ?? 0) - 1; + counters.set(element, count); + if (count === 0) { + if (!preExisting.has(element)) { + element.removeAttribute(controlAttribute); + } + preExisting.delete(element); } - preExistingHidden.delete(element); - } - }); + }); + } markedElements.forEach((element) => { const count = (markerCounters.get(element) ?? 0) - 1; @@ -163,9 +186,11 @@ export function markOthers(avoidElements: Element[], options: MarkOthersOptions lockCount -= 1; if (lockCount === 0) { // No active locks anywhere — drop the ref-count tables so detached elements can be GC'd. - ariaHiddenCounters = new WeakMap(); + controlCounters.inert = new WeakMap(); + controlCounters['aria-hidden'] = new WeakMap(); + preExistingControlled.inert = new WeakSet(); + preExistingControlled['aria-hidden'] = new WeakSet(); markerCounters = new WeakMap(); - preExistingHidden = new WeakSet(); } }; } diff --git a/packages/primitives/focus-scope/src/focus-scope.ts b/packages/primitives/focus-scope/src/focus-scope.ts index 584021c6..828ed2ce 100644 --- a/packages/primitives/focus-scope/src/focus-scope.ts +++ b/packages/primitives/focus-scope/src/focus-scope.ts @@ -239,7 +239,7 @@ export class RdxFocusScope { // so it runs after the unmounting paint settles (ADR 0017 Phase 1a queued focus). const view = this.ownerDocument.defaultView ?? globalThis; view.requestAnimationFrame(() => { - if (!unmountEvent.defaultPrevented) { + if (!unmountEvent.defaultPrevented && !this.shouldPreserveMovedFocus()) { focus(previouslyFocusedElement ?? this.ownerDocument.body, { select: true }); } @@ -255,6 +255,21 @@ export class RdxFocusScope { }); } + /** + * Whether the interaction that unmounted this scope already moved focus to a legitimate element + * **outside** it — e.g. an outside press onto an interactive control in a non-modal layer (ADR 0017 + * §2, finding #3). Returning focus to the previously-focused element would then *steal* it back from + * what the user just acted on. Focus that fell to `` / `null` (a backdrop press, Escape, or the + * focused element being removed) is **not** "moved" — return focus normally so keyboard users land + * back on the trigger. The page never scroll-jumps either way: {@link focus} uses `preventScroll`. + */ + private shouldPreserveMovedFocus(): boolean { + const active = getActiveElement(this.ownerDocument) as HTMLElement | null; + return ( + !!active && active !== this.ownerDocument.body && !composedContains(this.elementRef.nativeElement, active) + ); + } + handleKeyDown(event: KeyboardEvent) { const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey; diff --git a/packages/primitives/popover/__tests__/popover.spec.ts b/packages/primitives/popover/__tests__/popover.spec.ts index 88128f84..05b4ee29 100644 --- a/packages/primitives/popover/__tests__/popover.spec.ts +++ b/packages/primitives/popover/__tests__/popover.spec.ts @@ -586,41 +586,50 @@ describe('Popover', () => { }); it('updates modal behavior while the popover is open', async () => { + // An outside sibling: a full modal isolates the background with real `inert` (finding #4), + // not a global body pointer-lock. `trap-focus` isolates focus but does not lock page scroll. + const sibling = document.createElement('div'); + document.body.appendChild(sibling); const modalFixture = TestBed.createComponent(ModalHostComponent); - modalFixture.detectChanges(); - const modalTrigger: HTMLButtonElement = modalFixture.nativeElement.querySelector('[rdxPopoverTrigger]'); - modalTrigger.click(); - modalFixture.detectChanges(); - await modalFixture.whenStable(); + try { + modalFixture.detectChanges(); - const focusScope = modalFixture.debugElement.query(By.directive(RdxFocusScope)).injector.get(RdxFocusScope); + const modalTrigger: HTMLButtonElement = modalFixture.nativeElement.querySelector('[rdxPopoverTrigger]'); + modalTrigger.click(); + modalFixture.detectChanges(); + await modalFixture.whenStable(); - modalFixture.componentInstance.modal = true; - modalFixture.changeDetectorRef.markForCheck(); - modalFixture.detectChanges(); - await modalFixture.whenStable(); + const focusScope = modalFixture.debugElement.query(By.directive(RdxFocusScope)).injector.get(RdxFocusScope); - expect(document.body.style.overflow).toBe('hidden'); - expect(document.body.style.pointerEvents).toBe('none'); - expect(focusScope.isTrapped()).toBe(true); + modalFixture.componentInstance.modal = true; + modalFixture.changeDetectorRef.markForCheck(); + modalFixture.detectChanges(); + await modalFixture.whenStable(); - modalFixture.componentInstance.modal = 'trap-focus'; - modalFixture.changeDetectorRef.markForCheck(); - modalFixture.detectChanges(); - await modalFixture.whenStable(); + expect(document.body.style.overflow).toBe('hidden'); + expect(sibling.hasAttribute('inert')).toBe(true); + expect(focusScope.isTrapped()).toBe(true); - expect(document.body.style.overflow).toBe(''); - expect(document.body.style.pointerEvents).toBe(''); - expect(focusScope.isTrapped()).toBe(true); + modalFixture.componentInstance.modal = 'trap-focus'; + modalFixture.changeDetectorRef.markForCheck(); + modalFixture.detectChanges(); + await modalFixture.whenStable(); - modalFixture.componentInstance.modal = false; - modalFixture.changeDetectorRef.markForCheck(); - modalFixture.detectChanges(); + expect(document.body.style.overflow).toBe(''); + expect(sibling.hasAttribute('inert')).toBe(true); // trap-focus still isolates the background + expect(focusScope.isTrapped()).toBe(true); - expect(focusScope.isTrapped()).toBe(false); + modalFixture.componentInstance.modal = false; + modalFixture.changeDetectorRef.markForCheck(); + modalFixture.detectChanges(); - modalFixture.destroy(); + expect(focusScope.isTrapped()).toBe(false); + expect(sibling.hasAttribute('inert')).toBe(false); + } finally { + sibling.remove(); + modalFixture.destroy(); + } }); it('switches the active anchor between triggers inside one root', () => { diff --git a/packages/primitives/popover/src/popover-backdrop.ts b/packages/primitives/popover/src/popover-backdrop.ts index 58a2cf84..c2eaaf24 100644 --- a/packages/primitives/popover/src/popover-backdrop.ts +++ b/packages/primitives/popover/src/popover-backdrop.ts @@ -1,4 +1,5 @@ -import { Directive } from '@angular/core'; +import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; +import { RDX_FLOATING_ROOT_CONTEXT } from '@radix-ng/primitives/core'; import { injectRdxPopoverRootContext } from './popover-root'; /** @@ -17,4 +18,15 @@ import { injectRdxPopoverRootContext } from './popover-root'; }) export class RdxPopoverBackdrop { protected readonly rootContext = injectRdxPopoverRootContext(); + + constructor() { + // Register the backdrop (a separate portal root) as an owned floating element so the focus + // manager's markOthers keeps it instead of aria-hiding / marking it (ADR 0017 §3). + const floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }); + if (floatingContext) { + const host = inject>(ElementRef).nativeElement; + floatingContext.addFloatingElement(host); + inject(DestroyRef).onDestroy(() => floatingContext.removeFloatingElement(host)); + } + } } diff --git a/packages/primitives/popover/src/popover-popup.ts b/packages/primitives/popover/src/popover-popup.ts index 291bc5cb..650aebab 100644 --- a/packages/primitives/popover/src/popover-popup.ts +++ b/packages/primitives/popover/src/popover-popup.ts @@ -1,34 +1,48 @@ -import { computed, DestroyRef, Directive, ElementRef, inject } from '@angular/core'; +import { computed, DestroyRef, Directive, ElementRef, inject, output } from '@angular/core'; import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop'; -import { useScrollLock } from '@radix-ng/primitives/core'; -import { provideRdxDismissableLayerConfig, RdxDismissableLayer } from '@radix-ng/primitives/dismissable-layer'; -import { provideRdxFocusScopeConfig, RdxFocusScope } from '@radix-ng/primitives/focus-scope'; +import { + RDX_FLOATING_REGISTRATION, + RDX_FLOATING_ROOT_CONTEXT, + RdxFloatingNodeRegistration, + useScrollLock +} from '@radix-ng/primitives/core'; +import { RdxDismissableCapability } from '@radix-ng/primitives/dismissable-layer'; +import { + provideFloatingFocusManagerConfig, + RdxFloatingFocusManager +} from '@radix-ng/primitives/floating-focus-manager'; +import { RdxFocusScope } from '@radix-ng/primitives/focus-scope'; import { RdxPopperContent, RdxPopperContentWrapper } from '@radix-ng/primitives/popper'; -import { injectRdxPopoverRootContext, RdxPopoverOpenChangeReason } from './popover-root'; +import { injectRdxPopoverRootContext } from './popover-root'; /** * A container for the popover contents. + * + * **ADR 0015/0017 Phase-4 migration** onto the new floating dismissal + focus engine (same pattern as + * Dialog; browser-verified via `popover.behavior` Playwright). Popover-specific: + * - **Hover-open disables the manager** (`enabled = isOpen && !isHoverActive`) — Base UI parity + * (`disabled={!mounted || openReason === triggerHover}`); a hover-opened popover does not trap / mark. + * (The legacy only suppressed auto-focus while still trapping — that Radix divergence is dropped.) + * - Trap = `'trap-focus' || (modal === true && hasPopupClose())`; scroll / body-pointer lock + the + * popup's `pointer-events: auto` key off the full modal (`modal === true`). + * - No `disablePointerDismissal` — outside-press + focus-out always close. + * + * Note: a positioned popover does **not** auto-focus into the popup on open (pre-existing — the legacy + * behaved the same; verified). The trap holds focus once it is inside. Auto-focus-on-open + redirecting a + * Tab from the trigger into the popup needs the deferred portal-focus bridge / guards (ADR 0017 §6a). */ @Directive({ selector: '[rdxPopoverPopup]', - hostDirectives: [RdxPopperContent, RdxDismissableLayer, RdxFocusScope], + hostDirectives: [RdxPopperContent, RdxFloatingNodeRegistration, RdxFloatingFocusManager], providers: [ - provideRdxDismissableLayerConfig(() => { + provideFloatingFocusManagerConfig(() => { const rootContext = injectRdxPopoverRootContext(); - - return { - disableOutsidePointerEvents: computed(() => rootContext.modal() === true) - }; - }), - provideRdxFocusScopeConfig(() => { - const rootContext = injectRdxPopoverRootContext(); - return { - trapped: computed( - () => - rootContext.modal() === 'trap-focus' || - (rootContext.modal() === true && rootContext.hasPopupClose()) - ) + modal: () => + rootContext.modal() === 'trap-focus' || + (rootContext.modal() === true && rootContext.hasPopupClose()), + // Off while closed (still mounted) and while hover-opened (no trap / mark on hover). + enabled: () => rootContext.isOpen() && !rootContext.isHoverActive() }; }) ], @@ -50,84 +64,73 @@ import { injectRdxPopoverRootContext, RdxPopoverOpenChangeReason } from './popov }) export class RdxPopoverPopup { protected readonly rootContext = injectRdxPopoverRootContext(); - private readonly dismissableLayer = inject(RdxDismissableLayer); + private readonly host = inject>(ElementRef).nativeElement; + private readonly floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT); + private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); + private readonly focusManager = inject(RdxFloatingFocusManager); private readonly focusScope = inject(RdxFocusScope); private readonly wrapper = inject(RdxPopperContentWrapper, { optional: true }); protected readonly align = computed(() => this.wrapper?.placedAlign()); protected readonly side = computed(() => this.wrapper?.placedSide()); - private dismissDetails: { reason: RdxPopoverOpenChangeReason; event: Event } = { - reason: 'none', - event: new Event('popover.dismiss') - }; - /** - * Event handler called when the escape key is down. Can be prevented. - */ - readonly escapeKeyDown = outputFromObservable(outputToObservable(this.dismissableLayer.escapeKeyDown)); + /** Event handler called when the escape key is down. Can be prevented. */ + readonly escapeKeyDown = output(); - /** - * Event handler called when a pointerdown event happens outside of the popup. Can be prevented. - */ - readonly pointerDownOutside = outputFromObservable(outputToObservable(this.dismissableLayer.pointerDownOutside)); + /** Event handler called when a pointerdown event happens outside of the popup. Can be prevented. */ + readonly pointerDownOutside = output(); - /** - * Event handler called when focus moves outside of the popup. Can be prevented. - */ - readonly focusOutside = outputFromObservable(outputToObservable(this.dismissableLayer.focusOutside)); + /** Event handler called when focus moves outside of the popup. Can be prevented. */ + readonly focusOutside = output(); - /** - * Event handler called when an interaction happens outside of the popup. Can be prevented. - */ - readonly interactOutside = outputFromObservable(outputToObservable(this.dismissableLayer.interactOutside)); + /** Event handler called when an interaction (pointer / focus) happens outside of the popup. */ + readonly interactOutside = output(); - /** - * Event handler called before focus moves into the popup. Can be prevented. - */ + /** Event handler called before focus moves into the popup. Can be prevented. */ readonly openAutoFocus = outputFromObservable(outputToObservable(this.focusScope.mountAutoFocus)); - /** - * Event handler called before focus returns after the popup is removed. Can be prevented. - */ + /** Event handler called before focus returns after the popup is removed. Can be prevented. */ readonly closeAutoFocus = outputFromObservable(outputToObservable(this.focusScope.unmountAutoFocus)); constructor() { - useScrollLock(computed(() => this.rootContext.modal() === true)); + this.floatingContext.setFloatingElement(this.host); - const unregisterTransitionElement = this.rootContext.registerTransitionElement( - inject>(ElementRef).nativeElement - ); + // Background pointer/AT isolation for a full modal is the focus manager's `inert` pass (finding + // #4), not a global body lock; only the page scroll lock stays here. + useScrollLock(computed(() => this.rootContext.modal() === true)); + const unregisterTransitionElement = this.rootContext.registerTransitionElement(this.host); inject(DestroyRef).onDestroy(unregisterTransitionElement); - this.dismissableLayer.pointerDownOutside.subscribe((event) => { - this.dismissDetails = { reason: 'outside-press', event }; - - if (this.rootContext.triggers().some((trigger) => trigger.contains(event.target as Node))) { + // A hover-opened popover must not steal focus — suppress the composed focus scope's auto-focus. + this.focusScope.mountAutoFocus.subscribe((event) => { + if (this.rootContext.isHoverActive()) { event.preventDefault(); } }); - this.dismissableLayer.focusOutside.subscribe((event) => { - this.dismissDetails = { reason: 'focus-out', event }; - - if (this.rootContext.isPointerDownOnTrigger()) { - event.preventDefault(); + // Dismissal: Escape + outside-press always close (no pointer-dismissal opt-out). Focus-out is + // owned by the focus manager (below), so the capability's own focus-out is disabled. + new RdxDismissableCapability(this.floatingContext, () => this.registration?.node() ?? null, { + escapeKey: () => true, + outsidePress: () => true, + focusOutside: () => false, + onEscapeKeyDown: (event) => this.escapeKeyDown.emit(event), + onPointerDownOutside: (event) => { + this.pointerDownOutside.emit(event); + this.interactOutside.emit(event); + }, + onDismiss: (reason, event) => { + this.rootContext.close(reason === 'escape-key' ? 'escape-key' : 'outside-press', event); } }); - this.dismissableLayer.escapeKeyDown.subscribe((event) => { - this.dismissDetails = { reason: 'escape-key', event }; - }); - - this.focusScope.mountAutoFocus.subscribe((event) => { - if (this.rootContext.isHoverActive()) { - event.preventDefault(); + // Focus-out close (ADR 0017 §3) — re-expose as `focusOutside` (preventable) and close unless vetoed. + this.focusManager.focusOut.subscribe((event) => { + this.focusOutside.emit(event); + this.interactOutside.emit(event); + if (!event.defaultPrevented) { + this.rootContext.close('focus-out', event); } }); - - this.dismissableLayer.dismiss.subscribe(() => { - this.rootContext.close(this.dismissDetails.reason, this.dismissDetails.event); - this.dismissDetails = { reason: 'none', event: new Event('popover.dismiss') }; - }); } } diff --git a/packages/primitives/popover/src/popover-root.ts b/packages/primitives/popover/src/popover-root.ts index 1d75207d..f8362ff6 100644 --- a/packages/primitives/popover/src/popover-root.ts +++ b/packages/primitives/popover/src/popover-root.ts @@ -4,6 +4,7 @@ import { DestroyRef, Directive, effect, + ElementRef, inject, input, model, @@ -15,7 +16,11 @@ import { import { BooleanInput, createContext, + createFloatingRootContext, injectId, + provideFloatingRootContext, + provideFloatingTree, + RdxFloatingRootContext, RdxTransitionStatus, useTransitionStatus } from '@radix-ng/primitives/core'; @@ -103,12 +108,24 @@ export type RdxPopoverTransitionStatus = RdxTransitionStatus; @Directive({ selector: '[rdxPopoverRoot]', exportAs: 'rdxPopoverRoot', - providers: [provideRdxPopoverRootContext(context)], + providers: [ + provideRdxPopoverRootContext(context), + // New floating foundation (ADR 0015/0017 migration). Inherit-or-create tree (nested sharing); + // the per-popup root context bridges open / triggers / reference. + provideFloatingTree(), + provideFloatingRootContext(() => inject(RdxPopoverRoot).floatingContext) + ], hostDirectives: [RdxPopper] }) export class RdxPopoverRoot { private readonly popper = inject(RdxPopper); private readonly destroyRef = inject(DestroyRef); + + /** Shared per-popup floating context (ADR 0015 §1): `open`, trigger registry, reference / floating els. */ + readonly floatingContext: RdxFloatingRootContext = createFloatingRootContext({ + ownerDocument: inject(ElementRef).nativeElement.ownerDocument, + open: () => this.open() + }); private hasAppliedDefaultOpen = false; private hasAppliedDefaultTriggerId = false; private openTimer: ReturnType | undefined; @@ -173,6 +190,9 @@ export class RdxPopoverRoot { constructor() { let previousOpen = this.open(); + // Keep the floating context's reference element in sync with the active trigger. + effect(() => this.floatingContext.setReferenceElement(this.trigger() ?? null)); + effect( () => { const defaultOpen = this.defaultOpen(); @@ -350,6 +370,8 @@ export class RdxPopoverRoot { registerTrigger(id: string, trigger: HTMLElement, payload: () => unknown) { this.registeredTriggers.set(id, { element: trigger, payload }); this.triggers.update((triggers) => (triggers.includes(trigger) ? triggers : [...triggers, trigger])); + // Bridge into the floating context's trigger registry (new dismissal/focus inside-element checks). + this.floatingContext.triggers.add(trigger); if (this.triggerId() === id) { this.trigger.set(trigger); @@ -365,6 +387,7 @@ export class RdxPopoverRoot { } this.triggers.update((triggers) => triggers.filter((candidate) => candidate !== trigger)); + this.floatingContext.triggers.delete(trigger); if (this.destroyRef.destroyed) { return; diff --git a/packages/primitives/preview-card/src/preview-card-popup.ts b/packages/primitives/preview-card/src/preview-card-popup.ts index fd788ba4..55ce9f44 100644 --- a/packages/primitives/preview-card/src/preview-card-popup.ts +++ b/packages/primitives/preview-card/src/preview-card-popup.ts @@ -1,22 +1,25 @@ -import { computed, DestroyRef, Directive, ElementRef, inject, signal } from '@angular/core'; -import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop'; -import { provideRdxDismissableLayerConfig, RdxDismissableLayer } from '@radix-ng/primitives/dismissable-layer'; +import { computed, DestroyRef, Directive, ElementRef, inject, output } from '@angular/core'; +import { + RDX_FLOATING_REGISTRATION, + RDX_FLOATING_ROOT_CONTEXT, + RdxFloatingNodeRegistration +} from '@radix-ng/primitives/core'; +import { RdxDismissableCapability } from '@radix-ng/primitives/dismissable-layer'; import { RdxPopperContent, RdxPopperContentWrapper } from '@radix-ng/primitives/popper'; import { injectRdxPreviewCardRootContext, RdxPreviewCardOpenChangeReason } from './preview-card-root'; /** * A container for the preview-card contents. + * + * **ADR 0015 migration** onto the new floating dismissal engine (dismissal-only — a preview-card has + * no focus manager, ADR 0017 §1). Escape, an outside press, and a focus-out all close it (the legacy's + * trigger-press preventDefault is now automatic: the trigger is the registered reference, so a press on + * it is "inside" and never fires `pointerDownOutside`). A focus-out while a pointer is held on the + * trigger is still vetoed. */ @Directive({ selector: '[rdxPreviewCardPopup]', - hostDirectives: [RdxPopperContent, RdxDismissableLayer], - providers: [ - provideRdxDismissableLayerConfig(() => { - return { - disableOutsidePointerEvents: signal(false) - }; - }) - ], + hostDirectives: [RdxPopperContent, RdxFloatingNodeRegistration], host: { '[attr.data-closed]': 'rootContext.isOpen() ? undefined : ""', '[attr.data-ending-style]': 'rootContext.transitionStatus() === "ending" ? "" : undefined', @@ -32,67 +35,62 @@ import { injectRdxPreviewCardRootContext, RdxPreviewCardOpenChangeReason } from }) export class RdxPreviewCardPopup { protected readonly rootContext = injectRdxPreviewCardRootContext(); - private readonly dismissableLayer = inject(RdxDismissableLayer); + private readonly host = inject>(ElementRef).nativeElement; + private readonly floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT); + private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); private readonly wrapper = inject(RdxPopperContentWrapper, { optional: true }); protected readonly align = computed(() => this.wrapper?.placedAlign()); protected readonly side = computed(() => this.wrapper?.placedSide()); - private dismissDetails: { reason: RdxPreviewCardOpenChangeReason; event: Event } = { - reason: 'none', - event: new Event('preview-card.dismiss') - }; /** * Event handler called when the escape key is down. Can be prevented. */ - readonly escapeKeyDown = outputFromObservable(outputToObservable(this.dismissableLayer.escapeKeyDown)); + readonly escapeKeyDown = output(); /** * Event handler called when a pointerdown event happens outside of the popup. Can be prevented. */ - readonly pointerDownOutside = outputFromObservable(outputToObservable(this.dismissableLayer.pointerDownOutside)); + readonly pointerDownOutside = output(); /** * Event handler called when focus moves outside of the popup. Can be prevented. */ - readonly focusOutside = outputFromObservable(outputToObservable(this.dismissableLayer.focusOutside)); + readonly focusOutside = output(); /** * Event handler called when an interaction happens outside of the popup. Can be prevented. */ - readonly interactOutside = outputFromObservable(outputToObservable(this.dismissableLayer.interactOutside)); + readonly interactOutside = output(); constructor() { - const unregisterTransitionElement = this.rootContext.registerTransitionElement( - inject>(ElementRef).nativeElement - ); + this.floatingContext.setFloatingElement(this.host); - inject(DestroyRef).onDestroy(() => { - unregisterTransitionElement(); - }); - - this.dismissableLayer.pointerDownOutside.subscribe((event) => { - this.dismissDetails = { reason: 'outside-press', event }; - - if (this.rootContext.triggers().some((trigger) => trigger.contains(event.target as Node))) { - event.preventDefault(); - } - }); + const unregisterTransitionElement = this.rootContext.registerTransitionElement(this.host); + inject(DestroyRef).onDestroy(unregisterTransitionElement); - this.dismissableLayer.focusOutside.subscribe((event) => { - this.dismissDetails = { reason: 'none', event }; - - if (this.rootContext.isPointerDownOnTrigger()) { - event.preventDefault(); + new RdxDismissableCapability(this.floatingContext, () => this.registration?.node() ?? null, { + escapeKey: () => true, + outsidePress: () => true, + focusOutside: () => true, + onEscapeKeyDown: (event) => this.escapeKeyDown.emit(event), + onPointerDownOutside: (event) => { + this.pointerDownOutside.emit(event); + this.interactOutside.emit(event); + }, + onFocusOutside: (event) => { + // A focus-out triggered by a pointer press still in flight on the trigger must not close + // the card (the toggle handles it) — veto it like the legacy did. + if (this.rootContext.isPointerDownOnTrigger()) { + event.preventDefault(); + } + this.focusOutside.emit(event); + this.interactOutside.emit(event); + }, + onDismiss: (reason, event) => { + const mapped: RdxPreviewCardOpenChangeReason = + reason === 'escape-key' ? 'escape-key' : reason === 'outside-press' ? 'outside-press' : 'none'; + this.rootContext.close(mapped, event); } }); - - this.dismissableLayer.escapeKeyDown.subscribe((event) => { - this.dismissDetails = { reason: 'escape-key', event }; - }); - - this.dismissableLayer.dismiss.subscribe(() => { - this.rootContext.close(this.dismissDetails.reason, this.dismissDetails.event); - this.dismissDetails = { reason: 'none', event: new Event('preview-card.dismiss') }; - }); } } diff --git a/packages/primitives/preview-card/src/preview-card-root.ts b/packages/primitives/preview-card/src/preview-card-root.ts index 108c2e30..bb5998a0 100644 --- a/packages/primitives/preview-card/src/preview-card-root.ts +++ b/packages/primitives/preview-card/src/preview-card-root.ts @@ -4,6 +4,7 @@ import { DestroyRef, Directive, effect, + ElementRef, inject, input, model, @@ -12,7 +13,16 @@ import { signal, untracked } from '@angular/core'; -import { BooleanInput, createContext, injectId, useTransitionStatus } from '@radix-ng/primitives/core'; +import { + BooleanInput, + createContext, + createFloatingRootContext, + injectId, + provideFloatingRootContext, + provideFloatingTree, + RdxFloatingRootContext, + useTransitionStatus +} from '@radix-ng/primitives/core'; import { RdxPopper } from '@radix-ng/primitives/popper'; import { RdxPreviewCardHandle } from './preview-card-handle'; @@ -88,13 +98,27 @@ export type RdxPreviewCardTransitionStatus = 'starting' | 'ending' | undefined; @Directive({ selector: '[rdxPreviewCardRoot]', exportAs: 'rdxPreviewCardRoot', - providers: [provideRdxPreviewCardRootContext(context)], + providers: [ + provideRdxPreviewCardRootContext(context), + provideFloatingTree(), + provideFloatingRootContext(() => inject(RdxPreviewCardRoot).floatingContext) + ], hostDirectives: [RdxPopper] }) export class RdxPreviewCardRoot { private readonly popper = inject(RdxPopper); private readonly destroyRef = inject(DestroyRef); + /** + * Per-popup floating root context (ADR 0015) — the shared store the popup's dismissal capability + * reads (`open`, `triggers`, the reference/floating elements). The tree node is registered by the + * popup; this context exists independently so dismissal can read `open()`. + */ + readonly floatingContext: RdxFloatingRootContext = createFloatingRootContext({ + ownerDocument: inject(ElementRef).nativeElement.ownerDocument, + open: () => this.open() + }); + /** Shared open/close transition state machine (completes on the real animationend). */ private readonly transition = useTransitionStatus((open) => { this.instant.set(false); @@ -195,6 +219,10 @@ export class RdxPreviewCardRoot { effect(() => this.popper.anchorOverride.set(this.trigger())); + // Sync the dismissal reference (the active trigger) so an outside-press on the trigger counts + // as "inside" and never dismisses (ADR 0015). + effect(() => this.floatingContext.setReferenceElement(this.trigger() ?? null)); + this.destroyRef.onDestroy(() => { this.clearHoverTimers(); @@ -316,6 +344,7 @@ export class RdxPreviewCardRoot { registerTrigger(id: string, trigger: HTMLElement, payload: () => unknown) { this.registeredTriggers.set(id, { element: trigger, payload }); this.triggers.update((triggers) => (triggers.includes(trigger) ? triggers : [...triggers, trigger])); + this.floatingContext.triggers.add(trigger); if (this.triggerId() === id) { this.trigger.set(trigger); @@ -331,6 +360,7 @@ export class RdxPreviewCardRoot { } this.triggers.update((triggers) => triggers.filter((candidate) => candidate !== trigger)); + this.floatingContext.triggers.delete(trigger); if (this.destroyRef.destroyed) { return; diff --git a/packages/primitives/tooltip/src/tooltip-positioner.ts b/packages/primitives/tooltip/src/tooltip-positioner.ts index 6fe6d419..9875a0e6 100644 --- a/packages/primitives/tooltip/src/tooltip-positioner.ts +++ b/packages/primitives/tooltip/src/tooltip-positioner.ts @@ -1,7 +1,11 @@ -import { afterNextRender, DestroyRef, Directive, effect, ElementRef, inject, signal } from '@angular/core'; -import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop'; -import { useGraceArea } from '@radix-ng/primitives/core'; -import { RdxDismissableLayer } from '@radix-ng/primitives/dismissable-layer'; +import { afterNextRender, DestroyRef, Directive, effect, ElementRef, inject, output, signal } from '@angular/core'; +import { + RDX_FLOATING_REGISTRATION, + RDX_FLOATING_ROOT_CONTEXT, + RdxFloatingNodeRegistration, + useGraceArea +} from '@radix-ng/primitives/core'; +import { RdxDismissableCapability } from '@radix-ng/primitives/dismissable-layer'; import { provideRdxPopperContentConfig, provideRdxPopperContentWrapper, @@ -15,8 +19,9 @@ import { injectRdxTooltipContext } from './tooltip'; * A "thin" positioner (ADR 0012): it inherits the popper positioning surface (inputs, `placed` * output, unified vars + placement attrs) from {@link RdxPopperContentWrapper} and adds tooltip's own * concerns — Base UI-aligned defaults (`side: 'top'`) via the config provider, dismiss handling - * (composing {@link RdxDismissableLayer}), the cursor-follow pointer-through behavior (via the - * inherited `nonInteractive` signal), the open/closed state attributes, and the hover grace area. + * (ADR 0015 — inline {@link RdxDismissableCapability} on the shared floating tree, dismissal-only with + * no focus manager), the cursor-follow pointer-through behavior (via the inherited `nonInteractive` + * signal), the open/closed state attributes, and the hover grace area. */ @Directive({ selector: '[rdxTooltipPositioner]', @@ -24,7 +29,7 @@ import { injectRdxTooltipContext } from './tooltip'; ...provideRdxPopperContentWrapper(RdxTooltipPositioner), provideRdxPopperContentConfig({ side: 'top', arrowPadding: 5, collisionPadding: 5 }) ], - hostDirectives: [RdxDismissableLayer], + hostDirectives: [RdxFloatingNodeRegistration], host: { '[attr.data-open]': 'rootContext.isOpen() ? "" : undefined', '[attr.data-closed]': 'rootContext.isOpen() ? undefined : ""' @@ -35,18 +40,19 @@ import { injectRdxTooltipContext } from './tooltip'; export class RdxTooltipPositioner extends RdxPopperContentWrapper { protected readonly rootContext = injectRdxTooltipContext(); private readonly destroyRef = inject(DestroyRef); - private readonly dismissableLayer = inject(RdxDismissableLayer); + private readonly floatingContext = inject(RDX_FLOATING_ROOT_CONTEXT); + private readonly registration = inject(RDX_FLOATING_REGISTRATION, { optional: true }); private readonly containerRef = inject>(ElementRef); /** * Event handler called when the escape key is down. Can be prevented. */ - readonly escapeKeyDown = outputFromObservable(outputToObservable(this.dismissableLayer.escapeKeyDown)); + readonly escapeKeyDown = output(); /** - * Event handler called when a `pointerdown` event happens outside of the `DismissableLayer`. Can be prevented. + * Event handler called when a `pointerdown` event happens outside of the popup. Can be prevented. */ - readonly pointerDownOutside = outputFromObservable(outputToObservable(this.dismissableLayer.pointerDownOutside)); + readonly pointerDownOutside = output(); private readonly triggerEl = signal(null); private readonly containerEl = signal(null); @@ -85,9 +91,20 @@ export class RdxTooltipPositioner extends RdxPopperContentWrapper { constructor() { super(); - this.dismissableLayer.focusOutside.subscribe((e) => e.preventDefault()); + // Register as the floating element so dismissal containment (outside-press / focus) treats the + // popup as "inside". + this.floatingContext.setFloatingElement(this.containerRef.nativeElement); - this.dismissableLayer.dismiss.subscribe(() => this.rootContext.close()); + // Dismissal-only (ADR 0017 §1 — a tooltip has no focus manager): Escape and an outside press + // close it; focus-out is intentionally a no-op (a tooltip never traps or follows focus). + new RdxDismissableCapability(this.floatingContext, () => this.registration?.node() ?? null, { + escapeKey: () => true, + outsidePress: () => true, + focusOutside: () => false, + onEscapeKeyDown: (event) => this.escapeKeyDown.emit(event), + onPointerDownOutside: (event) => this.pointerDownOutside.emit(event), + onDismiss: () => this.rootContext.close() + }); // While following the cursor the popup sits right under the pointer; if it could intercept // the pointer it would steal hover from the trigger and the tooltip would flicker. Render it diff --git a/packages/primitives/tooltip/src/tooltip.ts b/packages/primitives/tooltip/src/tooltip.ts index dc500794..d57b94a4 100644 --- a/packages/primitives/tooltip/src/tooltip.ts +++ b/packages/primitives/tooltip/src/tooltip.ts @@ -4,6 +4,7 @@ import { DestroyRef, Directive, effect, + ElementRef, inject, input, model, @@ -14,7 +15,17 @@ import { untracked } from '@angular/core'; import type { ReferenceElement } from '@floating-ui/dom'; -import { BooleanInput, createContext, injectId, NumberInput, watch } from '@radix-ng/primitives/core'; +import { + BooleanInput, + createContext, + createFloatingRootContext, + injectId, + NumberInput, + provideFloatingRootContext, + provideFloatingTree, + RdxFloatingRootContext, + watch +} from '@radix-ng/primitives/core'; import { RdxPopper } from '@radix-ng/primitives/popper'; import { RdxTooltipHandle } from './tooltip-handle'; import { injectRdxTooltipProviderContext } from './tooltip-provider'; @@ -56,7 +67,11 @@ const context = () => contextFor(inject(RdxTooltip)); @Directive({ selector: '[rdxTooltip]', exportAs: 'rdxTooltip', - providers: [provideRdxTooltipContext(context)], + providers: [ + provideRdxTooltipContext(context), + provideFloatingTree(), + provideFloatingRootContext(() => inject(RdxTooltip).floatingContext) + ], hostDirectives: [RdxPopper] }) export class RdxTooltip { @@ -66,6 +81,16 @@ export class RdxTooltip { private readonly destroyRef = inject(DestroyRef); private hasAppliedDefaultOpen = false; + /** + * Per-popup floating root context (ADR 0015) — the shared store the positioner's dismissal + * capability reads (`open`, `triggers`, the reference/floating elements). The tree node is + * registered by the positioner; this context exists independently so dismissal can read `open()`. + */ + readonly floatingContext: RdxFloatingRootContext = createFloatingRootContext({ + ownerDocument: inject(ElementRef).nativeElement.ownerDocument, + open: () => this.open() + }); + /** * Whether the tooltip is currently open. */ @@ -218,6 +243,10 @@ export class RdxTooltip { // Keep the popper anchored to the active trigger, or to the cursor while tracking. effect(() => this.popper.anchorOverride.set(this.virtualAnchor())); + // Sync the dismissal reference (the active trigger) so an outside-press on the trigger counts + // as "inside" and never dismisses (ADR 0015). + effect(() => this.floatingContext.setReferenceElement(this.trigger() ?? null)); + watch( [this.open], ([isOpen]) => { @@ -287,6 +316,7 @@ export class RdxTooltip { registerTrigger(trigger: HTMLElement) { this.triggers.update((triggers) => (triggers.includes(trigger) ? triggers : [...triggers, trigger])); + this.floatingContext.triggers.add(trigger); if (!this.trigger()) { this.trigger.set(trigger); @@ -294,6 +324,7 @@ export class RdxTooltip { return () => { this.triggers.update((triggers) => triggers.filter((candidate) => candidate !== trigger)); + this.floatingContext.triggers.delete(trigger); if (this.trigger() === trigger) { const nextTrigger = this.triggers()[0]; From b7ffad58547bd047389cd120ed80ec9f42253b78 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 18:54:07 +0300 Subject: [PATCH 16/35] chore: upd skills --- skills/radix-ng/references/api-contract.json | 83 +++++++++++++++++--- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/skills/radix-ng/references/api-contract.json b/skills/radix-ng/references/api-contract.json index e8db2aa1..3634ad40 100644 --- a/skills/radix-ng/references/api-contract.json +++ b/skills/radix-ng/references/api-contract.json @@ -2249,8 +2249,15 @@ "directive": "RdxDialogBackdrop", "selector": "[rdxDialogBackdrop]", "exportAs": "rdxDialogBackdrop", - "description": "An overlay displayed beneath the dialog popup.", - "inputs": [], + "description": "An overlay displayed beneath the dialog popup.\n\nDecorative-only, so it carries `role=\"presentation\"` (Base UI `DialogBackdrop`). By default a **nested**\ndialog renders no backdrop — the parent's already dims the page; stacking a second one double-darkens\nand intercepts the parent's outside-press. Set `forceRender` to opt back in.", + "inputs": [ + { + "name": "forceRender", + "type": "boolean", + "default": "false", + "description": "Render the backdrop even for a nested dialog (off by default, matching Base UI)." + } + ], "outputs": [] }, { @@ -2273,7 +2280,7 @@ "directive": "RdxDialogPopup", "selector": "[rdxDialogPopup]", "exportAs": "rdxDialogPopup", - "description": "A container for the dialog contents.\n\n**ADR 0015/0017 Phase-4 migration — Dialog is the PILOT cutover onto the new floating dismissal +\nfocus engine. Browser-verified** by `apps/visual-regression/tests/dialog.behavior.spec.ts` (trap,\ninitial / return focus, Escape / outside-press / focus-out dismissal, nested-Escape deepest-first,\nbackdrop-not-marked).\n\n**Mapping (legacy → new):**\n- `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) +\n `RdxDismissableCapability` (Escape / outside-press; reads the root context + node).\n- `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap +\n markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`.\n- `disableOutsidePointerEvents` → `useBodyPointerEventsLock(modal === true)`; the popup re-enables its\n own `pointer-events: auto` while modal (else `body { pointer-events: none }` makes it unclickable).\n- focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager\n (`manager.focusOut`), per ADR 0017 §3.\n- `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine\n treats a press/focus on it as **inside** (no close-then-reopen).\n\n**Remaining open items (not blockers; tracked for the full cutover):**\n1. `enabled: isOpen()` releases the trap at close-start vs legacy holding it until unmount — verified\n OK (return-focus + exit-animation tests pass), but the manager's single `enabled` can't yet split\n trap(mounted) from marker/focus-out(open).\n2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal) while `aria-modal` is set\n only for `modal === true` — decide whether to split (AT review).\n3. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used.\n4. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive\n nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full Phase-4 cutover.", + "description": "A container for the dialog contents.\n\n**ADR 0015/0017 Phase-4 migration — Dialog is the PILOT cutover onto the new floating dismissal +\nfocus engine. Browser-verified** by `apps/visual-regression/tests/dialog.behavior.spec.ts` (trap,\ninitial / return focus, Escape / outside-press / focus-out dismissal, nested-Escape deepest-first,\nbackdrop-not-marked).\n\n**Mapping (legacy → new):**\n- `RdxDismissableLayer` (legacy) → `RdxFloatingNodeRegistration` (registers the tree node) +\n `RdxDismissableCapability` (Escape / outside-press; reads the root context + node).\n- `RdxFocusScope` (direct) → `RdxFloatingFocusManager` (composes the reworked focus scope; trap +\n markOthers + close-on-focus-out), driven by `provideFloatingFocusManagerConfig`.\n- `disableOutsidePointerEvents` → the focus manager's `inert` pass marks outside elements\n non-interactive for a modal (finding #4), scoped to siblings of the popup's ancestor chain instead\n of a global `body { pointer-events: none }` lock — so the popup needs no `pointer-events: auto`.\n- focus-out close moved from the dismissal capability (`focusOutside: () => false`) to the manager\n (`manager.focusOut`), per ADR 0017 §3.\n- `isEventOnTrigger` preventDefault → removed: the trigger is in `context.triggers`, so the engine\n treats a press/focus on it as **inside** (no close-then-reopen).\n\n**Remaining open items (not blockers; tracked for the full cutover):**\n1. `enabled: isOpen()` releases the trap at close-start vs legacy holding it until unmount — verified\n OK (return-focus + exit-animation tests pass), but the manager's single `enabled` can't yet split\n trap(mounted) from marker/focus-out(open).\n2. `markOthers` aria-hidden applies for `'trap-focus'` too (manager modal) while `aria-modal` is set\n only for `modal === true` — decide whether to split (AT review).\n3. `returnFocus` orchestration is deferred → the reworked focus scope's default return-focus is used.\n4. Atomic-cutover caveat: Dialog is on the new engine while other primitives are legacy — cross-primitive\n nesting (e.g. a legacy Popover inside this Dialog) is **out of scope** until the full Phase-4 cutover.", "hostDirectives": [ "RdxFloatingNodeRegistration", "RdxFloatingFocusManager" @@ -4686,14 +4693,35 @@ { "directive": "RdxPopoverPopup", "selector": "[rdxPopoverPopup]", - "description": "A container for the popover contents.", + "description": "A container for the popover contents.\n\n**ADR 0015/0017 Phase-4 migration** onto the new floating dismissal + focus engine (same pattern as\nDialog; browser-verified via `popover.behavior` Playwright). Popover-specific:\n- **Hover-open disables the manager** (`enabled = isOpen && !isHoverActive`) — Base UI parity\n (`disabled={!mounted || openReason === triggerHover}`); a hover-opened popover does not trap / mark.\n (The legacy only suppressed auto-focus while still trapping — that Radix divergence is dropped.)\n- Trap = `'trap-focus' || (modal === true && hasPopupClose())`; scroll / body-pointer lock + the\n popup's `pointer-events: auto` key off the full modal (`modal === true`).\n- No `disablePointerDismissal` — outside-press + focus-out always close.\n\nNote: a positioned popover does **not** auto-focus into the popup on open (pre-existing — the legacy\nbehaved the same; verified). The trap holds focus once it is inside. Auto-focus-on-open + redirecting a\nTab from the trigger into the popup needs the deferred portal-focus bridge / guards (ADR 0017 §6a).", "hostDirectives": [ "RdxPopperContent", - "RdxDismissableLayer", - "RdxFocusScope" + "RdxFloatingNodeRegistration", + "RdxFloatingFocusManager" ], "inputs": [], - "outputs": [] + "outputs": [ + { + "name": "escapeKeyDown", + "type": "KeyboardEvent", + "description": "Event handler called when the escape key is down. Can be prevented." + }, + { + "name": "focusOutside", + "type": "FocusEvent", + "description": "Event handler called when focus moves outside of the popup. Can be prevented." + }, + { + "name": "interactOutside", + "type": "PointerEvent | FocusEvent", + "description": "Event handler called when an interaction (pointer / focus) happens outside of the popup." + }, + { + "name": "pointerDownOutside", + "type": "PointerEvent", + "description": "Event handler called when a pointerdown event happens outside of the popup. Can be prevented." + } + ] }, { "directive": "RdxPopoverPortal", @@ -5097,13 +5125,34 @@ { "directive": "RdxPreviewCardPopup", "selector": "[rdxPreviewCardPopup]", - "description": "A container for the preview-card contents.", + "description": "A container for the preview-card contents.\n\n**ADR 0015 migration** onto the new floating dismissal engine (dismissal-only — a preview-card has\nno focus manager, ADR 0017 §1). Escape, an outside press, and a focus-out all close it (the legacy's\ntrigger-press preventDefault is now automatic: the trigger is the registered reference, so a press on\nit is \"inside\" and never fires `pointerDownOutside`). A focus-out while a pointer is held on the\ntrigger is still vetoed.", "hostDirectives": [ "RdxPopperContent", - "RdxDismissableLayer" + "RdxFloatingNodeRegistration" ], "inputs": [], - "outputs": [] + "outputs": [ + { + "name": "escapeKeyDown", + "type": "KeyboardEvent", + "description": "Event handler called when the escape key is down. Can be prevented." + }, + { + "name": "focusOutside", + "type": "FocusEvent", + "description": "Event handler called when focus moves outside of the popup. Can be prevented." + }, + { + "name": "interactOutside", + "type": "PointerEvent | FocusEvent", + "description": "Event handler called when an interaction happens outside of the popup. Can be prevented." + }, + { + "name": "pointerDownOutside", + "type": "PointerEvent", + "description": "Event handler called when a pointerdown event happens outside of the popup. Can be prevented." + } + ] }, { "directive": "RdxPreviewCardPortal", @@ -7223,9 +7272,9 @@ { "directive": "RdxTooltipPositioner", "selector": "[rdxTooltipPositioner]", - "description": "Positions the tooltip popup against its trigger (or a custom anchor).\n\nA \"thin\" positioner (ADR 0012): it inherits the popper positioning surface (inputs, `placed`\noutput, unified vars + placement attrs) from {@link RdxPopperContentWrapper} and adds tooltip's own\nconcerns — Base UI-aligned defaults (`side: 'top'`) via the config provider, dismiss handling\n(composing {@link RdxDismissableLayer}), the cursor-follow pointer-through behavior (via the\ninherited `nonInteractive` signal), the open/closed state attributes, and the hover grace area.", + "description": "Positions the tooltip popup against its trigger (or a custom anchor).\n\nA \"thin\" positioner (ADR 0012): it inherits the popper positioning surface (inputs, `placed`\noutput, unified vars + placement attrs) from {@link RdxPopperContentWrapper} and adds tooltip's own\nconcerns — Base UI-aligned defaults (`side: 'top'`) via the config provider, dismiss handling\n(ADR 0015 — inline {@link RdxDismissableCapability} on the shared floating tree, dismissal-only with\nno focus manager), the cursor-follow pointer-through behavior (via the inherited `nonInteractive`\nsignal), the open/closed state attributes, and the hover grace area.", "hostDirectives": [ - "RdxDismissableLayer" + "RdxFloatingNodeRegistration" ], "inputs": [ { @@ -7306,6 +7355,16 @@ } ], "outputs": [ + { + "name": "escapeKeyDown", + "type": "KeyboardEvent", + "description": "Event handler called when the escape key is down. Can be prevented." + }, + { + "name": "pointerDownOutside", + "type": "PointerEvent", + "description": "Event handler called when a `pointerdown` event happens outside of the popup. Can be prevented." + }, { "name": "placed", "type": "void", From 1d00d5b8f4c867ddcdc0d6d51d3c7a76dc1afb19 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Mon, 15 Jun 2026 20:38:46 +0300 Subject: [PATCH 17/35] fix(menu): after refactoring --- .../tests/context-menu.behavior.spec.ts | 51 +++++++ .../tests/menu-submenu.behavior.spec.ts | 34 +++++ .../tests/menu.behavior.spec.ts | 28 ++++ .../tests/menubar.behavior.spec.ts | 60 ++++++++ .../context-menu/src/context-menu-root.ts | 6 + .../stories/context-menu-default.ts | 2 +- .../menu/src/menu-internal-backdrop.ts | 108 ++++++++++++++ packages/primitives/menu/src/menu-popup.ts | 104 ++++++++------ .../primitives/menu/src/menu-positioner.ts | 12 +- packages/primitives/menu/src/menu-root.ts | 132 +++++++++++++++--- .../primitives/menu/src/menu-sub-trigger.ts | 5 +- packages/primitives/menu/src/menu-trigger.ts | 26 +--- .../primitives/menubar/src/menubar-root.ts | 2 +- .../references/components/context-menu.md | 4 +- .../references/llms-full.txt | 4 +- skills/radix-ng/references/api-contract.json | 28 +++- 16 files changed, 512 insertions(+), 94 deletions(-) create mode 100644 apps/visual-regression/tests/context-menu.behavior.spec.ts create mode 100644 apps/visual-regression/tests/menubar.behavior.spec.ts create mode 100644 packages/primitives/menu/src/menu-internal-backdrop.ts diff --git a/apps/visual-regression/tests/context-menu.behavior.spec.ts b/apps/visual-regression/tests/context-menu.behavior.spec.ts new file mode 100644 index 00000000..76f564c8 --- /dev/null +++ b/apps/visual-regression/tests/context-menu.behavior.spec.ts @@ -0,0 +1,51 @@ +import { expect, Page, test } from '@playwright/test'; + +/** + * ADR 0015/0017 Phase-4 migration of Context Menu (composes `RdxMenuRoot`, so it inherits the new + * floating dismissal engine) onto a real browser. Context menus open at the cursor via a virtual + * anchor; these guard that opening + every dismissal path still works and throws no runtime errors. + */ +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + await page.waitForSelector('#storybook-root', { state: 'attached' }); +} + +const trigger = '[rdxContextMenuTrigger]'; +const popup = '[rdxMenuPopup]'; + +async function openAtTrigger(page: Page): Promise { + await page.locator(trigger).first().click({ button: 'right' }); + await expect(page.locator(popup)).toBeVisible(); +} + +test('right-click opens the context menu without runtime errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(String(e))); + await gotoStory(page, 'primitives-context-menu--default'); + + await openAtTrigger(page); + expect(errors).toEqual([]); +}); + +test('Escape closes the context menu', async ({ page }) => { + await gotoStory(page, 'primitives-context-menu--default'); + await openAtTrigger(page); + + await page.keyboard.press('Escape'); + await expect(page.locator(popup)).toHaveCount(0); +}); + +test('a modal context menu renders an internal backdrop (finding #1)', async ({ page }) => { + await gotoStory(page, 'primitives-context-menu--default'); + await openAtTrigger(page); + + await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1); +}); + +test('an outside press closes the context menu', async ({ page }) => { + await gotoStory(page, 'primitives-context-menu--default'); + await openAtTrigger(page); + + await page.mouse.click(5, 5); + await expect(page.locator(popup)).toHaveCount(0); +}); diff --git a/apps/visual-regression/tests/menu-submenu.behavior.spec.ts b/apps/visual-regression/tests/menu-submenu.behavior.spec.ts index b56db14f..fea0ec3b 100644 --- a/apps/visual-regression/tests/menu-submenu.behavior.spec.ts +++ b/apps/visual-regression/tests/menu-submenu.behavior.spec.ts @@ -79,6 +79,40 @@ test('RTL: diagonal traversal toward a left-placed submenu keeps it open', async await expect(page.locator(spellingRtlSubmenu)).toHaveCount(0); }); +test('Escape closes only the deepest submenu, keeping the parent menu open (tree deepest-first)', async ({ page }) => { + await openEditMenu(page); + await openFindSubmenu(page); + + // The Find submenu is the deepest open layer. Escape (a document-level dismissal) closes only it — + // the parent Edit menu stays open because the open submenu node blocks the parent's Escape. + await page.keyboard.press('Escape'); + await expect(page.locator(findSubmenu)).toHaveCount(0); + await expect(page.locator('[rdxMenuPopup]').first()).toBeVisible(); + + // A second Escape now closes the parent. + await page.keyboard.press('Escape'); + await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0); +}); + +test('a submenu renders no internal backdrop (only the root modal menu does, finding #1)', async ({ page }) => { + await openEditMenu(page); // root menu opened by click → modal → one internal backdrop + await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1); + + await openFindSubmenu(page); // submenu (parent.type === 'menu') → adds no backdrop of its own + await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1); +}); + +test('an outside press closes the whole open menu chain (tree containment)', async ({ page }) => { + await openEditMenu(page); + await openFindSubmenu(page); + await expect(page.locator('[rdxMenuPopup]')).toHaveCount(2); + + // A press far outside both popups closes the entire stack — the submenu is logically "inside" the + // parent via the shared floating tree, so neither survives. + await page.mouse.click(5, 5); + await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0); +}); + test('moving straight down to the sibling switches submenus', async ({ page }) => { await openEditMenu(page); await openFindSubmenu(page); diff --git a/apps/visual-regression/tests/menu.behavior.spec.ts b/apps/visual-regression/tests/menu.behavior.spec.ts index ac7fadb0..e8046bb4 100644 --- a/apps/visual-regression/tests/menu.behavior.spec.ts +++ b/apps/visual-regression/tests/menu.behavior.spec.ts @@ -34,6 +34,34 @@ test('menu locks page scrolling by default and releases it when closed', async ( expect(await htmlOverflow()).toBe(''); }); +test('a modal menu renders an internal backdrop that blocks the background and is the outside-press target (finding #1)', async ({ + page +}) => { + await gotoStory(page, 'primitives-menu--default'); + + // A fixed background button in the far corner that must NOT receive clicks while the modal menu is + // open — the internal backdrop has to intercept them. + await page.evaluate(() => { + const b = document.createElement('button'); + b.id = 'rdx-bg-btn'; + b.style.cssText = 'position:fixed; left:2px; top:2px; width:40px; height:40px'; + b.addEventListener('click', () => { + (window as { __bgHits?: number }).__bgHits = ((window as { __bgHits?: number }).__bgHits || 0) + 1; + }); + document.body.appendChild(b); + }); + + await page.locator('[rdxMenuTrigger]').first().click(); + await expect(page.locator('[rdxMenuPopup]')).toBeVisible(); + await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1); + + // A press in the far corner lands on the backdrop (over the background button): the button must not + // fire (background blocked) and the menu closes (the backdrop is the outside-press target). + await page.mouse.click(10, 10); + await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0); + expect(await page.evaluate(() => (window as { __bgHits?: number }).__bgHits || 0)).toBe(0); +}); + test('modal menu trigger stays interactive and closes the open menu on click', async ({ page }) => { await gotoStory(page, 'primitives-menu--default'); diff --git a/apps/visual-regression/tests/menubar.behavior.spec.ts b/apps/visual-regression/tests/menubar.behavior.spec.ts new file mode 100644 index 00000000..7756404a --- /dev/null +++ b/apps/visual-regression/tests/menubar.behavior.spec.ts @@ -0,0 +1,60 @@ +import { expect, Page, test } from '@playwright/test'; + +/** + * ADR 0015/0017 Phase-4 migration of Menubar (a coordinator over `RdxMenuRoot` + `rdxMenuTrigger`, so + * each menu uses the new floating dismissal engine) onto a real browser. Guards that opening, switching + * between sibling menus, and dismissal still work — the cases the per-menu (vs legacy global-stack) + * containment had to preserve. The Default story keeps every menu popup mounted, so assertions scope to + * the *open* popup (`[data-state="open"]`) rather than counting all mounted popups. + */ +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + await page.waitForSelector('#storybook-root', { state: 'attached' }); +} + +const trigger = '[rdxMenuTrigger]'; +const openPopup = '[rdxMenuPopup][data-state="open"]'; + +test('opens a menu and keeps it open without runtime errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(String(e))); + await gotoStory(page, 'primitives-menubar--default'); + + await page.locator(trigger).first().click(); + // The freshly opened menu must not be dismissed by the async focus-outside check. + await expect(page.locator(openPopup)).toHaveCount(1); + expect(errors).toEqual([]); +}); + +test('hovering a sibling trigger switches menus — only one open at a time', async ({ page }) => { + await gotoStory(page, 'primitives-menubar--default'); + const triggers = page.locator(trigger); + + await triggers.first().click(); + await expect(page.locator(openPopup)).toHaveCount(1); + await expect(triggers.first()).toHaveAttribute('data-state', 'open'); + + await triggers.nth(1).hover(); + // Switched: still exactly one open popup, now the second menu's. + await expect(page.locator(openPopup)).toHaveCount(1); + await expect(triggers.nth(1)).toHaveAttribute('data-state', 'open'); + await expect(triggers.first()).toHaveAttribute('data-state', 'closed'); +}); + +test('Escape closes the open menu', async ({ page }) => { + await gotoStory(page, 'primitives-menubar--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(openPopup)).toHaveCount(1); + + await page.keyboard.press('Escape'); + await expect(page.locator(openPopup)).toHaveCount(0); +}); + +test('an outside press closes the open menu', async ({ page }) => { + await gotoStory(page, 'primitives-menubar--default'); + await page.locator(trigger).first().click(); + await expect(page.locator(openPopup)).toHaveCount(1); + + await page.mouse.click(5, 5); + await expect(page.locator(openPopup)).toHaveCount(0); +}); diff --git a/packages/primitives/context-menu/src/context-menu-root.ts b/packages/primitives/context-menu/src/context-menu-root.ts index ab0c0f53..d206271c 100644 --- a/packages/primitives/context-menu/src/context-menu-root.ts +++ b/packages/primitives/context-menu/src/context-menu-root.ts @@ -52,6 +52,12 @@ export class RdxContextMenuRoot { readonly menuRoot = inject(RdxMenuRoot); private readonly popper = inject(RdxPopper); + constructor() { + // Tell the composed menu root it is a Context Menu, so its per-kind policy (modal focus trap, + // backdrop, outside-press grace) differs from a plain dropdown (Base UI `MenuParent.type`). + this.menuRoot.markAsContextMenu(); + } + /** * Open the menu with the popup anchored at the given viewport coordinates. * diff --git a/packages/primitives/context-menu/stories/context-menu-default.ts b/packages/primitives/context-menu/stories/context-menu-default.ts index 044b203c..abfe8f27 100644 --- a/packages/primitives/context-menu/stories/context-menu-default.ts +++ b/packages/primitives/context-menu/stories/context-menu-default.ts @@ -77,8 +77,8 @@ import { cn, demoMenu } from '../../storybook/styles';
-
People
+
People