@radix-ng/primitives 1.0.0-beta.3 → 1.0.0-beta.4
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.
- package/README.md +1 -1
- package/fesm2022/radix-ng-primitives-accordion.mjs +5 -3
- package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-alert-dialog.mjs +3 -2
- package/fesm2022/radix-ng-primitives-alert-dialog.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-autocomplete.mjs +617 -659
- package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-calendar.mjs +5 -3
- package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-combobox.mjs +1305 -572
- package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-config.mjs +13 -4
- package/fesm2022/radix-ng-primitives-config.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-context-menu.mjs +51 -10
- package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-core.mjs +1345 -64
- package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-date-field.mjs +5 -3
- package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-dialog.mjs +240 -112
- package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-direction-provider.mjs +70 -0
- package/fesm2022/radix-ng-primitives-direction-provider.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-dismissable-layer.mjs +519 -184
- package/fesm2022/radix-ng-primitives-dismissable-layer.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-drawer.mjs +3 -3
- package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-field.mjs +3 -2
- package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +517 -0
- package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-focus-scope.mjs +296 -70
- package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menu.mjs +861 -286
- package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menubar.mjs +32 -4
- package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs +144 -159
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popover.mjs +220 -205
- package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popper.mjs +94 -51
- package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-presence.mjs +1 -1
- package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-preview-card.mjs +141 -173
- package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-roving-focus.mjs +4 -2
- package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-scroll-area.mjs +5 -4
- package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-select.mjs +211 -156
- package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-slider.mjs +5 -3
- package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-stepper.mjs +5 -3
- package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-time-field.mjs +5 -3
- package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toast.mjs +15 -36
- package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toggle-group.mjs +5 -3
- package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toolbar.mjs +5 -3
- package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-tooltip.mjs +73 -110
- package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
- package/package.json +10 -1
- package/types/radix-ng-primitives-accordion.d.ts +4 -3
- package/types/radix-ng-primitives-autocomplete.d.ts +217 -152
- package/types/radix-ng-primitives-calendar.d.ts +5 -3
- package/types/radix-ng-primitives-combobox.d.ts +672 -283
- package/types/radix-ng-primitives-config.d.ts +1 -1
- package/types/radix-ng-primitives-context-menu.d.ts +15 -5
- package/types/radix-ng-primitives-core.d.ts +762 -14
- package/types/radix-ng-primitives-date-field.d.ts +3 -2
- package/types/radix-ng-primitives-dialog.d.ts +77 -32
- package/types/radix-ng-primitives-direction-provider.d.ts +41 -0
- package/types/radix-ng-primitives-dismissable-layer.d.ts +147 -99
- package/types/radix-ng-primitives-field.d.ts +1 -0
- package/types/radix-ng-primitives-floating-focus-manager.d.ts +175 -0
- package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
- package/types/radix-ng-primitives-menu.d.ts +186 -103
- package/types/radix-ng-primitives-navigation-menu.d.ts +37 -75
- package/types/radix-ng-primitives-popover.d.ts +59 -92
- package/types/radix-ng-primitives-popper.d.ts +39 -9
- package/types/radix-ng-primitives-preview-card.d.ts +39 -72
- package/types/radix-ng-primitives-roving-focus.d.ts +7 -6
- package/types/radix-ng-primitives-scroll-area.d.ts +2 -2
- package/types/radix-ng-primitives-select.d.ts +145 -108
- package/types/radix-ng-primitives-slider.d.ts +5 -4
- package/types/radix-ng-primitives-stepper.d.ts +4 -3
- package/types/radix-ng-primitives-time-field.d.ts +3 -2
- package/types/radix-ng-primitives-toast.d.ts +7 -7
- package/types/radix-ng-primitives-toggle-group.d.ts +5 -4
- package/types/radix-ng-primitives-toolbar.d.ts +3 -2
- package/types/radix-ng-primitives-tooltip.d.ts +24 -67
|
@@ -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,
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,181 @@ 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 onFocus = (event) => {
|
|
266
|
+
// Only react to focus actually crossing the portal boundary.
|
|
267
|
+
if (!event.relatedTarget || !isOutsideEvent(event, node)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (event.type === 'focusin') {
|
|
271
|
+
if (focusInsideDisabled) {
|
|
272
|
+
enableFocusInside(node);
|
|
273
|
+
focusInsideDisabled = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
disableFocusInside(node);
|
|
278
|
+
focusInsideDisabled = true;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
node.addEventListener('focusin', onFocus, true);
|
|
282
|
+
node.addEventListener('focusout', onFocus, true);
|
|
283
|
+
onCleanup(() => {
|
|
284
|
+
node.removeEventListener('focusin', onFocus, true);
|
|
285
|
+
node.removeEventListener('focusout', onFocus, true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const RdxFocusScopeConfigToken = new InjectionToken('RdxFocusScopeConfig', {
|
|
291
|
+
factory: () => ({
|
|
292
|
+
trapped: signal(false)
|
|
293
|
+
})
|
|
294
|
+
});
|
|
295
|
+
function provideRdxFocusScopeConfig(factory) {
|
|
296
|
+
return { provide: RdxFocusScopeConfigToken, useFactory: factory };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The active-scope stack pauses/resumes scopes, so it **is** cross-document coordination state — keyed
|
|
301
|
+
* per owner `Document` (a `WeakMap`) rather than process-global (ADR 0017 Phase 1a): opening a scope in
|
|
302
|
+
* document B must not pause document A's scope.
|
|
303
|
+
*/
|
|
304
|
+
const stacksByDocument = new WeakMap();
|
|
305
|
+
function getFocusStackState(document) {
|
|
306
|
+
let state = stacksByDocument.get(document);
|
|
307
|
+
if (!state) {
|
|
308
|
+
state = signal([]);
|
|
309
|
+
stacksByDocument.set(document, state);
|
|
310
|
+
}
|
|
311
|
+
return state;
|
|
312
|
+
}
|
|
313
|
+
function createFocusScopesStack(document) {
|
|
314
|
+
/** A stack of focus scopes for this document, with the active one at the top */
|
|
315
|
+
const stack = getFocusStackState(document);
|
|
316
|
+
return {
|
|
317
|
+
add(focusScope) {
|
|
318
|
+
const current = stack();
|
|
319
|
+
const active = current[0];
|
|
320
|
+
if (focusScope !== active) {
|
|
321
|
+
active?.pause();
|
|
322
|
+
}
|
|
323
|
+
const updated = arrayRemove(current, focusScope);
|
|
324
|
+
updated.unshift(focusScope);
|
|
325
|
+
stack.set(updated);
|
|
326
|
+
},
|
|
327
|
+
remove(focusScope) {
|
|
328
|
+
const current = stack();
|
|
329
|
+
const updated = arrayRemove(current, focusScope);
|
|
330
|
+
stack.set(updated);
|
|
331
|
+
// после удаления «возобновляем» новый верхний
|
|
332
|
+
stack()[0]?.resume();
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function arrayRemove(array, item) {
|
|
337
|
+
const copy = [...array];
|
|
338
|
+
const idx = copy.indexOf(item);
|
|
339
|
+
if (idx !== -1) {
|
|
340
|
+
copy.splice(idx, 1);
|
|
341
|
+
}
|
|
342
|
+
return copy;
|
|
343
|
+
}
|
|
344
|
+
function removeLinks(items) {
|
|
345
|
+
return items.filter((el) => el.tagName !== 'A');
|
|
346
|
+
}
|
|
347
|
+
|
|
149
348
|
const [injectFocusScopeContext, provideFocusScopeContext] = createContext('FocusScope Context', 'utils/focus-scope');
|
|
150
349
|
const rootContext = () => {
|
|
151
350
|
const context = inject(RdxFocusScope);
|
|
@@ -163,6 +362,8 @@ class RdxFocusScope {
|
|
|
163
362
|
this.destroyRef = inject(DestroyRef);
|
|
164
363
|
this.elementRef = inject(ElementRef);
|
|
165
364
|
this.config = inject(RdxFocusScopeConfigToken);
|
|
365
|
+
/** The host's owner `Document` — all focus listeners / reads are scoped here, never global `document`. */
|
|
366
|
+
this.ownerDocument = this.elementRef.nativeElement.ownerDocument ?? document;
|
|
166
367
|
/**
|
|
167
368
|
* When `true`, tabbing from last item will focus first tabbable
|
|
168
369
|
* and shift+tab from first item will focus last tababble.
|
|
@@ -195,7 +396,7 @@ class RdxFocusScope {
|
|
|
195
396
|
*/
|
|
196
397
|
this.unmountAutoFocus = output();
|
|
197
398
|
this.lastFocusedElement = signal(null, ...(ngDevMode ? [{ debugName: "lastFocusedElement" }] : /* istanbul ignore next */ []));
|
|
198
|
-
this.focusScopesStack = createFocusScopesStack();
|
|
399
|
+
this.focusScopesStack = createFocusScopesStack(this.ownerDocument);
|
|
199
400
|
this.focusScope = {
|
|
200
401
|
paused: signal(false),
|
|
201
402
|
pause: () => this.focusScope.paused.set(true),
|
|
@@ -213,8 +414,8 @@ class RdxFocusScope {
|
|
|
213
414
|
if (this.focusScope.paused() || !container) {
|
|
214
415
|
return;
|
|
215
416
|
}
|
|
216
|
-
const target = event
|
|
217
|
-
if (
|
|
417
|
+
const target = getEventTarget(event);
|
|
418
|
+
if (composedContains(container, target)) {
|
|
218
419
|
this.lastFocusedElement.set(target);
|
|
219
420
|
}
|
|
220
421
|
else {
|
|
@@ -240,12 +441,12 @@ class RdxFocusScope {
|
|
|
240
441
|
return;
|
|
241
442
|
// If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
|
|
242
443
|
// that is outside the container, we move focus to the last valid focused element inside.
|
|
243
|
-
if (!container
|
|
444
|
+
if (!composedContains(container, relatedTarget)) {
|
|
244
445
|
focus(this.lastFocusedElement(), { select: true });
|
|
245
446
|
}
|
|
246
447
|
};
|
|
247
448
|
const handleMutations = () => {
|
|
248
|
-
const isLastFocusedElementExist = container
|
|
449
|
+
const isLastFocusedElementExist = composedContains(container, this.lastFocusedElement());
|
|
249
450
|
if (!isLastFocusedElementExist) {
|
|
250
451
|
focus(container);
|
|
251
452
|
}
|
|
@@ -254,11 +455,11 @@ class RdxFocusScope {
|
|
|
254
455
|
if (container) {
|
|
255
456
|
mutationObserver.observe(container, { childList: true, subtree: true });
|
|
256
457
|
}
|
|
257
|
-
|
|
258
|
-
|
|
458
|
+
this.ownerDocument.addEventListener('focusin', handleFocusIn);
|
|
459
|
+
this.ownerDocument.addEventListener('focusout', handleFocusOut);
|
|
259
460
|
onCleanup(() => {
|
|
260
|
-
|
|
261
|
-
|
|
461
|
+
this.ownerDocument.removeEventListener('focusin', handleFocusIn);
|
|
462
|
+
this.ownerDocument.removeEventListener('focusout', handleFocusOut);
|
|
262
463
|
mutationObserver.disconnect();
|
|
263
464
|
});
|
|
264
465
|
}
|
|
@@ -270,8 +471,8 @@ class RdxFocusScope {
|
|
|
270
471
|
return;
|
|
271
472
|
}
|
|
272
473
|
this.focusScopesStack.add(this.focusScope);
|
|
273
|
-
const previouslyFocusedElement = getActiveElement();
|
|
274
|
-
const hasFocusedCandidate = container
|
|
474
|
+
const previouslyFocusedElement = getActiveElement(this.ownerDocument);
|
|
475
|
+
const hasFocusedCandidate = composedContains(container, previouslyFocusedElement);
|
|
275
476
|
const mountEventHandler = (ev) => {
|
|
276
477
|
if (this.alive)
|
|
277
478
|
this.mountAutoFocus.emit(ev);
|
|
@@ -284,7 +485,7 @@ class RdxFocusScope {
|
|
|
284
485
|
focusFirst(removeLinks(getTabbableCandidates(container)), {
|
|
285
486
|
select: true
|
|
286
487
|
});
|
|
287
|
-
if (getActiveElement() === previouslyFocusedElement)
|
|
488
|
+
if (getActiveElement(this.ownerDocument) === previouslyFocusedElement)
|
|
288
489
|
focus(container);
|
|
289
490
|
}
|
|
290
491
|
}
|
|
@@ -297,20 +498,45 @@ class RdxFocusScope {
|
|
|
297
498
|
container.removeEventListener(AUTOFOCUS_ON_MOUNT, mountEventHandler);
|
|
298
499
|
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
|
|
299
500
|
container.dispatchEvent(unmountEvent);
|
|
300
|
-
setTimeout
|
|
301
|
-
|
|
302
|
-
|
|
501
|
+
// Queue the return-focus on the owner window's animation frame (not `setTimeout`),
|
|
502
|
+
// so it runs after the unmounting paint settles (ADR 0017 Phase 1a queued focus).
|
|
503
|
+
const view = this.ownerDocument.defaultView ?? globalThis;
|
|
504
|
+
view.requestAnimationFrame(() => {
|
|
505
|
+
// An enclosing focus manager can override the return target (ADR 0017
|
|
506
|
+
// `returnFocus`): `false` suppresses it, an element returns there explicitly
|
|
507
|
+
// (bypassing the moved-focus guard), `undefined` keeps the default behavior.
|
|
508
|
+
const override = this.config.returnFocus?.();
|
|
509
|
+
if (override !== false && !unmountEvent.defaultPrevented) {
|
|
510
|
+
if (override) {
|
|
511
|
+
focus(override, { select: true });
|
|
512
|
+
}
|
|
513
|
+
else if (!this.shouldPreserveMovedFocus()) {
|
|
514
|
+
focus(previouslyFocusedElement ?? this.ownerDocument.body, { select: true });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
303
517
|
// we need to remove the listener after we `dispatchEvent`
|
|
304
518
|
container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, unmountEventHandler);
|
|
305
519
|
this.focusScopesStack.remove(this.focusScope);
|
|
306
|
-
}
|
|
520
|
+
});
|
|
307
521
|
});
|
|
308
522
|
}, { injector: this.injector });
|
|
309
523
|
});
|
|
310
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Whether the interaction that unmounted this scope already moved focus to a legitimate element
|
|
527
|
+
* **outside** it — e.g. an outside press onto an interactive control in a non-modal layer (ADR 0017
|
|
528
|
+
* §2, finding #3). Returning focus to the previously-focused element would then *steal* it back from
|
|
529
|
+
* what the user just acted on. Focus that fell to `<body>` / `null` (a backdrop press, Escape, or the
|
|
530
|
+
* focused element being removed) is **not** "moved" — return focus normally so keyboard users land
|
|
531
|
+
* back on the trigger. The page never scroll-jumps either way: {@link focus} uses `preventScroll`.
|
|
532
|
+
*/
|
|
533
|
+
shouldPreserveMovedFocus() {
|
|
534
|
+
const active = getActiveElement(this.ownerDocument);
|
|
535
|
+
return (!!active && active !== this.ownerDocument.body && !composedContains(this.elementRef.nativeElement, active));
|
|
536
|
+
}
|
|
311
537
|
handleKeyDown(event) {
|
|
312
538
|
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
|
313
|
-
const focusedElement = getActiveElement();
|
|
539
|
+
const focusedElement = getActiveElement(this.ownerDocument);
|
|
314
540
|
if (isTabKey && focusedElement) {
|
|
315
541
|
const container = event.currentTarget;
|
|
316
542
|
const [first, last] = getTabbableEdges(container);
|
|
@@ -355,5 +581,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
|
|
|
355
581
|
* Generated bundle index. Do not edit.
|
|
356
582
|
*/
|
|
357
583
|
|
|
358
|
-
export { RdxFocusScope, RdxFocusScopeConfigToken, injectFocusScopeContext, provideFocusScopeContext, provideRdxFocusScopeConfig };
|
|
584
|
+
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
585
|
//# sourceMappingURL=radix-ng-primitives-focus-scope.mjs.map
|