@fivem-nui/svelte 0.0.0-stage → 1.0.0
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 +284 -2
- package/dist/actions/clickOutside/clickOutside.d.ts +24 -0
- package/dist/actions/clickOutside/clickOutside.d.ts.map +1 -0
- package/dist/actions/clickOutside/clickOutside.js +62 -0
- package/dist/actions/clickOutside/clickOutside.js.map +1 -0
- package/dist/actions/clickOutside/handler.d.ts +11 -0
- package/dist/actions/clickOutside/handler.d.ts.map +1 -0
- package/dist/actions/clickOutside/handler.js +45 -0
- package/dist/actions/clickOutside/handler.js.map +1 -0
- package/dist/actions/clickOutside/index.d.ts +4 -0
- package/dist/actions/clickOutside/index.d.ts.map +1 -0
- package/dist/actions/clickOutside/index.js +4 -0
- package/dist/actions/clickOutside/index.js.map +1 -0
- package/dist/actions/clickOutside/types.d.ts +36 -0
- package/dist/actions/clickOutside/types.d.ts.map +1 -0
- package/dist/actions/clickOutside/types.js +6 -0
- package/dist/actions/clickOutside/types.js.map +1 -0
- package/dist/actions/clickOutside.d.ts +63 -0
- package/dist/actions/clickOutside.d.ts.map +1 -0
- package/dist/actions/clickOutside.js +106 -0
- package/dist/actions/clickOutside.js.map +1 -0
- package/dist/actions/escape/escape.d.ts +26 -0
- package/dist/actions/escape/escape.d.ts.map +1 -0
- package/dist/actions/escape/escape.js +74 -0
- package/dist/actions/escape/escape.js.map +1 -0
- package/dist/actions/escape/handler.d.ts +11 -0
- package/dist/actions/escape/handler.d.ts.map +1 -0
- package/dist/actions/escape/handler.js +50 -0
- package/dist/actions/escape/handler.js.map +1 -0
- package/dist/actions/escape/index.d.ts +4 -0
- package/dist/actions/escape/index.d.ts.map +1 -0
- package/dist/actions/escape/index.js +4 -0
- package/dist/actions/escape/index.js.map +1 -0
- package/dist/actions/escape/types.d.ts +70 -0
- package/dist/actions/escape/types.d.ts.map +1 -0
- package/dist/actions/escape/types.js +12 -0
- package/dist/actions/escape/types.js.map +1 -0
- package/dist/actions/escape.d.ts +99 -0
- package/dist/actions/escape.d.ts.map +1 -0
- package/dist/actions/escape.js +128 -0
- package/dist/actions/escape.js.map +1 -0
- package/dist/actions/focusTrap/focusTrap.d.ts +19 -0
- package/dist/actions/focusTrap/focusTrap.d.ts.map +1 -0
- package/dist/actions/focusTrap/focusTrap.js +198 -0
- package/dist/actions/focusTrap/focusTrap.js.map +1 -0
- package/dist/actions/focusTrap/focusables.d.ts +39 -0
- package/dist/actions/focusTrap/focusables.d.ts.map +1 -0
- package/dist/actions/focusTrap/focusables.js +142 -0
- package/dist/actions/focusTrap/focusables.js.map +1 -0
- package/dist/actions/focusTrap/index.d.ts +5 -0
- package/dist/actions/focusTrap/index.d.ts.map +1 -0
- package/dist/actions/focusTrap/index.js +5 -0
- package/dist/actions/focusTrap/index.js.map +1 -0
- package/dist/actions/focusTrap/trapStack.d.ts +33 -0
- package/dist/actions/focusTrap/trapStack.d.ts.map +1 -0
- package/dist/actions/focusTrap/trapStack.js +84 -0
- package/dist/actions/focusTrap/trapStack.js.map +1 -0
- package/dist/actions/focusTrap/types.d.ts +50 -0
- package/dist/actions/focusTrap/types.d.ts.map +1 -0
- package/dist/actions/focusTrap/types.js +10 -0
- package/dist/actions/focusTrap/types.js.map +1 -0
- package/dist/actions/focusTrap.d.ts +24 -0
- package/dist/actions/focusTrap.d.ts.map +1 -0
- package/dist/actions/focusTrap.js +201 -0
- package/dist/actions/focusTrap.js.map +1 -0
- package/dist/actions/focusTrap.types.d.ts +50 -0
- package/dist/actions/focusTrap.types.d.ts.map +1 -0
- package/dist/actions/focusTrap.types.js +10 -0
- package/dist/actions/focusTrap.types.js.map +1 -0
- package/dist/actions/focusables.d.ts +39 -0
- package/dist/actions/focusables.d.ts.map +1 -0
- package/dist/actions/focusables.js +142 -0
- package/dist/actions/focusables.js.map +1 -0
- package/dist/actions/index.d.ts +5 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +5 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/layer/index.d.ts +4 -0
- package/dist/actions/layer/index.d.ts.map +1 -0
- package/dist/actions/layer/index.js +4 -0
- package/dist/actions/layer/index.js.map +1 -0
- package/dist/actions/layer/layer.d.ts +28 -0
- package/dist/actions/layer/layer.d.ts.map +1 -0
- package/dist/actions/layer/layer.js +118 -0
- package/dist/actions/layer/layer.js.map +1 -0
- package/dist/actions/layer/layerStack.d.ts +10 -0
- package/dist/actions/layer/layerStack.d.ts.map +1 -0
- package/dist/actions/layer/layerStack.js +153 -0
- package/dist/actions/layer/layerStack.js.map +1 -0
- package/dist/actions/layer/types.d.ts +105 -0
- package/dist/actions/layer/types.d.ts.map +1 -0
- package/dist/actions/layer/types.js +14 -0
- package/dist/actions/layer/types.js.map +1 -0
- package/dist/actions/layer.d.ts +133 -0
- package/dist/actions/layer.d.ts.map +1 -0
- package/dist/actions/layer.js +269 -0
- package/dist/actions/layer.js.map +1 -0
- package/dist/actions/trapStack.d.ts +33 -0
- package/dist/actions/trapStack.d.ts.map +1 -0
- package/dist/actions/trapStack.js +84 -0
- package/dist/actions/trapStack.js.map +1 -0
- package/dist/client.d.ts +179 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +176 -0
- package/dist/client.js.map +1 -0
- package/dist/context/index.d.ts +68 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +112 -0
- package/dist/context/index.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/mock/index.d.ts +27 -0
- package/dist/mock/index.d.ts.map +1 -0
- package/dist/mock/index.js +137 -0
- package/dist/mock/index.js.map +1 -0
- package/dist/mock/interceptor.d.ts +19 -0
- package/dist/mock/interceptor.d.ts.map +1 -0
- package/dist/mock/interceptor.js +128 -0
- package/dist/mock/interceptor.js.map +1 -0
- package/dist/mock/types.d.ts +100 -0
- package/dist/mock/types.d.ts.map +1 -0
- package/dist/mock/types.js +27 -0
- package/dist/mock/types.js.map +1 -0
- package/dist/mock/url.d.ts +14 -0
- package/dist/mock/url.d.ts.map +1 -0
- package/dist/mock/url.js +38 -0
- package/dist/mock/url.js.map +1 -0
- package/dist/runes/clickOutside.svelte.d.ts +29 -0
- package/dist/runes/clickOutside.svelte.d.ts.map +1 -0
- package/dist/runes/clickOutside.svelte.js +42 -0
- package/dist/runes/clickOutside.svelte.js.map +1 -0
- package/dist/runes/escape.svelte.d.ts +34 -0
- package/dist/runes/escape.svelte.d.ts.map +1 -0
- package/dist/runes/escape.svelte.js +44 -0
- package/dist/runes/escape.svelte.js.map +1 -0
- package/dist/runes/focusTrap.svelte.d.ts +21 -0
- package/dist/runes/focusTrap.svelte.d.ts.map +1 -0
- package/dist/runes/focusTrap.svelte.js +37 -0
- package/dist/runes/focusTrap.svelte.js.map +1 -0
- package/dist/runes/index.svelte.d.ts +8 -0
- package/dist/runes/index.svelte.d.ts.map +1 -0
- package/dist/runes/index.svelte.js +8 -0
- package/dist/runes/index.svelte.js.map +1 -0
- package/dist/runes/layer.svelte.d.ts +36 -0
- package/dist/runes/layer.svelte.d.ts.map +1 -0
- package/dist/runes/layer.svelte.js +48 -0
- package/dist/runes/layer.svelte.js.map +1 -0
- package/dist/runes/query.svelte.d.ts +104 -0
- package/dist/runes/query.svelte.d.ts.map +1 -0
- package/dist/runes/query.svelte.js +179 -0
- package/dist/runes/query.svelte.js.map +1 -0
- package/dist/runes/state.svelte.d.ts +72 -0
- package/dist/runes/state.svelte.d.ts.map +1 -0
- package/dist/runes/state.svelte.js +98 -0
- package/dist/runes/state.svelte.js.map +1 -0
- package/dist/runes/window.svelte.d.ts +120 -0
- package/dist/runes/window.svelte.d.ts.map +1 -0
- package/dist/runes/window.svelte.js +196 -0
- package/dist/runes/window.svelte.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/nui.d.ts +34 -0
- package/dist/types/nui.d.ts.map +1 -0
- package/dist/types/nui.js +17 -0
- package/dist/types/nui.js.map +1 -0
- package/dist/utils/env.d.ts +21 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +36 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/error.d.ts +11 -0
- package/dist/utils/error.d.ts.map +1 -0
- package/dist/utils/error.js +30 -0
- package/dist/utils/error.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/keyboard.d.ts +127 -0
- package/dist/utils/keyboard.d.ts.map +1 -0
- package/dist/utils/keyboard.js +152 -0
- package/dist/utils/keyboard.js.map +1 -0
- package/package.json +76 -4
- package/src/actions/clickOutside/clickOutside.ts +78 -0
- package/src/actions/clickOutside/handler.ts +49 -0
- package/src/actions/clickOutside/index.ts +11 -0
- package/src/actions/clickOutside/types.ts +54 -0
- package/src/actions/escape/escape.ts +90 -0
- package/src/actions/escape/handler.ts +52 -0
- package/src/actions/escape/index.ts +11 -0
- package/src/actions/escape/types.ts +88 -0
- package/src/actions/focusTrap/focusTrap.ts +228 -0
- package/src/actions/focusTrap/focusables.ts +155 -0
- package/src/actions/focusTrap/index.ts +22 -0
- package/src/actions/focusTrap/trapStack.ts +109 -0
- package/src/actions/focusTrap/types.ts +64 -0
- package/src/actions/index.ts +35 -0
- package/src/actions/layer/index.ts +13 -0
- package/src/actions/layer/layer.ts +140 -0
- package/src/actions/layer/layerStack.ts +169 -0
- package/src/actions/layer/types.ts +132 -0
- package/src/client.ts +323 -0
- package/src/context/index.ts +127 -0
- package/src/index.ts +53 -0
- package/src/mock/index.ts +176 -0
- package/src/mock/interceptor.ts +177 -0
- package/src/mock/types.ts +125 -0
- package/src/mock/url.ts +37 -0
- package/src/runes/clickOutside.svelte.ts +53 -0
- package/src/runes/escape.svelte.ts +71 -0
- package/src/runes/focusTrap.svelte.ts +56 -0
- package/src/runes/index.svelte.ts +38 -0
- package/src/runes/layer.svelte.ts +74 -0
- package/src/runes/query.svelte.ts +313 -0
- package/src/runes/state.svelte.ts +175 -0
- package/src/runes/window.svelte.ts +308 -0
- package/src/utils/env.ts +36 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/keyboard.ts +244 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
import {
|
|
3
|
+
getFocusables,
|
|
4
|
+
resolveInitialElement,
|
|
5
|
+
handleTabKey
|
|
6
|
+
} from './focusables';
|
|
7
|
+
import {
|
|
8
|
+
type FocusTrapEntry,
|
|
9
|
+
getTopTrap,
|
|
10
|
+
isNodeAllowedOutside,
|
|
11
|
+
pushTrap,
|
|
12
|
+
removeTrap
|
|
13
|
+
} from './trapStack';
|
|
14
|
+
import {
|
|
15
|
+
type NuiFocusTrapInput,
|
|
16
|
+
toFocusTrapOptions
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Svelte Action for trapping Tab key navigation inside an element.
|
|
21
|
+
*
|
|
22
|
+
* Essential in FiveM NUI to prevent keyboard navigation from leaking out of CEF
|
|
23
|
+
* modals or into the background game window.
|
|
24
|
+
*
|
|
25
|
+
* @param node - The container DOM element confining focus.
|
|
26
|
+
* @param parameter - Configuration options or boolean enabled flag.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```svelte
|
|
30
|
+
* <div class="modal" use:nuiFocusTrap={isOpen}>...</div>
|
|
31
|
+
* <div class="dialog" use:nuiFocusTrap={{ enabled: true, allowOutside: '.datepicker-portal' }}>...</div>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export const nuiFocusTrap: Action<HTMLElement, NuiFocusTrapInput> = (
|
|
35
|
+
node,
|
|
36
|
+
parameter
|
|
37
|
+
) => {
|
|
38
|
+
let currentParam = parameter;
|
|
39
|
+
let active = false;
|
|
40
|
+
let previouslyFocused: HTMLElement | null = null;
|
|
41
|
+
let rafId: number | null = null;
|
|
42
|
+
let controller: AbortController | null = null;
|
|
43
|
+
let addedTabindex = false;
|
|
44
|
+
|
|
45
|
+
function isEnabled(): boolean {
|
|
46
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
47
|
+
const { enabled } = opts;
|
|
48
|
+
return typeof enabled === 'function' ? enabled() : (enabled ?? true);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function containsOrAllowed(target: Node | null): boolean {
|
|
52
|
+
if (!target) return false;
|
|
53
|
+
if (node.contains(target)) return true;
|
|
54
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
55
|
+
return isNodeAllowedOutside(target, opts.allowOutside);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function focusFirst() {
|
|
59
|
+
if (!active || !node.isConnected) return;
|
|
60
|
+
|
|
61
|
+
const focusables = getFocusables(node);
|
|
62
|
+
if (focusables.length > 0) {
|
|
63
|
+
if (addedTabindex) {
|
|
64
|
+
node.removeAttribute('tabindex');
|
|
65
|
+
addedTabindex = false;
|
|
66
|
+
}
|
|
67
|
+
focusables[0].focus();
|
|
68
|
+
} else {
|
|
69
|
+
if (!node.hasAttribute('tabindex')) {
|
|
70
|
+
node.setAttribute('tabindex', '-1');
|
|
71
|
+
addedTabindex = true;
|
|
72
|
+
}
|
|
73
|
+
node.focus();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function focusInitial() {
|
|
78
|
+
if (!active || !node.isConnected) return;
|
|
79
|
+
|
|
80
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
81
|
+
if (opts.autofocus === false) return;
|
|
82
|
+
|
|
83
|
+
const targetEl = resolveInitialElement(node, opts.initialFocus);
|
|
84
|
+
if (
|
|
85
|
+
targetEl &&
|
|
86
|
+
targetEl.isConnected &&
|
|
87
|
+
(node.contains(targetEl) || isNodeAllowedOutside(targetEl, opts.allowOutside))
|
|
88
|
+
) {
|
|
89
|
+
if (addedTabindex) {
|
|
90
|
+
node.removeAttribute('tabindex');
|
|
91
|
+
addedTabindex = false;
|
|
92
|
+
}
|
|
93
|
+
targetEl.focus();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!containsOrAllowed(document.activeElement)) {
|
|
98
|
+
focusFirst();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkIsTopmost(): boolean {
|
|
103
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
104
|
+
if (typeof opts.isTopmost === 'function') {
|
|
105
|
+
return opts.isTopmost();
|
|
106
|
+
}
|
|
107
|
+
return getTopTrap()?.node === node;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onFocusIn(event: FocusEvent) {
|
|
111
|
+
if (!active || !node.isConnected) return;
|
|
112
|
+
if (!checkIsTopmost()) return;
|
|
113
|
+
|
|
114
|
+
const target = event.target as Node | null;
|
|
115
|
+
if (!target || containsOrAllowed(target)) return;
|
|
116
|
+
|
|
117
|
+
focusFirst();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
121
|
+
if (!active || !checkIsTopmost()) return;
|
|
122
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
123
|
+
handleTabKey(event, node, opts.allowOutside, focusFirst);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function restoreFocus() {
|
|
127
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
128
|
+
if (opts.returnFocus === false) return;
|
|
129
|
+
|
|
130
|
+
if (opts.returnFocus instanceof HTMLElement && opts.returnFocus.isConnected) {
|
|
131
|
+
opts.returnFocus.focus();
|
|
132
|
+
} else if (previouslyFocused && previouslyFocused.isConnected) {
|
|
133
|
+
previouslyFocused.focus();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const topEntry = getTopTrap();
|
|
137
|
+
if (topEntry && !topEntry.containsOrAllowed(document.activeElement)) {
|
|
138
|
+
topEntry.focusFirst();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function teardown() {
|
|
143
|
+
if (rafId !== null) {
|
|
144
|
+
if (typeof cancelAnimationFrame !== 'undefined') {
|
|
145
|
+
cancelAnimationFrame(rafId);
|
|
146
|
+
}
|
|
147
|
+
rafId = null;
|
|
148
|
+
}
|
|
149
|
+
if (controller) {
|
|
150
|
+
controller.abort();
|
|
151
|
+
controller = null;
|
|
152
|
+
}
|
|
153
|
+
removeTrap(node);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const entry: FocusTrapEntry = {
|
|
157
|
+
node,
|
|
158
|
+
focusFirst,
|
|
159
|
+
containsOrAllowed,
|
|
160
|
+
isTopmost: () => {
|
|
161
|
+
const opts = toFocusTrapOptions(currentParam);
|
|
162
|
+
return typeof opts.isTopmost === 'function' ? opts.isTopmost() : undefined;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function setup() {
|
|
167
|
+
teardown();
|
|
168
|
+
if (!isEnabled()) return;
|
|
169
|
+
|
|
170
|
+
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
171
|
+
if (previouslyFocused && node.contains(previouslyFocused)) {
|
|
172
|
+
previouslyFocused = null;
|
|
173
|
+
}
|
|
174
|
+
active = true;
|
|
175
|
+
pushTrap(entry);
|
|
176
|
+
controller = new AbortController();
|
|
177
|
+
|
|
178
|
+
document.addEventListener('keydown', onKeyDown, { capture: true, signal: controller.signal });
|
|
179
|
+
document.addEventListener('focusin', onFocusIn, { signal: controller.signal });
|
|
180
|
+
|
|
181
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
182
|
+
rafId = requestAnimationFrame(() => {
|
|
183
|
+
rafId = null;
|
|
184
|
+
if (active && node.isConnected) {
|
|
185
|
+
focusInitial();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
} else if (typeof queueMicrotask !== 'undefined') {
|
|
189
|
+
queueMicrotask(() => {
|
|
190
|
+
if (active && node.isConnected) {
|
|
191
|
+
focusInitial();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deactivate() {
|
|
198
|
+
const wasActive = active;
|
|
199
|
+
active = false;
|
|
200
|
+
teardown();
|
|
201
|
+
if (addedTabindex) {
|
|
202
|
+
node.removeAttribute('tabindex');
|
|
203
|
+
addedTabindex = false;
|
|
204
|
+
}
|
|
205
|
+
if (wasActive) {
|
|
206
|
+
restoreFocus();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setup();
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
update(newParam) {
|
|
214
|
+
const wasEnabled = active;
|
|
215
|
+
currentParam = newParam;
|
|
216
|
+
const nowEnabled = isEnabled();
|
|
217
|
+
|
|
218
|
+
if (!wasEnabled && nowEnabled) {
|
|
219
|
+
setup();
|
|
220
|
+
} else if (wasEnabled && !nowEnabled) {
|
|
221
|
+
deactivate();
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
destroy() {
|
|
225
|
+
deactivate();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type FocusTrapAllowOutside, isNodeAllowedOutside } from './trapStack';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CSS selector matching all natively and custom focusable elements in HTML documents.
|
|
5
|
+
*/
|
|
6
|
+
export const FOCUSABLE_SELECTORS = [
|
|
7
|
+
'a[href]',
|
|
8
|
+
'area[href]',
|
|
9
|
+
'input:not([type="hidden"])',
|
|
10
|
+
'select',
|
|
11
|
+
'textarea',
|
|
12
|
+
'button',
|
|
13
|
+
'iframe',
|
|
14
|
+
'summary',
|
|
15
|
+
'audio[controls]',
|
|
16
|
+
'video[controls]',
|
|
17
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
18
|
+
'[tabindex]'
|
|
19
|
+
].join(', ');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns all currently focusable elements in Tab order within a container.
|
|
23
|
+
* Filters out disabled elements (including `<fieldset disabled>`), negative tabindexes,
|
|
24
|
+
* aria-hidden, inert, and hidden elements.
|
|
25
|
+
* Correctly scopes radio button groups per form so only the active radio button is in Tab order.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Assumes DOM order for keyboard navigation. Elements with positive tabindex (> 0)
|
|
29
|
+
* alter standard browser tab sequencing and should generally be avoided.
|
|
30
|
+
*
|
|
31
|
+
* @param container - The root element to query focusable elements from.
|
|
32
|
+
* @returns Array of focusable HTML elements within the container.
|
|
33
|
+
*/
|
|
34
|
+
export function getFocusables(container: HTMLElement): HTMLElement[] {
|
|
35
|
+
const elements = Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS));
|
|
36
|
+
const focusables: HTMLElement[] = [];
|
|
37
|
+
const radioGroups = new Map<HTMLFormElement | null, Map<string, HTMLInputElement>>();
|
|
38
|
+
|
|
39
|
+
for (const el of elements) {
|
|
40
|
+
if (el.matches(':disabled') || el.closest('fieldset[disabled]')) continue;
|
|
41
|
+
if (el.tabIndex < 0) continue;
|
|
42
|
+
if (el.closest('[aria-hidden="true"], [inert]')) continue;
|
|
43
|
+
|
|
44
|
+
if (typeof el.checkVisibility === 'function') {
|
|
45
|
+
if (!el.checkVisibility()) continue;
|
|
46
|
+
} else {
|
|
47
|
+
const isVisible =
|
|
48
|
+
el.offsetWidth > 0 ||
|
|
49
|
+
el.offsetHeight > 0 ||
|
|
50
|
+
el.getClientRects().length > 0 ||
|
|
51
|
+
el.offsetParent !== null;
|
|
52
|
+
if (!isVisible) continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
|
56
|
+
const form = el.form;
|
|
57
|
+
let formRadios = radioGroups.get(form);
|
|
58
|
+
if (!formRadios) {
|
|
59
|
+
formRadios = new Map<string, HTMLInputElement>();
|
|
60
|
+
radioGroups.set(form, formRadios);
|
|
61
|
+
}
|
|
62
|
+
const currentRadio = formRadios.get(el.name);
|
|
63
|
+
if (el.checked) {
|
|
64
|
+
formRadios.set(el.name, el);
|
|
65
|
+
} else if (!currentRadio) {
|
|
66
|
+
formRadios.set(el.name, el);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
focusables.push(el);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (radioGroups.size > 0) {
|
|
75
|
+
const keep = new Set<Element>(focusables);
|
|
76
|
+
for (const formRadios of radioGroups.values()) {
|
|
77
|
+
for (const radio of formRadios.values()) {
|
|
78
|
+
keep.add(radio);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return elements.filter((el) => keep.has(el));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return focusables;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the designated initial focus element from an element reference, selector, or getter.
|
|
89
|
+
*
|
|
90
|
+
* @param container - The container DOM element.
|
|
91
|
+
* @param initialFocus - Target element, selector string, or getter function.
|
|
92
|
+
* @returns The resolved HTMLElement, or null if none was found.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveInitialElement(
|
|
95
|
+
container: HTMLElement,
|
|
96
|
+
initialFocus?: HTMLElement | string | (() => HTMLElement | null | undefined)
|
|
97
|
+
): HTMLElement | null {
|
|
98
|
+
if (!initialFocus) return null;
|
|
99
|
+
if (typeof initialFocus === 'string') {
|
|
100
|
+
return (
|
|
101
|
+
container.querySelector<HTMLElement>(initialFocus) ??
|
|
102
|
+
document.querySelector<HTMLElement>(initialFocus)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (typeof initialFocus === 'function') {
|
|
106
|
+
return initialFocus() ?? null;
|
|
107
|
+
}
|
|
108
|
+
return initialFocus;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Confines Tab and Shift+Tab keyboard navigation within a container element.
|
|
113
|
+
* Wraps around when reaching the start or end of the focus sequence.
|
|
114
|
+
* Skips redirection if the currently focused element is allowed outside (e.g. portaled child).
|
|
115
|
+
*
|
|
116
|
+
* @param event - The keyboard event.
|
|
117
|
+
* @param container - The container element confining focus.
|
|
118
|
+
* @param allowOutside - Elements or selectors permitted to retain focus.
|
|
119
|
+
* @param onEmptyFallback - Callback when no focusable elements exist in container.
|
|
120
|
+
*/
|
|
121
|
+
export function handleTabKey(
|
|
122
|
+
event: KeyboardEvent,
|
|
123
|
+
container: HTMLElement,
|
|
124
|
+
allowOutside?: FocusTrapAllowOutside,
|
|
125
|
+
onEmptyFallback?: () => void
|
|
126
|
+
): void {
|
|
127
|
+
if (event.key !== 'Tab') return;
|
|
128
|
+
|
|
129
|
+
const activeEl = document.activeElement;
|
|
130
|
+
if (isNodeAllowedOutside(activeEl, allowOutside)) return;
|
|
131
|
+
|
|
132
|
+
const focusables = getFocusables(container);
|
|
133
|
+
if (focusables.length === 0) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
if (!container.contains(activeEl)) {
|
|
136
|
+
onEmptyFallback?.();
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const first = focusables[0];
|
|
142
|
+
const last = focusables[focusables.length - 1];
|
|
143
|
+
|
|
144
|
+
if (event.shiftKey) {
|
|
145
|
+
if (activeEl === first || !container.contains(activeEl)) {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
last.focus();
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
if (activeEl === last || !container.contains(activeEl)) {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
first.focus();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { nuiFocusTrap } from './focusTrap';
|
|
2
|
+
export {
|
|
3
|
+
FOCUSABLE_SELECTORS,
|
|
4
|
+
getFocusables,
|
|
5
|
+
resolveInitialElement,
|
|
6
|
+
handleTabKey
|
|
7
|
+
} from './focusables';
|
|
8
|
+
export {
|
|
9
|
+
type FocusTrapAllowOutsideTarget,
|
|
10
|
+
type FocusTrapAllowOutside,
|
|
11
|
+
type FocusTrapEntry,
|
|
12
|
+
getTopTrap,
|
|
13
|
+
resolveAllowOutsideElement,
|
|
14
|
+
isNodeAllowedOutside,
|
|
15
|
+
pushTrap,
|
|
16
|
+
removeTrap
|
|
17
|
+
} from './trapStack';
|
|
18
|
+
export {
|
|
19
|
+
type NuiFocusTrapOptions,
|
|
20
|
+
type NuiFocusTrapInput,
|
|
21
|
+
toFocusTrapOptions
|
|
22
|
+
} from './types';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type FocusTrapAllowOutsideTarget =
|
|
2
|
+
| Element
|
|
3
|
+
| string
|
|
4
|
+
| null
|
|
5
|
+
| undefined
|
|
6
|
+
| (() => Element | null | undefined);
|
|
7
|
+
|
|
8
|
+
export type FocusTrapAllowOutside =
|
|
9
|
+
| FocusTrapAllowOutsideTarget
|
|
10
|
+
| FocusTrapAllowOutsideTarget[];
|
|
11
|
+
|
|
12
|
+
export interface FocusTrapEntry {
|
|
13
|
+
node: HTMLElement;
|
|
14
|
+
focusFirst: () => void;
|
|
15
|
+
containsOrAllowed: (el: Node | null) => boolean;
|
|
16
|
+
isTopmost?: () => boolean | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Global stack of active focus trap entries.
|
|
21
|
+
* Ensures only the topmost trap (e.g. child confirmation dialog or portaled modal)
|
|
22
|
+
* enforces focus containment, preventing nested traps from fighting for focus.
|
|
23
|
+
*/
|
|
24
|
+
const trapStack: FocusTrapEntry[] = [];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Registers an active focus trap entry to the top of the trap stack.
|
|
28
|
+
*/
|
|
29
|
+
export function pushTrap(entry: FocusTrapEntry): void {
|
|
30
|
+
trapStack.push(entry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Removes a focus trap entry associated with the given container node.
|
|
35
|
+
*/
|
|
36
|
+
export function removeTrap(node: HTMLElement): void {
|
|
37
|
+
const idx = trapStack.findIndex((e) => e.node === node);
|
|
38
|
+
if (idx !== -1) {
|
|
39
|
+
trapStack.splice(idx, 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the topmost active focus trap entry.
|
|
45
|
+
* Evaluates custom `isTopmost` predicates first (e.g. from `nuiLayer`), where layer-managed
|
|
46
|
+
* traps with `isTopmost: true` always take precedence over standalone traps.
|
|
47
|
+
* Falls back to activation order (most recently activated connected trap).
|
|
48
|
+
*/
|
|
49
|
+
export function getTopTrap(): FocusTrapEntry | undefined {
|
|
50
|
+
for (let i = trapStack.length - 1; i >= 0; i--) {
|
|
51
|
+
const entry = trapStack[i];
|
|
52
|
+
if (entry.node.isConnected && entry.isTopmost?.() === true) {
|
|
53
|
+
return entry;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (let i = trapStack.length - 1; i >= 0; i--) {
|
|
57
|
+
if (trapStack[i].node.isConnected) {
|
|
58
|
+
return trapStack[i];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves an element, selector, or getter from an `allowOutside` configuration.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveAllowOutsideElement(
|
|
68
|
+
target: FocusTrapAllowOutsideTarget
|
|
69
|
+
): Element | null {
|
|
70
|
+
if (typeof target === 'string') {
|
|
71
|
+
return document.querySelector(target);
|
|
72
|
+
}
|
|
73
|
+
if (typeof target === 'function') {
|
|
74
|
+
return target() ?? null;
|
|
75
|
+
}
|
|
76
|
+
return target ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks whether a given node resides within any of the elements specified in `allowOutside`.
|
|
81
|
+
* Supports selector strings, elements, or reactive getters.
|
|
82
|
+
*/
|
|
83
|
+
export function isNodeAllowedOutside(
|
|
84
|
+
target: Node | null,
|
|
85
|
+
allowOutside?: FocusTrapAllowOutside
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!target || !allowOutside) return false;
|
|
88
|
+
const list = Array.isArray(allowOutside) ? allowOutside : [allowOutside];
|
|
89
|
+
const targetElement = target instanceof Element ? target : target.parentElement;
|
|
90
|
+
|
|
91
|
+
for (const item of list) {
|
|
92
|
+
if (!item) continue;
|
|
93
|
+
if (typeof item === 'string') {
|
|
94
|
+
try {
|
|
95
|
+
if (targetElement?.closest(item)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Invalid selector syntax, ignore
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const el = resolveAllowOutsideElement(item);
|
|
104
|
+
if (el && (el === target || el.contains(target))) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { FocusTrapAllowOutside } from './trapStack';
|
|
2
|
+
|
|
3
|
+
export interface NuiFocusTrapOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the focus trap is actively confining focus.
|
|
6
|
+
* Accepts a boolean or a reactive getter `() => boolean`.
|
|
7
|
+
* @defaultValue true
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean | (() => boolean);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Custom element, selector or getter to receive initial focus upon activation.
|
|
13
|
+
* If omitted, the first focusable element inside the node receives focus.
|
|
14
|
+
*/
|
|
15
|
+
initialFocus?: HTMLElement | string | (() => HTMLElement | null | undefined);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether to restore focus to the previously focused element upon deactivation.
|
|
19
|
+
* Can also be a specific HTMLElement to return focus to.
|
|
20
|
+
* @defaultValue true
|
|
21
|
+
*/
|
|
22
|
+
returnFocus?: boolean | HTMLElement;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether to automatically move focus inside the trap on activation.
|
|
26
|
+
* @defaultValue true
|
|
27
|
+
*/
|
|
28
|
+
autofocus?: boolean;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Elements, selectors, or getters outside the container that are permitted to receive focus
|
|
32
|
+
* without being redirected back into the trap.
|
|
33
|
+
*
|
|
34
|
+
* Essential for portaled child elements such as select dropdowns, date pickers, or tooltips
|
|
35
|
+
* rendered into `document.body` outside the modal container.
|
|
36
|
+
*/
|
|
37
|
+
allowOutside?: FocusTrapAllowOutside;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Optional custom predicate to determine if this trap is currently topmost.
|
|
41
|
+
* When provided, takes precedence over the default `trapStack` activation-order check.
|
|
42
|
+
* Used by coordinators such as `nuiLayer` to synchronize focus trapping with priority order.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* If providing custom `isTopmost` predicates manually, ensure only one active trap
|
|
46
|
+
* evaluates to `true` at any given time to avoid conflicting focus containment.
|
|
47
|
+
* Note that `nuiLayer` automatically controls and overrides this predicate based on layer priority.
|
|
48
|
+
*/
|
|
49
|
+
isTopmost?: () => boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type NuiFocusTrapInput = boolean | NuiFocusTrapOptions | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalizes boolean or option object parameters into `NuiFocusTrapOptions`.
|
|
56
|
+
*/
|
|
57
|
+
export function toFocusTrapOptions(
|
|
58
|
+
input: NuiFocusTrapInput
|
|
59
|
+
): NuiFocusTrapOptions {
|
|
60
|
+
if (input === undefined || typeof input === 'boolean') {
|
|
61
|
+
return { enabled: input ?? true };
|
|
62
|
+
}
|
|
63
|
+
return input;
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
nuiEscapeAction,
|
|
3
|
+
NuiEscapeTarget,
|
|
4
|
+
type NuiEscapeParameter,
|
|
5
|
+
type NuiEscapeInput
|
|
6
|
+
} from './escape';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
nuiFocusTrap,
|
|
10
|
+
type NuiFocusTrapOptions,
|
|
11
|
+
type NuiFocusTrapInput,
|
|
12
|
+
type FocusTrapAllowOutsideTarget,
|
|
13
|
+
type FocusTrapAllowOutside,
|
|
14
|
+
type FocusTrapEntry,
|
|
15
|
+
FOCUSABLE_SELECTORS,
|
|
16
|
+
getFocusables
|
|
17
|
+
} from './focusTrap';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
nuiClickOutside,
|
|
21
|
+
type NuiClickOutsideParameter,
|
|
22
|
+
type NuiClickOutsideInput,
|
|
23
|
+
type ClickOutsideTarget
|
|
24
|
+
} from './clickOutside';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
nuiLayer,
|
|
28
|
+
type NuiLayerOptions,
|
|
29
|
+
type NuiLayerInput,
|
|
30
|
+
NuiLayerDismissReason,
|
|
31
|
+
type LayerInstance,
|
|
32
|
+
getLayerStack,
|
|
33
|
+
getTopLayer,
|
|
34
|
+
resetLayerStack
|
|
35
|
+
} from './layer';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { nuiLayer } from './layer';
|
|
2
|
+
export {
|
|
3
|
+
NuiLayerDismissReason,
|
|
4
|
+
type NuiLayerOptions,
|
|
5
|
+
type NuiLayerInput,
|
|
6
|
+
type LayerInstance,
|
|
7
|
+
toLayerOptions
|
|
8
|
+
} from './types';
|
|
9
|
+
export {
|
|
10
|
+
getLayerStack,
|
|
11
|
+
getTopLayer,
|
|
12
|
+
resetLayerStack
|
|
13
|
+
} from './layerStack';
|