@radix-ng/primitives 1.0.0-beta.3 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +1 -1
  2. package/fesm2022/radix-ng-primitives-accordion.mjs +5 -3
  3. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  4. package/fesm2022/radix-ng-primitives-alert-dialog.mjs +3 -2
  5. package/fesm2022/radix-ng-primitives-alert-dialog.mjs.map +1 -1
  6. package/fesm2022/radix-ng-primitives-autocomplete.mjs +617 -659
  7. package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -1
  8. package/fesm2022/radix-ng-primitives-calendar.mjs +5 -3
  9. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  10. package/fesm2022/radix-ng-primitives-checkbox.mjs +33 -18
  11. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  12. package/fesm2022/radix-ng-primitives-combobox.mjs +1305 -572
  13. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
  14. package/fesm2022/radix-ng-primitives-config.mjs +13 -4
  15. package/fesm2022/radix-ng-primitives-config.mjs.map +1 -1
  16. package/fesm2022/radix-ng-primitives-context-menu.mjs +51 -10
  17. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  18. package/fesm2022/radix-ng-primitives-core.mjs +1352 -64
  19. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  20. package/fesm2022/radix-ng-primitives-date-field.mjs +5 -3
  21. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  22. package/fesm2022/radix-ng-primitives-dialog.mjs +290 -120
  23. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  24. package/fesm2022/radix-ng-primitives-direction-provider.mjs +70 -0
  25. package/fesm2022/radix-ng-primitives-direction-provider.mjs.map +1 -0
  26. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs +519 -184
  27. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs.map +1 -1
  28. package/fesm2022/radix-ng-primitives-drawer.mjs +3 -3
  29. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  30. package/fesm2022/radix-ng-primitives-editable.mjs +12 -7
  31. package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
  32. package/fesm2022/radix-ng-primitives-field.mjs +3 -2
  33. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  34. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +803 -0
  35. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
  36. package/fesm2022/radix-ng-primitives-focus-scope.mjs +305 -70
  37. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  38. package/fesm2022/radix-ng-primitives-menu.mjs +893 -289
  39. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  40. package/fesm2022/radix-ng-primitives-menubar.mjs +32 -4
  41. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  42. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +144 -159
  43. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  44. package/fesm2022/radix-ng-primitives-number-field.mjs +7 -2
  45. package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
  46. package/fesm2022/radix-ng-primitives-popover.mjs +284 -212
  47. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  48. package/fesm2022/radix-ng-primitives-popper.mjs +94 -51
  49. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  50. package/fesm2022/radix-ng-primitives-presence.mjs +1 -1
  51. package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
  52. package/fesm2022/radix-ng-primitives-preview-card.mjs +141 -173
  53. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  54. package/fesm2022/radix-ng-primitives-radio.mjs +19 -14
  55. package/fesm2022/radix-ng-primitives-radio.mjs.map +1 -1
  56. package/fesm2022/radix-ng-primitives-roving-focus.mjs +4 -2
  57. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  58. package/fesm2022/radix-ng-primitives-scroll-area.mjs +5 -4
  59. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
  60. package/fesm2022/radix-ng-primitives-select.mjs +241 -164
  61. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  62. package/fesm2022/radix-ng-primitives-slider.mjs +262 -29
  63. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  64. package/fesm2022/radix-ng-primitives-stepper.mjs +16 -10
  65. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  66. package/fesm2022/radix-ng-primitives-switch.mjs +10 -5
  67. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  68. package/fesm2022/radix-ng-primitives-tabs.mjs +15 -10
  69. package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
  70. package/fesm2022/radix-ng-primitives-time-field.mjs +5 -3
  71. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  72. package/fesm2022/radix-ng-primitives-toast.mjs +15 -36
  73. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
  74. package/fesm2022/radix-ng-primitives-toggle-group.mjs +14 -7
  75. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  76. package/fesm2022/radix-ng-primitives-toggle.mjs +12 -6
  77. package/fesm2022/radix-ng-primitives-toggle.mjs.map +1 -1
  78. package/fesm2022/radix-ng-primitives-toolbar.mjs +5 -3
  79. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  80. package/fesm2022/radix-ng-primitives-tooltip.mjs +251 -143
  81. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  82. package/package.json +10 -1
  83. package/types/radix-ng-primitives-accordion.d.ts +4 -3
  84. package/types/radix-ng-primitives-autocomplete.d.ts +217 -152
  85. package/types/radix-ng-primitives-calendar.d.ts +5 -3
  86. package/types/radix-ng-primitives-checkbox.d.ts +27 -15
  87. package/types/radix-ng-primitives-combobox.d.ts +672 -283
  88. package/types/radix-ng-primitives-config.d.ts +1 -1
  89. package/types/radix-ng-primitives-context-menu.d.ts +15 -5
  90. package/types/radix-ng-primitives-core.d.ts +764 -14
  91. package/types/radix-ng-primitives-date-field.d.ts +3 -2
  92. package/types/radix-ng-primitives-dialog.d.ts +88 -32
  93. package/types/radix-ng-primitives-direction-provider.d.ts +41 -0
  94. package/types/radix-ng-primitives-dismissable-layer.d.ts +147 -99
  95. package/types/radix-ng-primitives-editable.d.ts +11 -5
  96. package/types/radix-ng-primitives-field.d.ts +1 -0
  97. package/types/radix-ng-primitives-floating-focus-manager.d.ts +272 -0
  98. package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
  99. package/types/radix-ng-primitives-menu.d.ts +192 -103
  100. package/types/radix-ng-primitives-navigation-menu.d.ts +37 -75
  101. package/types/radix-ng-primitives-number-field.d.ts +8 -3
  102. package/types/radix-ng-primitives-popover.d.ts +71 -92
  103. package/types/radix-ng-primitives-popper.d.ts +39 -9
  104. package/types/radix-ng-primitives-preview-card.d.ts +39 -72
  105. package/types/radix-ng-primitives-radio.d.ts +13 -6
  106. package/types/radix-ng-primitives-roving-focus.d.ts +7 -6
  107. package/types/radix-ng-primitives-scroll-area.d.ts +2 -2
  108. package/types/radix-ng-primitives-select.d.ts +142 -109
  109. package/types/radix-ng-primitives-slider.d.ts +64 -12
  110. package/types/radix-ng-primitives-stepper.d.ts +15 -7
  111. package/types/radix-ng-primitives-switch.d.ts +10 -4
  112. package/types/radix-ng-primitives-tabs.d.ts +12 -6
  113. package/types/radix-ng-primitives-time-field.d.ts +3 -2
  114. package/types/radix-ng-primitives-toast.d.ts +7 -7
  115. package/types/radix-ng-primitives-toggle-group.d.ts +15 -8
  116. package/types/radix-ng-primitives-toggle.d.ts +10 -3
  117. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  118. package/types/radix-ng-primitives-tooltip.d.ts +61 -80
@@ -1,59 +1,31 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, inject, Injector, DestroyRef, ElementRef, input, booleanAttribute, computed, output, afterNextRender, effect, Directive } from '@angular/core';
2
+ import { effect, InjectionToken, signal, inject, Injector, DestroyRef, ElementRef, input, booleanAttribute, computed, output, afterNextRender, Directive } from '@angular/core';
3
3
  import { getActiveElement, createContext } from '@radix-ng/primitives/core';
4
4
 
5
- const RdxFocusScopeConfigToken = new InjectionToken('RdxFocusScopeConfig', {
6
- factory: () => ({
7
- trapped: signal(false)
8
- })
9
- });
10
- function provideRdxFocusScopeConfig(factory) {
11
- return { provide: RdxFocusScopeConfigToken, useFactory: factory };
12
- }
13
-
14
- function createGlobalState(factory) {
15
- const state = factory();
16
- return () => state;
5
+ const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
6
+ const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
7
+ const EVENT_OPTIONS = { bubbles: false, cancelable: true };
8
+ /**
9
+ * The real target of a (possibly retargeted) event, piercing shadow boundaries via `composedPath()`.
10
+ * Falls back to `event.target` when `composedPath` is unavailable.
11
+ */
12
+ function getEventTarget(event) {
13
+ return event.composedPath?.()[0] ?? event.target;
17
14
  }
18
- const useFocusStackState = createGlobalState(() => signal([]));
19
- function createFocusScopesStack() {
20
- /** A stack of focus scopes, with the active one at the top */
21
- const stack = useFocusStackState();
22
- return {
23
- add(focusScope) {
24
- const current = stack();
25
- const active = current[0];
26
- if (focusScope !== active) {
27
- active?.pause();
28
- }
29
- const updated = arrayRemove(current, focusScope);
30
- updated.unshift(focusScope);
31
- stack.set(updated);
32
- },
33
- remove(focusScope) {
34
- const current = stack();
35
- const updated = arrayRemove(current, focusScope);
36
- stack.set(updated);
37
- // после удаления «возобновляем» новый верхний
38
- stack()[0]?.resume();
15
+ /**
16
+ * Shadow-DOM-aware containment: whether `node` is `container` or lives inside it, crossing shadow roots
17
+ * via their `host` (unlike `Node.contains`, which stops at a shadow boundary).
18
+ */
19
+ function composedContains(container, node) {
20
+ let current = node;
21
+ while (current) {
22
+ if (current === container) {
23
+ return true;
39
24
  }
40
- };
41
- }
42
- function arrayRemove(array, item) {
43
- const copy = [...array];
44
- const idx = copy.indexOf(item);
45
- if (idx !== -1) {
46
- copy.splice(idx, 1);
25
+ current = current instanceof ShadowRoot ? current.host : current.parentNode;
47
26
  }
48
- return copy;
49
- }
50
- function removeLinks(items) {
51
- return items.filter((el) => el.tagName !== 'A');
27
+ return false;
52
28
  }
53
-
54
- const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
55
- const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
56
- const EVENT_OPTIONS = { bubbles: false, cancelable: true };
57
29
  /**
58
30
  * Attempts focusing the first element in a list of candidates.
59
31
  * Stops when focus has actually moved.
@@ -79,7 +51,7 @@ function focusFirst(candidates, { select = false } = {}) {
79
51
  */
80
52
  function getTabbableCandidates(container) {
81
53
  const nodes = [];
82
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
54
+ const walker = container.ownerDocument.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
83
55
  acceptNode: (node) => {
84
56
  const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
85
57
  if (node.disabled || node.hidden || isHiddenInput)
@@ -97,13 +69,17 @@ function getTabbableCandidates(container) {
97
69
  return nodes;
98
70
  }
99
71
  function isHidden(node, { upTo }) {
100
- if (getComputedStyle(node).visibility === 'hidden')
72
+ const view = node.ownerDocument.defaultView;
73
+ if (!view) {
74
+ return false; // no view (detached / SSR) — cannot resolve computed styles, treat as visible
75
+ }
76
+ if (view.getComputedStyle(node).visibility === 'hidden')
101
77
  return true;
102
78
  while (node) {
103
79
  // we stop at `upTo` (excluding it)
104
80
  if (upTo !== undefined && node === upTo)
105
81
  return false;
106
- if (getComputedStyle(node).display === 'none')
82
+ if (view.getComputedStyle(node).display === 'none')
107
83
  return true;
108
84
  node = node.parentElement;
109
85
  }
@@ -130,6 +106,54 @@ function getTabbableEdges(container) {
130
106
  const last = findVisible(candidates.reverse(), container);
131
107
  return [first, last];
132
108
  }
109
+ /** Visible tabbable elements of `root` in document order (the basis for tab-order navigation). */
110
+ function visibleTabbablesIn(root) {
111
+ return getTabbableCandidates(root).filter((el) => !isHidden(el, { upTo: root }));
112
+ }
113
+ /** The tabbable one step (`dir`) from the document's active element, within `container`. */
114
+ function getTabbableIn(container, dir) {
115
+ const list = visibleTabbablesIn(container);
116
+ if (list.length === 0) {
117
+ return undefined;
118
+ }
119
+ const active = getActiveElement(container.ownerDocument);
120
+ const index = active ? list.indexOf(active) : -1;
121
+ const nextIndex = index === -1 ? (dir === 1 ? 0 : list.length - 1) : index + dir;
122
+ return list[nextIndex];
123
+ }
124
+ /**
125
+ * The next tabbable in the document after the current focus (Base UI `getNextTabbable`) — used by the
126
+ * portal-focus bridge's trailing guard to step focus past the popup. Falls back to `reference`.
127
+ */
128
+ function getNextTabbable(reference) {
129
+ const body = (reference?.ownerDocument ?? document).body;
130
+ return getTabbableIn(body, 1) ?? reference;
131
+ }
132
+ /** The previous tabbable in the document before the current focus (Base UI `getPreviousTabbable`). */
133
+ function getPreviousTabbable(reference) {
134
+ const body = (reference?.ownerDocument ?? document).body;
135
+ return getTabbableIn(body, -1) ?? reference;
136
+ }
137
+ /** The tabbable `dir` steps from `reference` in the document, wrapping around. */
138
+ function getTabbableNearElement(reference, dir) {
139
+ if (!reference) {
140
+ return null;
141
+ }
142
+ const list = visibleTabbablesIn(reference.ownerDocument.body);
143
+ const index = list.indexOf(reference);
144
+ if (list.length === 0 || index === -1) {
145
+ return null;
146
+ }
147
+ return list[(index + dir + list.length) % list.length];
148
+ }
149
+ /** The tabbable immediately after `reference` in the document, wrapping (Base UI `getTabbableAfterElement`). */
150
+ function getTabbableAfterElement(reference) {
151
+ return getTabbableNearElement(reference, 1);
152
+ }
153
+ /** The tabbable immediately before `reference` in the document, wrapping (Base UI `getTabbableBeforeElement`). */
154
+ function getTabbableBeforeElement(reference) {
155
+ return getTabbableNearElement(reference, -1);
156
+ }
133
157
  function isSelectableInput(element) {
134
158
  return element instanceof HTMLInputElement && 'select' in element;
135
159
  }
@@ -146,6 +170,190 @@ function focus(element, { select = false } = {}) {
146
170
  }
147
171
  }
148
172
 
173
+ /** Marks the leading / trailing focus-guard spans (Base UI `data-base-ui-focus-guard`). */
174
+ const FOCUS_GUARD_ATTR = 'data-rdx-focus-guard';
175
+ /** Saved-tabindex marker used by {@link disableFocusInside} / {@link enableFocusInside}. */
176
+ const SAVED_TABINDEX_ATTR = 'data-rdx-tabindex';
177
+ /** Visually-hidden, off-flow style for a focus guard / `aria-owns` anchor (Base UI `visuallyHidden`). */
178
+ const FOCUS_GUARD_STYLE = {
179
+ position: 'fixed',
180
+ top: '0',
181
+ left: '0',
182
+ width: '1px',
183
+ height: '1px',
184
+ padding: '0',
185
+ margin: '-1px',
186
+ overflow: 'hidden',
187
+ clipPath: 'inset(50%)',
188
+ whiteSpace: 'nowrap',
189
+ border: '0'
190
+ };
191
+ /**
192
+ * Creates a visually-hidden, **tabbable** focus-guard `<span>` — the Angular counterpart of Base UI's
193
+ * `FocusGuard`. The portal-focus bridge places one before and one after the portal content so a Tab into
194
+ * (or out of) the portal lands on a guard, which then redirects focus to the right boundary.
195
+ */
196
+ function createFocusGuard(ownerDocument) {
197
+ const guard = ownerDocument.createElement('span');
198
+ guard.setAttribute('tabindex', '0');
199
+ guard.setAttribute('aria-hidden', 'true');
200
+ guard.setAttribute(FOCUS_GUARD_ATTR, '');
201
+ Object.assign(guard.style, FOCUS_GUARD_STYLE);
202
+ return guard;
203
+ }
204
+ /**
205
+ * Creates a visually-hidden `<span aria-owns="…">` that links the portal node into the trigger's tab /
206
+ * AT order (Base UI's single `aria-owns` anchor). The manager places it next to the trigger.
207
+ */
208
+ function createAriaOwnsAnchor(ownerDocument, portalId) {
209
+ const anchor = ownerDocument.createElement('span');
210
+ anchor.setAttribute('aria-owns', portalId);
211
+ Object.assign(anchor.style, FOCUS_GUARD_STYLE);
212
+ return anchor;
213
+ }
214
+ /**
215
+ * Makes every tabbable descendant of `container` **non-tabbable** (`tabindex="-1"`), saving each one's
216
+ * original tabindex so {@link enableFocusInside} can restore it. Base UI `disableFocusInside`: a
217
+ * non-modal portal keeps its content untabbable until focus is actually inside it, so a Tab from the
218
+ * trigger steps onto the guard instead of jumping into the content.
219
+ */
220
+ function disableFocusInside(container) {
221
+ for (const element of getTabbableCandidates(container)) {
222
+ element.setAttribute(SAVED_TABINDEX_ATTR, element.getAttribute('tabindex') ?? '');
223
+ element.setAttribute('tabindex', '-1');
224
+ }
225
+ }
226
+ /** Restores the tabbability that {@link disableFocusInside} suspended. Base UI `enableFocusInside`. */
227
+ function enableFocusInside(container) {
228
+ container.querySelectorAll(`[${SAVED_TABINDEX_ATTR}]`).forEach((element) => {
229
+ const original = element.getAttribute(SAVED_TABINDEX_ATTR);
230
+ element.removeAttribute(SAVED_TABINDEX_ATTR);
231
+ if (original) {
232
+ element.setAttribute('tabindex', original);
233
+ }
234
+ else {
235
+ element.removeAttribute('tabindex');
236
+ }
237
+ });
238
+ }
239
+ /**
240
+ * Whether a focus event crossed the `container` boundary — its `relatedTarget` (the other side of the
241
+ * focus move) is `null` or outside `container` (Base UI `isOutsideEvent`). Shadow-DOM-aware via
242
+ * {@link composedContains}.
243
+ */
244
+ function isOutsideEvent(event, container) {
245
+ const relatedTarget = event.relatedTarget;
246
+ return !relatedTarget || !composedContains(container, relatedTarget);
247
+ }
248
+ /**
249
+ * The portal-focus bridge's **tabbability toggle** (Base UI `FloatingPortal` capture-phase `onFocus`).
250
+ * While `portalNode` is mounted (and `enabled`), it makes the portal content tabbable **only when focus
251
+ * is inside it**: focus entering from outside re-enables tabbability, focus leaving to outside disables
252
+ * it again. Listens on the **capture** phase so it settles before the focus manager's guards react.
253
+ *
254
+ * Must be called in an injection context. The initial disable-on-mount and the guard-span placement are
255
+ * the manager's responsibility (Phase 1b); this owns only the dynamic in/out toggle.
256
+ */
257
+ function useFocusGuardsTabbability(portalNode, options = {}) {
258
+ const enabled = options.enabled ?? (() => true);
259
+ let focusInsideDisabled = false;
260
+ effect((onCleanup) => {
261
+ const node = portalNode();
262
+ if (!node || !enabled()) {
263
+ return;
264
+ }
265
+ const ownerDocument = node.ownerDocument;
266
+ const onFocus = (event) => {
267
+ // Only react to focus actually crossing the portal boundary.
268
+ if (!event.relatedTarget || !isOutsideEvent(event, node)) {
269
+ return;
270
+ }
271
+ if (event.type === 'focusin') {
272
+ if (focusInsideDisabled) {
273
+ enableFocusInside(node);
274
+ focusInsideDisabled = false;
275
+ }
276
+ }
277
+ else {
278
+ disableFocusInside(node);
279
+ focusInsideDisabled = true;
280
+ }
281
+ };
282
+ if (isOutsideEvent(new FocusEvent('focusin', { relatedTarget: ownerDocument.activeElement }), node)) {
283
+ disableFocusInside(node);
284
+ focusInsideDisabled = true;
285
+ }
286
+ node.addEventListener('focusin', onFocus, true);
287
+ node.addEventListener('focusout', onFocus, true);
288
+ onCleanup(() => {
289
+ node.removeEventListener('focusin', onFocus, true);
290
+ node.removeEventListener('focusout', onFocus, true);
291
+ if (focusInsideDisabled) {
292
+ enableFocusInside(node);
293
+ focusInsideDisabled = false;
294
+ }
295
+ });
296
+ });
297
+ }
298
+
299
+ const RdxFocusScopeConfigToken = new InjectionToken('RdxFocusScopeConfig', {
300
+ factory: () => ({
301
+ trapped: signal(false)
302
+ })
303
+ });
304
+ function provideRdxFocusScopeConfig(factory) {
305
+ return { provide: RdxFocusScopeConfigToken, useFactory: factory };
306
+ }
307
+
308
+ /**
309
+ * The active-scope stack pauses/resumes scopes, so it **is** cross-document coordination state — keyed
310
+ * per owner `Document` (a `WeakMap`) rather than process-global (ADR 0017 Phase 1a): opening a scope in
311
+ * document B must not pause document A's scope.
312
+ */
313
+ const stacksByDocument = new WeakMap();
314
+ function getFocusStackState(document) {
315
+ let state = stacksByDocument.get(document);
316
+ if (!state) {
317
+ state = signal([]);
318
+ stacksByDocument.set(document, state);
319
+ }
320
+ return state;
321
+ }
322
+ function createFocusScopesStack(document) {
323
+ /** A stack of focus scopes for this document, with the active one at the top */
324
+ const stack = getFocusStackState(document);
325
+ return {
326
+ add(focusScope) {
327
+ const current = stack();
328
+ const active = current[0];
329
+ if (focusScope !== active) {
330
+ active?.pause();
331
+ }
332
+ const updated = arrayRemove(current, focusScope);
333
+ updated.unshift(focusScope);
334
+ stack.set(updated);
335
+ },
336
+ remove(focusScope) {
337
+ const current = stack();
338
+ const updated = arrayRemove(current, focusScope);
339
+ stack.set(updated);
340
+ // после удаления «возобновляем» новый верхний
341
+ stack()[0]?.resume();
342
+ }
343
+ };
344
+ }
345
+ function arrayRemove(array, item) {
346
+ const copy = [...array];
347
+ const idx = copy.indexOf(item);
348
+ if (idx !== -1) {
349
+ copy.splice(idx, 1);
350
+ }
351
+ return copy;
352
+ }
353
+ function removeLinks(items) {
354
+ return items.filter((el) => el.tagName !== 'A');
355
+ }
356
+
149
357
  const [injectFocusScopeContext, provideFocusScopeContext] = createContext('FocusScope Context', 'utils/focus-scope');
150
358
  const rootContext = () => {
151
359
  const context = inject(RdxFocusScope);
@@ -163,6 +371,8 @@ class RdxFocusScope {
163
371
  this.destroyRef = inject(DestroyRef);
164
372
  this.elementRef = inject(ElementRef);
165
373
  this.config = inject(RdxFocusScopeConfigToken);
374
+ /** The host's owner `Document` — all focus listeners / reads are scoped here, never global `document`. */
375
+ this.ownerDocument = this.elementRef.nativeElement.ownerDocument ?? document;
166
376
  /**
167
377
  * When `true`, tabbing from last item will focus first tabbable
168
378
  * and shift+tab from first item will focus last tababble.
@@ -195,7 +405,7 @@ class RdxFocusScope {
195
405
  */
196
406
  this.unmountAutoFocus = output();
197
407
  this.lastFocusedElement = signal(null, ...(ngDevMode ? [{ debugName: "lastFocusedElement" }] : /* istanbul ignore next */ []));
198
- this.focusScopesStack = createFocusScopesStack();
408
+ this.focusScopesStack = createFocusScopesStack(this.ownerDocument);
199
409
  this.focusScope = {
200
410
  paused: signal(false),
201
411
  pause: () => this.focusScope.paused.set(true),
@@ -213,8 +423,8 @@ class RdxFocusScope {
213
423
  if (this.focusScope.paused() || !container) {
214
424
  return;
215
425
  }
216
- const target = event.target;
217
- if (this.elementRef.nativeElement.contains(target)) {
426
+ const target = getEventTarget(event);
427
+ if (composedContains(container, target)) {
218
428
  this.lastFocusedElement.set(target);
219
429
  }
220
430
  else {
@@ -240,12 +450,12 @@ class RdxFocusScope {
240
450
  return;
241
451
  // If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
242
452
  // that is outside the container, we move focus to the last valid focused element inside.
243
- if (!container.contains(relatedTarget)) {
453
+ if (!composedContains(container, relatedTarget)) {
244
454
  focus(this.lastFocusedElement(), { select: true });
245
455
  }
246
456
  };
247
457
  const handleMutations = () => {
248
- const isLastFocusedElementExist = container.contains(this.lastFocusedElement());
458
+ const isLastFocusedElementExist = composedContains(container, this.lastFocusedElement());
249
459
  if (!isLastFocusedElementExist) {
250
460
  focus(container);
251
461
  }
@@ -254,11 +464,11 @@ class RdxFocusScope {
254
464
  if (container) {
255
465
  mutationObserver.observe(container, { childList: true, subtree: true });
256
466
  }
257
- document.addEventListener('focusin', handleFocusIn);
258
- document.addEventListener('focusout', handleFocusOut);
467
+ this.ownerDocument.addEventListener('focusin', handleFocusIn);
468
+ this.ownerDocument.addEventListener('focusout', handleFocusOut);
259
469
  onCleanup(() => {
260
- document.removeEventListener('focusin', handleFocusIn);
261
- document.removeEventListener('focusout', handleFocusOut);
470
+ this.ownerDocument.removeEventListener('focusin', handleFocusIn);
471
+ this.ownerDocument.removeEventListener('focusout', handleFocusOut);
262
472
  mutationObserver.disconnect();
263
473
  });
264
474
  }
@@ -270,8 +480,8 @@ class RdxFocusScope {
270
480
  return;
271
481
  }
272
482
  this.focusScopesStack.add(this.focusScope);
273
- const previouslyFocusedElement = getActiveElement();
274
- const hasFocusedCandidate = container.contains(previouslyFocusedElement);
483
+ const previouslyFocusedElement = getActiveElement(this.ownerDocument);
484
+ const hasFocusedCandidate = composedContains(container, previouslyFocusedElement);
275
485
  const mountEventHandler = (ev) => {
276
486
  if (this.alive)
277
487
  this.mountAutoFocus.emit(ev);
@@ -284,7 +494,7 @@ class RdxFocusScope {
284
494
  focusFirst(removeLinks(getTabbableCandidates(container)), {
285
495
  select: true
286
496
  });
287
- if (getActiveElement() === previouslyFocusedElement)
497
+ if (getActiveElement(this.ownerDocument) === previouslyFocusedElement)
288
498
  focus(container);
289
499
  }
290
500
  }
@@ -297,20 +507,45 @@ class RdxFocusScope {
297
507
  container.removeEventListener(AUTOFOCUS_ON_MOUNT, mountEventHandler);
298
508
  const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
299
509
  container.dispatchEvent(unmountEvent);
300
- setTimeout(() => {
301
- if (!unmountEvent.defaultPrevented)
302
- focus(previouslyFocusedElement ?? document.body, { select: true });
510
+ // Queue the return-focus on the owner window's animation frame (not `setTimeout`),
511
+ // so it runs after the unmounting paint settles (ADR 0017 Phase 1a queued focus).
512
+ const view = this.ownerDocument.defaultView ?? globalThis;
513
+ view.requestAnimationFrame(() => {
514
+ // An enclosing focus manager can override the return target (ADR 0017
515
+ // `returnFocus`): `false` suppresses it, an element returns there explicitly
516
+ // (bypassing the moved-focus guard), `undefined` keeps the default behavior.
517
+ const override = this.config.returnFocus?.();
518
+ if (override !== false && !unmountEvent.defaultPrevented) {
519
+ if (override) {
520
+ focus(override, { select: true });
521
+ }
522
+ else if (!this.shouldPreserveMovedFocus()) {
523
+ focus(previouslyFocusedElement ?? this.ownerDocument.body, { select: true });
524
+ }
525
+ }
303
526
  // we need to remove the listener after we `dispatchEvent`
304
527
  container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, unmountEventHandler);
305
528
  this.focusScopesStack.remove(this.focusScope);
306
- }, 0);
529
+ });
307
530
  });
308
531
  }, { injector: this.injector });
309
532
  });
310
533
  }
534
+ /**
535
+ * Whether the interaction that unmounted this scope already moved focus to a legitimate element
536
+ * **outside** it — e.g. an outside press onto an interactive control in a non-modal layer (ADR 0017
537
+ * §2, finding #3). Returning focus to the previously-focused element would then *steal* it back from
538
+ * what the user just acted on. Focus that fell to `<body>` / `null` (a backdrop press, Escape, or the
539
+ * focused element being removed) is **not** "moved" — return focus normally so keyboard users land
540
+ * back on the trigger. The page never scroll-jumps either way: {@link focus} uses `preventScroll`.
541
+ */
542
+ shouldPreserveMovedFocus() {
543
+ const active = getActiveElement(this.ownerDocument);
544
+ return (!!active && active !== this.ownerDocument.body && !composedContains(this.elementRef.nativeElement, active));
545
+ }
311
546
  handleKeyDown(event) {
312
547
  const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
313
- const focusedElement = getActiveElement();
548
+ const focusedElement = getActiveElement(this.ownerDocument);
314
549
  if (isTabKey && focusedElement) {
315
550
  const container = event.currentTarget;
316
551
  const [first, last] = getTabbableEdges(container);
@@ -355,5 +590,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
355
590
  * Generated bundle index. Do not edit.
356
591
  */
357
592
 
358
- export { RdxFocusScope, RdxFocusScopeConfigToken, injectFocusScopeContext, provideFocusScopeContext, provideRdxFocusScopeConfig };
593
+ export { AUTOFOCUS_ON_MOUNT, AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS, FOCUS_GUARD_ATTR, FOCUS_GUARD_STYLE, RdxFocusScope, RdxFocusScopeConfigToken, composedContains, createAriaOwnsAnchor, createFocusGuard, disableFocusInside, enableFocusInside, findVisible, focus, focusFirst, getEventTarget, getNextTabbable, getPreviousTabbable, getTabbableAfterElement, getTabbableBeforeElement, getTabbableCandidates, getTabbableEdges, injectFocusScopeContext, isHidden, isOutsideEvent, isSelectableInput, provideFocusScopeContext, provideRdxFocusScopeConfig, useFocusGuardsTabbability };
359
594
  //# sourceMappingURL=radix-ng-primitives-focus-scope.mjs.map