@destyler/focus-trap 0.0.7-beta.9
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/LICENSE +21 -0
- package/dist/index.d.ts +236 -0
- package/dist/index.js +433 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023-PRESENT Elone Hoo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type FocusableElement = HTMLElement | SVGElement;
|
|
3
|
+
type FocusTargetValue = FocusableElement | string;
|
|
4
|
+
type FocusTargetValueOrFalse = FocusTargetValue | false;
|
|
5
|
+
/**
|
|
6
|
+
* A DOM node, a selector string (which will be passed to
|
|
7
|
+
* `document.querySelector()` to find the DOM node), or a function that
|
|
8
|
+
* returns a DOM node.
|
|
9
|
+
*/
|
|
10
|
+
type FocusTarget = FocusTargetValue | (() => FocusTargetValue);
|
|
11
|
+
/**
|
|
12
|
+
* A DOM node, a selector string (which will be passed to
|
|
13
|
+
* `document.querySelector()` to find the DOM node), `false` to explicitly indicate
|
|
14
|
+
* an opt-out, or a function that returns a DOM node or `false`.
|
|
15
|
+
*/
|
|
16
|
+
type FocusTargetOrFalse = FocusTargetValueOrFalse | (() => FocusTargetValueOrFalse);
|
|
17
|
+
type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean;
|
|
18
|
+
type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean;
|
|
19
|
+
interface FocusTrapOptions {
|
|
20
|
+
/**
|
|
21
|
+
* A function that will be called **before** sending focus to the
|
|
22
|
+
* target element upon activation.
|
|
23
|
+
*/
|
|
24
|
+
onActivate?: VoidFunction;
|
|
25
|
+
/**
|
|
26
|
+
* A function that will be called **after** focus has been sent to the
|
|
27
|
+
* target element upon activation.
|
|
28
|
+
*/
|
|
29
|
+
onPostActivate?: VoidFunction;
|
|
30
|
+
/**
|
|
31
|
+
* A function that will be called immediately after the trap's state is updated to be paused.
|
|
32
|
+
*/
|
|
33
|
+
onPause?: VoidFunction;
|
|
34
|
+
/**
|
|
35
|
+
* A function that will be called after the trap has been completely paused and is no longer
|
|
36
|
+
* managing/trapping focus.
|
|
37
|
+
*/
|
|
38
|
+
onPostPause?: VoidFunction;
|
|
39
|
+
/**
|
|
40
|
+
* A function that will be called immediately after the trap's state is updated to be active
|
|
41
|
+
* again, but prior to updating its knowledge of what nodes are tabbable within its containers,
|
|
42
|
+
* and prior to actively managing/trapping focus.
|
|
43
|
+
*/
|
|
44
|
+
onUnpause?: VoidFunction;
|
|
45
|
+
/**
|
|
46
|
+
* A function that will be called after the trap has been completely unpaused and is once
|
|
47
|
+
* again managing/trapping focus.
|
|
48
|
+
*/
|
|
49
|
+
onPostUnpause?: VoidFunction;
|
|
50
|
+
/**
|
|
51
|
+
* A function for determining if it is safe to send focus to the focus trap
|
|
52
|
+
* or not.
|
|
53
|
+
*
|
|
54
|
+
* It should return a promise that only resolves once all the listed `containers`
|
|
55
|
+
* are able to receive focus.
|
|
56
|
+
*
|
|
57
|
+
* The purpose of this is to prevent early focus-trap activation on animated
|
|
58
|
+
* dialogs that fade in and out. When a dialog fades in, there is a brief delay
|
|
59
|
+
* between the activation of the trap and the trap element being focusable.
|
|
60
|
+
*/
|
|
61
|
+
checkCanFocusTrap?: (containers: Array<HTMLElement | SVGElement>) => Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* A function that will be called **before** sending focus to the
|
|
64
|
+
* trigger element upon deactivation.
|
|
65
|
+
*/
|
|
66
|
+
onDeactivate?: VoidFunction;
|
|
67
|
+
/**
|
|
68
|
+
* A function that will be called after the trap is deactivated, after `onDeactivate`.
|
|
69
|
+
* If `returnFocus` was set, it will be called **after** focus has been sent to the trigger
|
|
70
|
+
* element upon deactivation; otherwise, it will be called after deactivation completes.
|
|
71
|
+
*/
|
|
72
|
+
onPostDeactivate?: VoidFunction;
|
|
73
|
+
/**
|
|
74
|
+
* A function for determining if it is safe to send focus back to the `trigger` element.
|
|
75
|
+
*
|
|
76
|
+
* It should return a promise that only resolves once `trigger` is focusable.
|
|
77
|
+
*
|
|
78
|
+
* The purpose of this is to prevent the focus being sent to an animated trigger element too early.
|
|
79
|
+
* If a trigger element fades in upon trap deactivation, there is a brief delay between the deactivation
|
|
80
|
+
* of the trap and when the trigger element is focusable.
|
|
81
|
+
*
|
|
82
|
+
* `trigger` will be either the node that had focus prior to the trap being activated,
|
|
83
|
+
* or the result of the `setReturnFocus` option, if configured.
|
|
84
|
+
*
|
|
85
|
+
* This handler is **not** called if the `returnFocusOnDeactivate` configuration option
|
|
86
|
+
* (or the `returnFocus` deactivation option) is falsy.
|
|
87
|
+
*/
|
|
88
|
+
checkCanReturnFocus?: (trigger: HTMLElement | SVGElement) => Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* By default, when a focus trap is activated the first element in the
|
|
91
|
+
* focus trap's tab order will receive focus. With this option you can
|
|
92
|
+
* specify a different element to receive that initial focus, or use `false`
|
|
93
|
+
* for no initially focused element at all.
|
|
94
|
+
*
|
|
95
|
+
* NOTE: Setting this option to `false` (or a function that returns `false`)
|
|
96
|
+
* will prevent the `fallbackFocus` option from being used.
|
|
97
|
+
*
|
|
98
|
+
* Setting this option to `undefined` (or a function that returns `undefined`)
|
|
99
|
+
* will result in the default behavior.
|
|
100
|
+
*/
|
|
101
|
+
initialFocus?: FocusTargetOrFalse | undefined | VoidFunction;
|
|
102
|
+
/**
|
|
103
|
+
* By default, an error will be thrown if the focus trap contains no
|
|
104
|
+
* elements in its tab order. With this option you can specify a
|
|
105
|
+
* fallback element to programmatically receive focus if no other
|
|
106
|
+
* tabbable elements are found. For example, you may want a popover's
|
|
107
|
+
* `<div>` to receive focus if the popover's content includes no
|
|
108
|
+
* tabbable elements. *Make sure the fallback element has a negative
|
|
109
|
+
* `tabindex` so it can be programmatically focused.
|
|
110
|
+
*
|
|
111
|
+
* NOTE: If `initialFocus` is `false` (or a function that returns `false`),
|
|
112
|
+
* this function will not be called when the trap is activated, and no element
|
|
113
|
+
* will be initially focused. This function may still be called while the trap
|
|
114
|
+
* is active if things change such that there are no longer any tabbable nodes
|
|
115
|
+
* in the trap.
|
|
116
|
+
*/
|
|
117
|
+
fallbackFocus?: FocusTarget;
|
|
118
|
+
/**
|
|
119
|
+
* Default: `true`. If `false`, when the trap is deactivated,
|
|
120
|
+
* focus will *not* return to the element that had focus before activation.
|
|
121
|
+
*/
|
|
122
|
+
returnFocusOnDeactivate?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* By default, focus trap on deactivation will return to the element
|
|
125
|
+
* that was focused before activation.
|
|
126
|
+
*/
|
|
127
|
+
setReturnFocus?: FocusTargetValueOrFalse | ((nodeFocusedBeforeActivation: HTMLElement | SVGElement) => FocusTargetValueOrFalse);
|
|
128
|
+
/**
|
|
129
|
+
* Default: `true`. If `false` or returns `false`, the `Escape` key will not trigger
|
|
130
|
+
* deactivation of the focus trap. This can be useful if you want
|
|
131
|
+
* to force the user to make a decision instead of allowing an easy
|
|
132
|
+
* way out. Note that if a function is given, it's only called if the ESC key
|
|
133
|
+
* was pressed.
|
|
134
|
+
*/
|
|
135
|
+
escapeDeactivates?: boolean | KeyboardEventToBoolean;
|
|
136
|
+
/**
|
|
137
|
+
* If `true` or returns `true`, a click outside the focus trap will
|
|
138
|
+
* deactivate the focus trap and allow the click event to do its thing (i.e.
|
|
139
|
+
* to pass-through to the element that was clicked). This option **takes
|
|
140
|
+
* precedence** over `allowOutsideClick` when it's set to `true`, causing
|
|
141
|
+
* that option to be ignored. Default: `false`.
|
|
142
|
+
*/
|
|
143
|
+
clickOutsideDeactivates?: boolean | MouseEventToBoolean;
|
|
144
|
+
/**
|
|
145
|
+
* If set and is or returns `true`, a click outside the focus trap will not
|
|
146
|
+
* be prevented, even when `clickOutsideDeactivates` is `false`. When
|
|
147
|
+
* `clickOutsideDeactivates` is `true`, this option is **ignored** (i.e.
|
|
148
|
+
* if it's a function, it will not be called). Use this option to control
|
|
149
|
+
* if (and even which) clicks are allowed outside the trap in conjunction
|
|
150
|
+
* with `clickOutsideDeactivates: false`. Default: `false`.
|
|
151
|
+
*/
|
|
152
|
+
allowOutsideClick?: boolean | MouseEventToBoolean;
|
|
153
|
+
/**
|
|
154
|
+
* By default, focus() will scroll to the element if not in viewport.
|
|
155
|
+
* It can produce unintended effects like scrolling back to the top of a modal.
|
|
156
|
+
* If set to `true`, no scroll will happen.
|
|
157
|
+
*/
|
|
158
|
+
preventScroll?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* Default: `true`. Delays the autofocus when the focus trap is activated.
|
|
161
|
+
* This prevents elements within the focusable element from capturing
|
|
162
|
+
* the event that triggered the focus trap activation.
|
|
163
|
+
*/
|
|
164
|
+
delayInitialFocus?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Default: `window.document`. Document where the focus trap will be active.
|
|
167
|
+
* This allows to use FocusTrap in an iFrame context.
|
|
168
|
+
*/
|
|
169
|
+
document?: Document;
|
|
170
|
+
/**
|
|
171
|
+
* Determines if the given keyboard event is a "tab forward" event that will move
|
|
172
|
+
* the focus to the next trapped element in tab order. Defaults to the `TAB` key.
|
|
173
|
+
* Use this to override the trap's behavior if you want to use arrow keys to control
|
|
174
|
+
* keyboard navigation within the trap, for example. Also see `isKeyBackward()` option.
|
|
175
|
+
*/
|
|
176
|
+
isKeyForward?: KeyboardEventToBoolean;
|
|
177
|
+
/**
|
|
178
|
+
* Determines if the given keyboard event is a "tab backward" event that will move
|
|
179
|
+
* the focus to the previous trapped element in tab order. Defaults to the `SHIFT+TAB` key.
|
|
180
|
+
* Use this to override the trap's behavior if you want to use arrow keys to control
|
|
181
|
+
* keyboard navigation within the trap, for example. Also see `isKeyForward()` option.
|
|
182
|
+
*/
|
|
183
|
+
isKeyBackward?: KeyboardEventToBoolean;
|
|
184
|
+
/**
|
|
185
|
+
* Default: `[]`. An array of FocusTrap instances that will be managed by this FocusTrap.
|
|
186
|
+
*/
|
|
187
|
+
trapStack?: any[];
|
|
188
|
+
}
|
|
189
|
+
interface DeactivateOptions extends Pick<FocusTrapOptions, 'onDeactivate' | 'onPostDeactivate' | 'checkCanReturnFocus'> {
|
|
190
|
+
returnFocus?: boolean | undefined;
|
|
191
|
+
}
|
|
192
|
+
type ActivateOptions = Pick<FocusTrapOptions, 'onActivate' | 'onPostActivate' | 'checkCanFocusTrap'>;
|
|
193
|
+
type PauseOptions = Pick<FocusTrapOptions, 'onPause' | 'onPostPause'>;
|
|
194
|
+
type UnpauseOptions = Pick<FocusTrapOptions, 'onUnpause' | 'onPostUnpause'>;
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/focus-trap.d.ts
|
|
197
|
+
declare class FocusTrap {
|
|
198
|
+
private trapStack;
|
|
199
|
+
private config;
|
|
200
|
+
private doc;
|
|
201
|
+
private state;
|
|
202
|
+
get active(): boolean;
|
|
203
|
+
get paused(): boolean;
|
|
204
|
+
constructor(elements: HTMLElement | HTMLElement[], options: FocusTrapOptions);
|
|
205
|
+
private findContainerIndex;
|
|
206
|
+
private updateTabbableNodes;
|
|
207
|
+
private listenerCleanups;
|
|
208
|
+
private addListeners;
|
|
209
|
+
private removeListeners;
|
|
210
|
+
private handleFocus;
|
|
211
|
+
private handlePointerDown;
|
|
212
|
+
private handleClick;
|
|
213
|
+
private handleTabKey;
|
|
214
|
+
private handleEscapeKey;
|
|
215
|
+
private _mutationObserver?;
|
|
216
|
+
private setupMutationObserver;
|
|
217
|
+
private updateObservedNodes;
|
|
218
|
+
private getInitialFocusNode;
|
|
219
|
+
private tryFocus;
|
|
220
|
+
activate(activateOptions?: ActivateOptions): this;
|
|
221
|
+
deactivate: (deactivateOptions?: DeactivateOptions) => this;
|
|
222
|
+
pause: (pauseOptions?: PauseOptions) => this;
|
|
223
|
+
unpause: (unpauseOptions?: UnpauseOptions) => this;
|
|
224
|
+
updateContainerElements: (containerElements: HTMLElement | HTMLElement[]) => this;
|
|
225
|
+
private getReturnFocusNode;
|
|
226
|
+
private getOption;
|
|
227
|
+
private getNodeForOption;
|
|
228
|
+
private findNextNavNode;
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region index.d.ts
|
|
232
|
+
type ElementOrGetter = HTMLElement | null | (() => HTMLElement | null);
|
|
233
|
+
interface TrapFocusOptions extends Omit<FocusTrapOptions, 'document'> {}
|
|
234
|
+
declare function trapFocus(el: ElementOrGetter, options?: TrapFocusOptions): () => void;
|
|
235
|
+
//#endregion
|
|
236
|
+
export { FocusTrap, type FocusTrapOptions, TrapFocusOptions, trapFocus };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { addDomEvent, getActiveElement, getDocument, getEventTarget, getFocusables, getTabIndex, getTabbables, isDocument, isFocusable, isTabbable, raf } from "@destyler/dom";
|
|
2
|
+
|
|
3
|
+
//#region src/focus-trap.ts
|
|
4
|
+
const isTabEvent = (event) => event.key === "Tab";
|
|
5
|
+
const valueOrHandler = (value, ...params) => typeof value === "function" ? value(...params) : value;
|
|
6
|
+
const isEscapeEvent = (event) => !event.isComposing && event.key === "Escape";
|
|
7
|
+
const delay = (fn) => setTimeout(fn, 0);
|
|
8
|
+
function isSelectableInput(node) {
|
|
9
|
+
return node.localName === "input" && "select" in node && typeof node.select === "function";
|
|
10
|
+
}
|
|
11
|
+
const activeFocusTraps = {
|
|
12
|
+
activateTrap(trapStack, trap) {
|
|
13
|
+
if (trapStack.length > 0) {
|
|
14
|
+
const activeTrap = trapStack[trapStack.length - 1];
|
|
15
|
+
if (activeTrap !== trap) activeTrap.pause();
|
|
16
|
+
}
|
|
17
|
+
const trapIndex = trapStack.indexOf(trap);
|
|
18
|
+
if (trapIndex === -1) trapStack.push(trap);
|
|
19
|
+
else {
|
|
20
|
+
trapStack.splice(trapIndex, 1);
|
|
21
|
+
trapStack.push(trap);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
deactivateTrap(trapStack, trap) {
|
|
25
|
+
const trapIndex = trapStack.indexOf(trap);
|
|
26
|
+
if (trapIndex !== -1) trapStack.splice(trapIndex, 1);
|
|
27
|
+
if (trapStack.length > 0) trapStack[trapStack.length - 1].unpause();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const sharedTrapStack = [];
|
|
31
|
+
var FocusTrap = class {
|
|
32
|
+
trapStack;
|
|
33
|
+
config;
|
|
34
|
+
doc;
|
|
35
|
+
state = {
|
|
36
|
+
containers: [],
|
|
37
|
+
containerGroups: [],
|
|
38
|
+
tabbableGroups: [],
|
|
39
|
+
nodeFocusedBeforeActivation: null,
|
|
40
|
+
mostRecentlyFocusedNode: null,
|
|
41
|
+
active: false,
|
|
42
|
+
paused: false,
|
|
43
|
+
delayInitialFocusTimer: void 0,
|
|
44
|
+
recentNavEvent: void 0
|
|
45
|
+
};
|
|
46
|
+
get active() {
|
|
47
|
+
return this.state.active;
|
|
48
|
+
}
|
|
49
|
+
get paused() {
|
|
50
|
+
return this.state.paused;
|
|
51
|
+
}
|
|
52
|
+
constructor(elements, options) {
|
|
53
|
+
this.trapStack = options.trapStack || sharedTrapStack;
|
|
54
|
+
const config = {
|
|
55
|
+
returnFocusOnDeactivate: true,
|
|
56
|
+
escapeDeactivates: true,
|
|
57
|
+
delayInitialFocus: true,
|
|
58
|
+
isKeyForward(e) {
|
|
59
|
+
return isTabEvent(e) && !e.shiftKey;
|
|
60
|
+
},
|
|
61
|
+
isKeyBackward(e) {
|
|
62
|
+
return isTabEvent(e) && e.shiftKey;
|
|
63
|
+
},
|
|
64
|
+
...options
|
|
65
|
+
};
|
|
66
|
+
this.doc = config.document || getDocument(Array.isArray(elements) ? elements[0] : elements);
|
|
67
|
+
this.config = config;
|
|
68
|
+
this.updateContainerElements(elements);
|
|
69
|
+
this.setupMutationObserver();
|
|
70
|
+
}
|
|
71
|
+
findContainerIndex(element, event) {
|
|
72
|
+
const composedPath = typeof event?.composedPath === "function" ? event.composedPath() : void 0;
|
|
73
|
+
return this.state.containerGroups.findIndex(({ container, tabbableNodes }) => container.contains(element) || composedPath?.includes(container) || tabbableNodes.find((node) => node === element));
|
|
74
|
+
}
|
|
75
|
+
updateTabbableNodes() {
|
|
76
|
+
this.state.containerGroups = this.state.containers.map((container) => {
|
|
77
|
+
const tabbableNodes = getTabbables(container);
|
|
78
|
+
const focusableNodes = getFocusables(container);
|
|
79
|
+
const firstTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[0] : void 0;
|
|
80
|
+
const lastTabbableNode = tabbableNodes.length > 0 ? tabbableNodes[tabbableNodes.length - 1] : void 0;
|
|
81
|
+
const firstDomTabbableNode = focusableNodes.find((node) => isTabbable(node));
|
|
82
|
+
const lastDomTabbableNode = focusableNodes.slice().reverse().find((node) => isTabbable(node));
|
|
83
|
+
const posTabIndexesFound = !!tabbableNodes.find((node) => getTabIndex(node) > 0);
|
|
84
|
+
function nextTabbableNode(node, forward = true) {
|
|
85
|
+
const nodeIdx = tabbableNodes.indexOf(node);
|
|
86
|
+
if (nodeIdx < 0) {
|
|
87
|
+
if (forward) return focusableNodes.slice(focusableNodes.indexOf(node) + 1).find((el) => isTabbable(el));
|
|
88
|
+
return focusableNodes.slice(0, focusableNodes.indexOf(node)).reverse().find((el) => isTabbable(el));
|
|
89
|
+
}
|
|
90
|
+
return tabbableNodes[nodeIdx + (forward ? 1 : -1)];
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
container,
|
|
94
|
+
tabbableNodes,
|
|
95
|
+
focusableNodes,
|
|
96
|
+
posTabIndexesFound,
|
|
97
|
+
firstTabbableNode,
|
|
98
|
+
lastTabbableNode,
|
|
99
|
+
firstDomTabbableNode,
|
|
100
|
+
lastDomTabbableNode,
|
|
101
|
+
nextTabbableNode
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
this.state.tabbableGroups = this.state.containerGroups.filter((group) => group.tabbableNodes.length > 0);
|
|
105
|
+
if (this.state.tabbableGroups.length <= 0 && !this.getNodeForOption("fallbackFocus")) throw new Error("Your focus-trap must have at least one container with at least one tabbable node in it at all times");
|
|
106
|
+
if (this.state.containerGroups.find((g) => g.posTabIndexesFound) && this.state.containerGroups.length > 1) throw new Error("At least one node with a positive tabindex was found in one of your focus-trap's multiple containers. Positive tabindexes are only supported in single-container focus-traps.");
|
|
107
|
+
}
|
|
108
|
+
listenerCleanups = [];
|
|
109
|
+
addListeners() {
|
|
110
|
+
if (!this.state.active) return;
|
|
111
|
+
activeFocusTraps.activateTrap(this.trapStack, this);
|
|
112
|
+
this.state.delayInitialFocusTimer = this.config.delayInitialFocus ? delay(() => {
|
|
113
|
+
this.tryFocus(this.getInitialFocusNode());
|
|
114
|
+
}) : this.tryFocus(this.getInitialFocusNode());
|
|
115
|
+
this.listenerCleanups.push(addDomEvent(this.doc, "focusin", this.handleFocus, true), addDomEvent(this.doc, "mousedown", this.handlePointerDown, {
|
|
116
|
+
capture: true,
|
|
117
|
+
passive: false
|
|
118
|
+
}), addDomEvent(this.doc, "touchstart", this.handlePointerDown, {
|
|
119
|
+
capture: true,
|
|
120
|
+
passive: false
|
|
121
|
+
}), addDomEvent(this.doc, "click", this.handleClick, {
|
|
122
|
+
capture: true,
|
|
123
|
+
passive: false
|
|
124
|
+
}), addDomEvent(this.doc, "keydown", this.handleTabKey, {
|
|
125
|
+
capture: true,
|
|
126
|
+
passive: false
|
|
127
|
+
}), addDomEvent(this.doc, "keydown", this.handleEscapeKey));
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
removeListeners() {
|
|
131
|
+
if (!this.state.active) return;
|
|
132
|
+
this.listenerCleanups.forEach((cleanup) => cleanup());
|
|
133
|
+
this.listenerCleanups = [];
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
handleFocus = (event) => {
|
|
137
|
+
const target = getEventTarget(event);
|
|
138
|
+
const targetContained = this.findContainerIndex(target, event) >= 0;
|
|
139
|
+
if (targetContained || isDocument(target)) {
|
|
140
|
+
if (targetContained) this.state.mostRecentlyFocusedNode = target;
|
|
141
|
+
} else {
|
|
142
|
+
event.stopImmediatePropagation();
|
|
143
|
+
let nextNode;
|
|
144
|
+
let navAcrossContainers = true;
|
|
145
|
+
if (this.state.mostRecentlyFocusedNode) {
|
|
146
|
+
if (getTabIndex(this.state.mostRecentlyFocusedNode) > 0) {
|
|
147
|
+
const mruContainerIdx = this.findContainerIndex(this.state.mostRecentlyFocusedNode);
|
|
148
|
+
const { tabbableNodes } = this.state.containerGroups[mruContainerIdx];
|
|
149
|
+
if (tabbableNodes.length > 0) {
|
|
150
|
+
const mruTabIdx = tabbableNodes.findIndex((node) => node === this.state.mostRecentlyFocusedNode);
|
|
151
|
+
if (mruTabIdx >= 0) {
|
|
152
|
+
if (this.config.isKeyForward(this.state.recentNavEvent)) {
|
|
153
|
+
if (mruTabIdx + 1 < tabbableNodes.length) {
|
|
154
|
+
nextNode = tabbableNodes[mruTabIdx + 1];
|
|
155
|
+
navAcrossContainers = false;
|
|
156
|
+
}
|
|
157
|
+
} else if (mruTabIdx - 1 >= 0) {
|
|
158
|
+
nextNode = tabbableNodes[mruTabIdx - 1];
|
|
159
|
+
navAcrossContainers = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else if (!this.state.containerGroups.some((g) => g.tabbableNodes.some((n) => getTabIndex(n) > 0))) navAcrossContainers = false;
|
|
164
|
+
} else navAcrossContainers = false;
|
|
165
|
+
if (navAcrossContainers) nextNode = this.findNextNavNode({
|
|
166
|
+
target: this.state.mostRecentlyFocusedNode,
|
|
167
|
+
isBackward: this.config.isKeyBackward(this.state.recentNavEvent)
|
|
168
|
+
});
|
|
169
|
+
if (nextNode) this.tryFocus(nextNode);
|
|
170
|
+
else this.tryFocus(this.state.mostRecentlyFocusedNode || this.getInitialFocusNode());
|
|
171
|
+
}
|
|
172
|
+
this.state.recentNavEvent = void 0;
|
|
173
|
+
};
|
|
174
|
+
handlePointerDown = (event) => {
|
|
175
|
+
const target = getEventTarget(event);
|
|
176
|
+
if (this.findContainerIndex(target, event) >= 0) return;
|
|
177
|
+
if (valueOrHandler(this.config.clickOutsideDeactivates, event)) {
|
|
178
|
+
this.deactivate({ returnFocus: this.config.returnFocusOnDeactivate });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (valueOrHandler(this.config.allowOutsideClick, event)) return;
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
};
|
|
184
|
+
handleClick = (event) => {
|
|
185
|
+
const target = getEventTarget(event);
|
|
186
|
+
if (this.findContainerIndex(target, event) >= 0) return;
|
|
187
|
+
if (valueOrHandler(this.config.clickOutsideDeactivates, event)) return;
|
|
188
|
+
if (valueOrHandler(this.config.allowOutsideClick, event)) return;
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
event.stopImmediatePropagation();
|
|
191
|
+
};
|
|
192
|
+
handleTabKey = (event) => {
|
|
193
|
+
if (this.config.isKeyForward(event) || this.config.isKeyBackward(event)) {
|
|
194
|
+
this.state.recentNavEvent = event;
|
|
195
|
+
const isBackward = this.config.isKeyBackward(event);
|
|
196
|
+
const destinationNode = this.findNextNavNode({
|
|
197
|
+
event,
|
|
198
|
+
isBackward
|
|
199
|
+
});
|
|
200
|
+
if (!destinationNode) return;
|
|
201
|
+
if (isTabEvent(event)) event.preventDefault();
|
|
202
|
+
this.tryFocus(destinationNode);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
handleEscapeKey = (event) => {
|
|
206
|
+
if (isEscapeEvent(event) && valueOrHandler(this.config.escapeDeactivates, event) !== false) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
this.deactivate();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
_mutationObserver;
|
|
212
|
+
setupMutationObserver = () => {
|
|
213
|
+
const win = this.doc.defaultView || window;
|
|
214
|
+
this._mutationObserver = new win.MutationObserver((mutations) => {
|
|
215
|
+
const isFocusedNodeRemoved = mutations.some((mutation) => {
|
|
216
|
+
const removedNodes = Array.from(mutation.removedNodes);
|
|
217
|
+
return removedNodes.includes(this.state.mostRecentlyFocusedNode);
|
|
218
|
+
});
|
|
219
|
+
if (isFocusedNodeRemoved) this.tryFocus(this.getInitialFocusNode());
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
updateObservedNodes = () => {
|
|
223
|
+
this._mutationObserver?.disconnect();
|
|
224
|
+
if (this.state.active && !this.state.paused) this.state.containers.forEach((container) => {
|
|
225
|
+
this._mutationObserver?.observe(container, {
|
|
226
|
+
subtree: true,
|
|
227
|
+
childList: true
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
getInitialFocusNode = () => {
|
|
232
|
+
let node = this.getNodeForOption("initialFocus", { hasFallback: true });
|
|
233
|
+
if (node === false) return false;
|
|
234
|
+
if (node === void 0 || node && !isFocusable(node)) if (this.findContainerIndex(this.doc.activeElement) >= 0) node = this.doc.activeElement;
|
|
235
|
+
else {
|
|
236
|
+
const firstTabbableGroup = this.state.tabbableGroups[0];
|
|
237
|
+
const firstTabbableNode = firstTabbableGroup && firstTabbableGroup.firstTabbableNode;
|
|
238
|
+
node = firstTabbableNode || this.getNodeForOption("fallbackFocus");
|
|
239
|
+
}
|
|
240
|
+
else if (node === null) node = this.getNodeForOption("fallbackFocus");
|
|
241
|
+
if (!node) throw new Error("Your focus-trap needs to have at least one focusable element");
|
|
242
|
+
if (!node.isConnected) node = this.getNodeForOption("fallbackFocus");
|
|
243
|
+
return node;
|
|
244
|
+
};
|
|
245
|
+
tryFocus = (node) => {
|
|
246
|
+
if (node === false) return;
|
|
247
|
+
if (node === getActiveElement(this.doc)) return;
|
|
248
|
+
if (!node || !node.focus) {
|
|
249
|
+
this.tryFocus(this.getInitialFocusNode());
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
node.focus({ preventScroll: !!this.config.preventScroll });
|
|
253
|
+
this.state.mostRecentlyFocusedNode = node;
|
|
254
|
+
if (isSelectableInput(node)) node.select();
|
|
255
|
+
};
|
|
256
|
+
activate(activateOptions) {
|
|
257
|
+
if (this.state.active) return this;
|
|
258
|
+
const onActivate = this.getOption(activateOptions, "onActivate");
|
|
259
|
+
const onPostActivate = this.getOption(activateOptions, "onPostActivate");
|
|
260
|
+
const checkCanFocusTrap = this.getOption(activateOptions, "checkCanFocusTrap");
|
|
261
|
+
if (!checkCanFocusTrap) this.updateTabbableNodes();
|
|
262
|
+
this.state.active = true;
|
|
263
|
+
this.state.paused = false;
|
|
264
|
+
this.state.nodeFocusedBeforeActivation = this.doc.activeElement || null;
|
|
265
|
+
onActivate?.();
|
|
266
|
+
const finishActivation = () => {
|
|
267
|
+
if (checkCanFocusTrap) this.updateTabbableNodes();
|
|
268
|
+
this.addListeners();
|
|
269
|
+
this.updateObservedNodes();
|
|
270
|
+
onPostActivate?.();
|
|
271
|
+
};
|
|
272
|
+
if (checkCanFocusTrap) {
|
|
273
|
+
checkCanFocusTrap(this.state.containers.concat()).then(finishActivation, finishActivation);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
finishActivation();
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
deactivate = (deactivateOptions) => {
|
|
280
|
+
if (!this.state.active) return this;
|
|
281
|
+
const options = {
|
|
282
|
+
onDeactivate: this.config.onDeactivate,
|
|
283
|
+
onPostDeactivate: this.config.onPostDeactivate,
|
|
284
|
+
checkCanReturnFocus: this.config.checkCanReturnFocus,
|
|
285
|
+
...deactivateOptions
|
|
286
|
+
};
|
|
287
|
+
clearTimeout(this.state.delayInitialFocusTimer);
|
|
288
|
+
this.state.delayInitialFocusTimer = void 0;
|
|
289
|
+
this.removeListeners();
|
|
290
|
+
this.state.active = false;
|
|
291
|
+
this.state.paused = false;
|
|
292
|
+
this.updateObservedNodes();
|
|
293
|
+
activeFocusTraps.deactivateTrap(this.trapStack, this);
|
|
294
|
+
const onDeactivate = this.getOption(options, "onDeactivate");
|
|
295
|
+
const onPostDeactivate = this.getOption(options, "onPostDeactivate");
|
|
296
|
+
const checkCanReturnFocus = this.getOption(options, "checkCanReturnFocus");
|
|
297
|
+
const returnFocus = this.getOption(options, "returnFocus", "returnFocusOnDeactivate");
|
|
298
|
+
onDeactivate?.();
|
|
299
|
+
const finishDeactivation = () => {
|
|
300
|
+
delay(() => {
|
|
301
|
+
if (returnFocus) {
|
|
302
|
+
const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
|
|
303
|
+
this.tryFocus(returnFocusNode);
|
|
304
|
+
}
|
|
305
|
+
onPostDeactivate?.();
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
if (returnFocus && checkCanReturnFocus) {
|
|
309
|
+
const returnFocusNode = this.getReturnFocusNode(this.state.nodeFocusedBeforeActivation);
|
|
310
|
+
checkCanReturnFocus(returnFocusNode).then(finishDeactivation, finishDeactivation);
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
finishDeactivation();
|
|
314
|
+
return this;
|
|
315
|
+
};
|
|
316
|
+
pause = (pauseOptions) => {
|
|
317
|
+
if (this.state.paused || !this.state.active) return this;
|
|
318
|
+
const onPause = this.getOption(pauseOptions, "onPause");
|
|
319
|
+
const onPostPause = this.getOption(pauseOptions, "onPostPause");
|
|
320
|
+
this.state.paused = true;
|
|
321
|
+
onPause?.();
|
|
322
|
+
this.removeListeners();
|
|
323
|
+
this.updateObservedNodes();
|
|
324
|
+
onPostPause?.();
|
|
325
|
+
return this;
|
|
326
|
+
};
|
|
327
|
+
unpause = (unpauseOptions) => {
|
|
328
|
+
if (!this.state.paused || !this.state.active) return this;
|
|
329
|
+
const onUnpause = this.getOption(unpauseOptions, "onUnpause");
|
|
330
|
+
const onPostUnpause = this.getOption(unpauseOptions, "onPostUnpause");
|
|
331
|
+
this.state.paused = false;
|
|
332
|
+
onUnpause?.();
|
|
333
|
+
this.updateTabbableNodes();
|
|
334
|
+
this.addListeners();
|
|
335
|
+
this.updateObservedNodes();
|
|
336
|
+
onPostUnpause?.();
|
|
337
|
+
return this;
|
|
338
|
+
};
|
|
339
|
+
updateContainerElements = (containerElements) => {
|
|
340
|
+
this.state.containers = Array.isArray(containerElements) ? containerElements.filter(Boolean) : [containerElements].filter(Boolean);
|
|
341
|
+
if (this.state.active) this.updateTabbableNodes();
|
|
342
|
+
this.updateObservedNodes();
|
|
343
|
+
return this;
|
|
344
|
+
};
|
|
345
|
+
getReturnFocusNode = (previousActiveElement) => {
|
|
346
|
+
const node = this.getNodeForOption("setReturnFocus", { params: [previousActiveElement] });
|
|
347
|
+
return node || (node === false ? false : previousActiveElement);
|
|
348
|
+
};
|
|
349
|
+
getOption = (configOverrideOptions, optionName, configOptionName) => {
|
|
350
|
+
return configOverrideOptions && configOverrideOptions[optionName] !== void 0 ? configOverrideOptions[optionName] : this.config[configOptionName || optionName];
|
|
351
|
+
};
|
|
352
|
+
getNodeForOption = (optionName, { hasFallback = false, params = [] } = {}) => {
|
|
353
|
+
let optionValue = this.config[optionName];
|
|
354
|
+
if (typeof optionValue === "function") optionValue = optionValue(...params);
|
|
355
|
+
if (optionValue === true) optionValue = void 0;
|
|
356
|
+
if (!optionValue) {
|
|
357
|
+
if (optionValue === void 0 || optionValue === false) return optionValue;
|
|
358
|
+
throw new Error(`\`${optionName}\` was specified but was not a node, or did not return a node`);
|
|
359
|
+
}
|
|
360
|
+
let node = optionValue;
|
|
361
|
+
if (typeof optionValue === "string") {
|
|
362
|
+
try {
|
|
363
|
+
node = this.doc.querySelector(optionValue);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
throw new Error(`\`${optionName}\` appears to be an invalid selector; error="${err.message}"`);
|
|
366
|
+
}
|
|
367
|
+
if (!node) {
|
|
368
|
+
if (!hasFallback) throw new Error(`\`${optionName}\` as selector refers to no known node`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return node;
|
|
372
|
+
};
|
|
373
|
+
findNextNavNode = (opts) => {
|
|
374
|
+
const { event, isBackward = false } = opts;
|
|
375
|
+
const target = opts.target || getEventTarget(event);
|
|
376
|
+
this.updateTabbableNodes();
|
|
377
|
+
let destinationNode = null;
|
|
378
|
+
if (this.state.tabbableGroups.length > 0) {
|
|
379
|
+
const containerIndex = this.findContainerIndex(target, event);
|
|
380
|
+
const containerGroup = containerIndex >= 0 ? this.state.containerGroups[containerIndex] : void 0;
|
|
381
|
+
if (containerIndex < 0) if (isBackward) destinationNode = this.state.tabbableGroups[this.state.tabbableGroups.length - 1].lastTabbableNode;
|
|
382
|
+
else destinationNode = this.state.tabbableGroups[0].firstTabbableNode;
|
|
383
|
+
else if (isBackward) {
|
|
384
|
+
let startOfGroupIndex = this.state.tabbableGroups.findIndex(({ firstTabbableNode }) => target === firstTabbableNode);
|
|
385
|
+
if (startOfGroupIndex < 0 && (containerGroup?.container === target || isFocusable(target) && !isTabbable(target) && !containerGroup?.nextTabbableNode(target, false))) startOfGroupIndex = containerIndex;
|
|
386
|
+
if (startOfGroupIndex >= 0) {
|
|
387
|
+
const destinationGroupIndex = startOfGroupIndex === 0 ? this.state.tabbableGroups.length - 1 : startOfGroupIndex - 1;
|
|
388
|
+
const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
|
|
389
|
+
destinationNode = getTabIndex(target) >= 0 ? destinationGroup.lastTabbableNode : destinationGroup.lastDomTabbableNode;
|
|
390
|
+
} else if (!isTabEvent(event)) destinationNode = containerGroup?.nextTabbableNode(target, false);
|
|
391
|
+
} else {
|
|
392
|
+
let lastOfGroupIndex = this.state.tabbableGroups.findIndex(({ lastTabbableNode }) => target === lastTabbableNode);
|
|
393
|
+
if (lastOfGroupIndex < 0 && (containerGroup?.container === target || isFocusable(target) && !isTabbable(target) && !containerGroup?.nextTabbableNode(target))) lastOfGroupIndex = containerIndex;
|
|
394
|
+
if (lastOfGroupIndex >= 0) {
|
|
395
|
+
const destinationGroupIndex = lastOfGroupIndex === this.state.tabbableGroups.length - 1 ? 0 : lastOfGroupIndex + 1;
|
|
396
|
+
const destinationGroup = this.state.tabbableGroups[destinationGroupIndex];
|
|
397
|
+
destinationNode = getTabIndex(target) >= 0 ? destinationGroup.firstTabbableNode : destinationGroup.firstDomTabbableNode;
|
|
398
|
+
} else if (!isTabEvent(event)) destinationNode = containerGroup?.nextTabbableNode(target);
|
|
399
|
+
}
|
|
400
|
+
} else destinationNode = this.getNodeForOption("fallbackFocus");
|
|
401
|
+
return destinationNode;
|
|
402
|
+
};
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region index.ts
|
|
407
|
+
function trapFocus(el, options = {}) {
|
|
408
|
+
let trap;
|
|
409
|
+
const cleanup = raf(() => {
|
|
410
|
+
const contentEl = typeof el === "function" ? el() : el;
|
|
411
|
+
if (!contentEl) return;
|
|
412
|
+
trap = new FocusTrap(contentEl, {
|
|
413
|
+
escapeDeactivates: false,
|
|
414
|
+
allowOutsideClick: true,
|
|
415
|
+
preventScroll: true,
|
|
416
|
+
returnFocusOnDeactivate: true,
|
|
417
|
+
delayInitialFocus: false,
|
|
418
|
+
fallbackFocus: contentEl,
|
|
419
|
+
...options,
|
|
420
|
+
document: getDocument(contentEl)
|
|
421
|
+
});
|
|
422
|
+
try {
|
|
423
|
+
trap.activate();
|
|
424
|
+
} catch {}
|
|
425
|
+
});
|
|
426
|
+
return function destroy() {
|
|
427
|
+
trap?.deactivate();
|
|
428
|
+
cleanup();
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
433
|
+
export { FocusTrap, trapFocus };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@destyler/focus-trap",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.7-beta.9",
|
|
5
|
+
"description": "Focus trap util",
|
|
6
|
+
"author": "Elone Hoo <elonehoo@gmail.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"module": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"*.d.ts",
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@destyler/dom": "0.0.7-beta.9"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clear": "rimraf ./dist",
|
|
23
|
+
"clear:node_modules": "rimraf ./node_modules",
|
|
24
|
+
"build": "pnpm run clear && tsdown",
|
|
25
|
+
"watch": "tsdown --watch"
|
|
26
|
+
}
|
|
27
|
+
}
|