@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.
- 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-checkbox.mjs +33 -18
- package/fesm2022/radix-ng-primitives-checkbox.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 +1352 -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 +290 -120
- 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-editable.mjs +12 -7
- package/fesm2022/radix-ng-primitives-editable.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 +803 -0
- package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-focus-scope.mjs +305 -70
- package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menu.mjs +893 -289
- 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-number-field.mjs +7 -2
- package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popover.mjs +284 -212
- 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-radio.mjs +19 -14
- package/fesm2022/radix-ng-primitives-radio.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 +241 -164
- package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-slider.mjs +262 -29
- package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-stepper.mjs +16 -10
- package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-switch.mjs +10 -5
- package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-tabs.mjs +15 -10
- package/fesm2022/radix-ng-primitives-tabs.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 +14 -7
- package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toggle.mjs +12 -6
- package/fesm2022/radix-ng-primitives-toggle.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 +251 -143
- 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-checkbox.d.ts +27 -15
- 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 +764 -14
- package/types/radix-ng-primitives-date-field.d.ts +3 -2
- package/types/radix-ng-primitives-dialog.d.ts +88 -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-editable.d.ts +11 -5
- package/types/radix-ng-primitives-field.d.ts +1 -0
- package/types/radix-ng-primitives-floating-focus-manager.d.ts +272 -0
- package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
- package/types/radix-ng-primitives-menu.d.ts +192 -103
- package/types/radix-ng-primitives-navigation-menu.d.ts +37 -75
- package/types/radix-ng-primitives-number-field.d.ts +8 -3
- package/types/radix-ng-primitives-popover.d.ts +71 -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-radio.d.ts +13 -6
- 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 +142 -109
- package/types/radix-ng-primitives-slider.d.ts +64 -12
- package/types/radix-ng-primitives-stepper.d.ts +15 -7
- package/types/radix-ng-primitives-switch.d.ts +10 -4
- package/types/radix-ng-primitives-tabs.d.ts +12 -6
- 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 +15 -8
- package/types/radix-ng-primitives-toggle.d.ts +10 -3
- package/types/radix-ng-primitives-toolbar.d.ts +3 -2
- 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,
|
|
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,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
|
|
217
|
-
if (
|
|
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
|
|
453
|
+
if (!composedContains(container, relatedTarget)) {
|
|
244
454
|
focus(this.lastFocusedElement(), { select: true });
|
|
245
455
|
}
|
|
246
456
|
};
|
|
247
457
|
const handleMutations = () => {
|
|
248
|
-
const isLastFocusedElementExist = container
|
|
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
|
-
|
|
258
|
-
|
|
467
|
+
this.ownerDocument.addEventListener('focusin', handleFocusIn);
|
|
468
|
+
this.ownerDocument.addEventListener('focusout', handleFocusOut);
|
|
259
469
|
onCleanup(() => {
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
}
|
|
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
|