@reshaped/utilities 3.9.1-canary.3 → 3.10.0-canary.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 +232 -0
- package/dist/a11y/Chain.d.ts +20 -0
- package/dist/a11y/Chain.js +60 -0
- package/dist/a11y/TrapFocus.d.ts +28 -0
- package/dist/a11y/TrapFocus.js +162 -0
- package/dist/a11y/TrapScreenReader.d.ts +15 -0
- package/dist/a11y/TrapScreenReader.js +42 -0
- package/dist/a11y/focus.d.ts +38 -0
- package/dist/a11y/focus.js +101 -0
- package/dist/a11y/index.d.ts +4 -0
- package/dist/a11y/index.js +3 -0
- package/dist/a11y/keyboardMode.d.ts +4 -0
- package/dist/a11y/keyboardMode.js +10 -0
- package/dist/a11y/tests/Chain.test.js +88 -0
- package/dist/a11y/tests/TrapFocus.test.d.ts +1 -0
- package/dist/a11y/tests/TrapFocus.test.js +313 -0
- package/dist/a11y/tests/TrapScreenReader.test.d.ts +1 -0
- package/dist/a11y/tests/TrapScreenReader.test.js +126 -0
- package/dist/a11y/tests/focus.test.d.ts +1 -0
- package/dist/a11y/tests/focus.test.js +278 -0
- package/dist/a11y/tests/keyboardMode.test.d.ts +1 -0
- package/dist/a11y/tests/keyboardMode.test.js +27 -0
- package/dist/a11y/types.d.ts +24 -0
- package/dist/a11y/types.js +1 -0
- package/dist/constants/keys.d.ts +11 -0
- package/dist/constants/keys.js +11 -0
- package/dist/css/StyleCache.d.ts +7 -0
- package/dist/css/StyleCache.js +19 -0
- package/dist/css/classNames.d.ts +7 -0
- package/dist/css/classNames.js +19 -0
- package/dist/css/index.d.ts +2 -0
- package/dist/css/index.js +4 -0
- package/dist/css/tests/StyleCache.test.d.ts +1 -0
- package/dist/css/tests/StyleCache.test.js +45 -0
- package/dist/css/tests/classNames.test.d.ts +1 -0
- package/dist/css/tests/classNames.test.js +63 -0
- package/dist/dom/findClosestScrollableContainer.d.ts +5 -0
- package/dist/dom/findClosestScrollableContainer.js +12 -0
- package/dist/dom/findParent.d.ts +2 -0
- package/dist/dom/findParent.js +10 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +4 -0
- package/dist/dom/tests/findClosestScrollableContainer.test.d.ts +1 -0
- package/dist/dom/tests/findClosestScrollableContainer.test.js +61 -0
- package/dist/dom/tests/findParent.test.d.ts +1 -0
- package/dist/dom/tests/findParent.test.js +45 -0
- package/dist/flyout/Flyout.d.ts +2 -2
- package/dist/flyout/Flyout.js +6 -6
- package/dist/flyout/index.d.ts +1 -1
- package/dist/flyout/index.js +1 -1
- package/dist/flyout/tests/Flyout.test.js +9 -9
- package/dist/flyout/types.d.ts +2 -2
- package/dist/flyout/utilities/applyPosition.js +1 -1
- package/dist/flyout/utilities/tests/applyPosition.test.js +10 -10
- package/dist/flyout/utilities/tests/calculateLayoutAdjustment.test.js +2 -2
- package/dist/flyout/utilities/tests/calculatePosition.test.js +1 -1
- package/dist/flyout/utilities/tests/centerBySize.test.js +1 -1
- package/dist/flyout/utilities/tests/getPositionFallbacks.test.js +1 -1
- package/dist/flyout/utilities/tests/getRTLPosition.test.js +1 -1
- package/dist/flyout/utilities/tests/isFullyVisible.test.js +1 -1
- package/dist/helpers/classNames.d.ts +7 -0
- package/dist/helpers/classNames.js +19 -0
- package/dist/helpers/index.d.ts +1 -0
- package/dist/helpers/index.js +2 -0
- package/dist/helpers/tests/classNames.test.d.ts +1 -0
- package/dist/helpers/tests/classNames.test.js +63 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +2 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/internal.d.ts +11 -0
- package/dist/internal.js +10 -0
- package/dist/platform/index.d.ts +1 -0
- package/dist/platform/index.js +16 -0
- package/dist/scroll/disable.d.ts +7 -0
- package/dist/scroll/disable.js +15 -0
- package/dist/scroll/helpers.d.ts +1 -0
- package/dist/scroll/helpers.js +17 -0
- package/dist/scroll/index.d.ts +2 -0
- package/dist/scroll/index.js +4 -0
- package/dist/scroll/lock.d.ts +7 -0
- package/dist/scroll/lock.js +26 -0
- package/dist/scroll/lockSafari.d.ts +2 -0
- package/dist/scroll/lockSafari.js +20 -0
- package/dist/scroll/lockStandard.d.ts +4 -0
- package/dist/scroll/lockStandard.js +15 -0
- package/dist/scroll/tests/lock.test.d.ts +1 -0
- package/dist/scroll/tests/lock.test.js +81 -0
- package/package.json +6 -1
- package/dist/flyout/utilities/findClosestFixedContainer.d.ts +0 -5
- package/dist/flyout/utilities/findClosestFixedContainer.js +0 -18
- package/dist/flyout/utilities/tests/findClosestFixedContainer.test.js +0 -46
- /package/dist/{flyout/utilities/tests/findClosestFixedContainer.test.d.ts → a11y/tests/Chain.test.d.ts} +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# @reshaped/utilities
|
|
2
|
+
|
|
3
|
+
`@reshaped/utilities` is a standalone package that provides common utilities for building components and web applications with any framework. These utilities handle common patterns like focus management, scroll locking, DOM manipulation, and more.
|
|
4
|
+
|
|
5
|
+
Reshaped uses this package internally to power its component library, and you can use the same utilities in your own projects to build consistent, accessible experiences. In case you're using React, check `@reshaped/headless` instead as it covers more APIs and provides a better built-in integration with React.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @reshaped/utilities
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## API overview
|
|
12
|
+
|
|
13
|
+
### Flyout
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Flyout } from "@reshaped/utilities";
|
|
17
|
+
|
|
18
|
+
const flyout = new Flyout({
|
|
19
|
+
content: contentElement,
|
|
20
|
+
trigger: triggerElement,
|
|
21
|
+
position: "bottom-start",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Position the flyout content based on the trigger element
|
|
25
|
+
flyout.activate();
|
|
26
|
+
|
|
27
|
+
// Clean-up the flyout behavior on closing the content
|
|
28
|
+
flyout.deactivate();
|
|
29
|
+
|
|
30
|
+
// Update flyout position based on updated options and the current viewport position
|
|
31
|
+
flyout.update(options);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When you need to position floating elements like dropdowns, popovers, or tooltips, create a new Flyout instance and pass the content and trigger elements. The class handles complex positioning logic, collision detection, and automatic position adjustments to ensure your floating content is always visible and properly aligned.
|
|
35
|
+
|
|
36
|
+
Flyout support multiple options to tailor its behavior for your use case. These options can be pass when creating a flyout instance or when using the `update` method.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export type Options = {
|
|
40
|
+
// Element that has to be positioned against the trigger. It has to be rendered at the moment of calling `new Flyout`
|
|
41
|
+
content: HTMLElement;
|
|
42
|
+
|
|
43
|
+
// Trigger element to position the content against
|
|
44
|
+
trigger?: HTMLElement | null;
|
|
45
|
+
|
|
46
|
+
// In case there is no trigger on the page, pass the x, y coordinates instead. This is useful for building components like context menus
|
|
47
|
+
triggerCoordinates?: { x: number; y: number } | null;
|
|
48
|
+
|
|
49
|
+
// Specify a container to position the content within instead of using the viewport boundaries
|
|
50
|
+
container?: HTMLElement | null;
|
|
51
|
+
|
|
52
|
+
// Default position to render the content, relative to the trigger
|
|
53
|
+
position:
|
|
54
|
+
| `top`
|
|
55
|
+
| `top-start`
|
|
56
|
+
| `top-end`
|
|
57
|
+
| `bottom`
|
|
58
|
+
| `bottom-start`
|
|
59
|
+
| `bottom-end`
|
|
60
|
+
| `start`
|
|
61
|
+
| `start-top`
|
|
62
|
+
| `start-bottom`
|
|
63
|
+
| `end`
|
|
64
|
+
| `end-top`
|
|
65
|
+
| `end-bottom`;
|
|
66
|
+
|
|
67
|
+
// Define which positions are used as fallbacks when default position doesn't fit into the viewport
|
|
68
|
+
// Passing an empty array would always keep the content in the same position
|
|
69
|
+
fallbackPositions?: Position[];
|
|
70
|
+
|
|
71
|
+
// Custom content width instead of using the width based on the content children
|
|
72
|
+
width?: Width;
|
|
73
|
+
|
|
74
|
+
// Try shifting and resizing the content if it doesn't fit into the viewport first instead of immediately changing the position
|
|
75
|
+
fallbackAdjustLayout?: boolean;
|
|
76
|
+
|
|
77
|
+
// Minimal height value for the content when using `fallbackAdjustLayout`
|
|
78
|
+
fallbackMinHeight?: string;
|
|
79
|
+
|
|
80
|
+
// Add an additional gap between the trigger and the content
|
|
81
|
+
contentGap?: number;
|
|
82
|
+
// Shift the content alongside the trigger
|
|
83
|
+
contentShift?: number;
|
|
84
|
+
|
|
85
|
+
// Handler for the cases when flyout deactivates itself internally based on the event handling
|
|
86
|
+
// For example, it might deactivate itself after scrolling the trigger outside the viewport
|
|
87
|
+
onDeactivate: () => void;
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### TrapFocus
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { TrapFocus } from "@reshaped/utilities";
|
|
95
|
+
|
|
96
|
+
class Modal {
|
|
97
|
+
trapFocus;
|
|
98
|
+
|
|
99
|
+
constructor() {
|
|
100
|
+
this.trapFocus = new TrapFocus();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
open() {
|
|
104
|
+
this.trapFocus.trap(targetRef.current);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close() {
|
|
108
|
+
this.trapFocus.release();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
When `TrapFocus` is activated, it traps keyboard and screen reader navigation within the given element until focus is released. Since TrapFocus is a class, you can create a new instance and pass the target element to initialize it. This is useful when an element (like a modal or popup) appears on screen. When the element is dismissed, call the release method to restore normal focus behavior.
|
|
114
|
+
|
|
115
|
+
There are multiple options you can pass to the trapFocus methods to customize its behavior:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
trapFocus.trap(
|
|
119
|
+
// element to trap the focus within
|
|
120
|
+
rootElement,
|
|
121
|
+
{
|
|
122
|
+
// keep the focus on the trigger and include it in the focus sequence
|
|
123
|
+
includeTrigger: boolean,
|
|
124
|
+
|
|
125
|
+
// element to move to the focus to after the initialization
|
|
126
|
+
initialFocusEl: HTMLElement
|
|
127
|
+
|
|
128
|
+
// keyboard navigation mode
|
|
129
|
+
// `dialog` - handles Tab and Shift + Tab, looping focus within the element
|
|
130
|
+
// `action-menu` - allows focus navigation with the ArrowUp and ArrowDown keys. Pressing Tab moves focus to the next element after the original trigger and automatically releases the focus trap. You can use the `onRelease` option to dismiss the content when that happens.
|
|
131
|
+
// `action-bar` - works like `action-menu`, but uses the ArrowLeft and ArrowRight keys to move focus instead.
|
|
132
|
+
// `content-menu` - keeps the navigation flow natural by including all focusable elements in the trapped area after the trigger element.
|
|
133
|
+
// Focus navigation uses the Tab key, but pressing Tab on the last element moves focus to the next element after the original trigger.
|
|
134
|
+
// This releases the focus trap, and you can respond to it using the `onRelease` handler.
|
|
135
|
+
mode: 'dialog' | 'action-menu' | 'action-bar' | 'content-menu';
|
|
136
|
+
|
|
137
|
+
// callback to run when the focus trap is released internally
|
|
138
|
+
// for example, when Tab is pressed in the action-menu mode
|
|
139
|
+
onRelease?: () => void;
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
trapFocus.release({
|
|
144
|
+
// By default, releasing the focus trap returns focus to the original trigger element.
|
|
145
|
+
// Use the withoutFocusReturn option to disable this behavior.
|
|
146
|
+
// This is useful when closing the element with an outside click and you want to avoid the page scrolling back to the trigger.
|
|
147
|
+
withoutFocusReturn: boolean
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### classNames
|
|
152
|
+
|
|
153
|
+
The `classNames` utility is a lightweight function for combining multiple class names into a single string. It's similar to the popular classnames library but is included directly in Reshaped, eliminating the need for an additional dependency.
|
|
154
|
+
|
|
155
|
+
Use this utility when building custom components that need to conditionally apply CSS classes based on props, state, or other conditions. It handles various input types including strings, booleans, null, and undefined values, making it easy to compose dynamic class names.
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
import { classNames } from "@reshaped/utilities";
|
|
159
|
+
import styles from "./Button.module.css";
|
|
160
|
+
|
|
161
|
+
const Button = ({ variant, disabled, className }) => {
|
|
162
|
+
const buttonClassNames = classNames(
|
|
163
|
+
styles.root,
|
|
164
|
+
variant && styles[`variant-${variant}`],
|
|
165
|
+
disabled && styles.disabled,
|
|
166
|
+
className
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return <button className={buttonClassNames}>Click me</button>;
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
You can also pass arrays of class names, which is useful when dealing with responsive class names or more complex scenarios:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
const containerClassNames = classNames(
|
|
177
|
+
styles.container,
|
|
178
|
+
[responsive && styles.responsive, styles.flex],
|
|
179
|
+
padding && [styles.paddingTop, styles.paddingBottom]
|
|
180
|
+
);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### lockScroll
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { lockScroll } from "@reshaped/utilities";
|
|
187
|
+
|
|
188
|
+
class Modal {
|
|
189
|
+
unlock;
|
|
190
|
+
|
|
191
|
+
constructor() {}
|
|
192
|
+
|
|
193
|
+
open() {
|
|
194
|
+
this.unlock = lockScroll();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
close() {
|
|
198
|
+
this.unlock?.();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
By default, `lockScroll` will lock the scrolling of the document body and there are a few additional options you can pass to customize the behavior. Calling the returned `unlockScroll` function will unlock the scrolling based on the originally passed options.
|
|
204
|
+
|
|
205
|
+
`lockScroll` options:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const unlock = lockScroll({
|
|
209
|
+
// lock the scrolling of a specific element
|
|
210
|
+
containerEl,
|
|
211
|
+
// specify an element that triggered the scroll lock
|
|
212
|
+
// specifying it will automatically find the closest scrollable parent to lock its scrolling instead of locking the whole document
|
|
213
|
+
originEl,
|
|
214
|
+
// callback triggered when the scroll gets locked
|
|
215
|
+
// in case it was already locked before when triggered, the callback won't run
|
|
216
|
+
callback,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
unlock({
|
|
220
|
+
// callback triggered when the scroll gets unlocked
|
|
221
|
+
callback,
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### isRTL
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { isRTL } from "@reshaped/utilities";
|
|
229
|
+
|
|
230
|
+
// Call anywhere in your code
|
|
231
|
+
const rtl = isRTL();
|
|
232
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type ID = number;
|
|
2
|
+
type Item<T> = {
|
|
3
|
+
previousId?: ID | null;
|
|
4
|
+
nextId?: ID | null;
|
|
5
|
+
data: T;
|
|
6
|
+
};
|
|
7
|
+
declare class Chain<T = unknown> {
|
|
8
|
+
chain: Record<ID, Item<T>>;
|
|
9
|
+
tailId: ID | null;
|
|
10
|
+
idCounter: ID;
|
|
11
|
+
generateId(): number;
|
|
12
|
+
getAll(): Record<number, Item<T>>;
|
|
13
|
+
get(id: ID): Item<T>;
|
|
14
|
+
isLast(id: ID): boolean;
|
|
15
|
+
isEmpty(): boolean;
|
|
16
|
+
add(data: T): number;
|
|
17
|
+
remove(id: ID): T | undefined;
|
|
18
|
+
removePreviousTill(id: ID, condition: (item: Item<T>) => boolean): T | undefined;
|
|
19
|
+
}
|
|
20
|
+
export default Chain;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
class Chain {
|
|
2
|
+
chain = {};
|
|
3
|
+
tailId = null;
|
|
4
|
+
idCounter = 0;
|
|
5
|
+
generateId() {
|
|
6
|
+
this.idCounter += 1;
|
|
7
|
+
return this.idCounter;
|
|
8
|
+
}
|
|
9
|
+
getAll() {
|
|
10
|
+
return this.chain;
|
|
11
|
+
}
|
|
12
|
+
get(id) {
|
|
13
|
+
return this.chain[id];
|
|
14
|
+
}
|
|
15
|
+
isLast(id) {
|
|
16
|
+
return this.tailId !== null && id === this.tailId;
|
|
17
|
+
}
|
|
18
|
+
isEmpty() {
|
|
19
|
+
return typeof this.tailId !== "number";
|
|
20
|
+
}
|
|
21
|
+
add(data) {
|
|
22
|
+
const previousId = this.tailId;
|
|
23
|
+
const previousItem = previousId && this.get(previousId);
|
|
24
|
+
const id = this.generateId();
|
|
25
|
+
this.chain[id] = { previousId, data };
|
|
26
|
+
if (previousItem)
|
|
27
|
+
previousItem.nextId = id;
|
|
28
|
+
this.tailId = id;
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
remove(id) {
|
|
32
|
+
const target = this.chain[id];
|
|
33
|
+
if (!target)
|
|
34
|
+
return;
|
|
35
|
+
const previousId = target.previousId;
|
|
36
|
+
const previousItem = previousId && this.get(previousId);
|
|
37
|
+
const nextId = target.nextId;
|
|
38
|
+
const nextItem = nextId && this.get(nextId);
|
|
39
|
+
if (previousItem)
|
|
40
|
+
previousItem.nextId = target.nextId ?? null;
|
|
41
|
+
if (nextItem)
|
|
42
|
+
nextItem.previousId = target.previousId ?? null;
|
|
43
|
+
if (!nextId)
|
|
44
|
+
this.tailId = previousId ?? null;
|
|
45
|
+
const data = this.get(id).data;
|
|
46
|
+
delete this.chain[id];
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
removePreviousTill(id, condition) {
|
|
50
|
+
const target = this.get(id);
|
|
51
|
+
const data = this.remove(id);
|
|
52
|
+
if (!target || !target.previousId)
|
|
53
|
+
return data;
|
|
54
|
+
const keepIterating = !condition(target);
|
|
55
|
+
if (keepIterating)
|
|
56
|
+
return this.removePreviousTill(target.previousId, condition);
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export default Chain;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Chain from "./Chain";
|
|
2
|
+
import type { FocusableElement, TrapMode } from "./types";
|
|
3
|
+
type ReleaseOptions = {
|
|
4
|
+
withoutFocusReturn?: boolean;
|
|
5
|
+
};
|
|
6
|
+
type TrapOptions = {
|
|
7
|
+
onRelease?: () => void;
|
|
8
|
+
includeTrigger?: boolean;
|
|
9
|
+
initialFocusEl?: FocusableElement | null;
|
|
10
|
+
mode?: TrapMode;
|
|
11
|
+
};
|
|
12
|
+
declare class TrapFocus {
|
|
13
|
+
#private;
|
|
14
|
+
static chain: Chain<TrapFocus>;
|
|
15
|
+
trapped?: boolean;
|
|
16
|
+
constructor();
|
|
17
|
+
/**
|
|
18
|
+
* Trap the focus, add observer and keyboard event listeners
|
|
19
|
+
* and create a chain item
|
|
20
|
+
*/
|
|
21
|
+
trap: (root: HTMLElement, options?: TrapOptions) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Disabled the trap focus for the element,
|
|
24
|
+
* cleanup all observers/handlers and trap for the previous element in the chain
|
|
25
|
+
*/
|
|
26
|
+
release: (releaseOptions?: ReleaseOptions) => void;
|
|
27
|
+
}
|
|
28
|
+
export default TrapFocus;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
import * as keys from "../constants/keys.js";
|
|
3
|
+
import { getShadowRoot } from "../dom/index.js";
|
|
4
|
+
import Chain from "./Chain.js";
|
|
5
|
+
import { getActiveElement, getFocusableElements, focusElement, getFocusData } from "./focus.js";
|
|
6
|
+
import { checkKeyboardMode } from "./keyboardMode.js";
|
|
7
|
+
import TrapScreenReader from "./TrapScreenReader.js";
|
|
8
|
+
class TrapFocus {
|
|
9
|
+
static chain = new Chain();
|
|
10
|
+
#chainId;
|
|
11
|
+
#root = null;
|
|
12
|
+
#trigger = null;
|
|
13
|
+
#options = {};
|
|
14
|
+
trapped;
|
|
15
|
+
#screenReaderTrap = null;
|
|
16
|
+
#mutationObserver = null;
|
|
17
|
+
constructor() { }
|
|
18
|
+
/**
|
|
19
|
+
* Handle keyboard navigation while focus is trapped
|
|
20
|
+
*/
|
|
21
|
+
#handleKeyDown = (event) => {
|
|
22
|
+
if (event.defaultPrevented)
|
|
23
|
+
return;
|
|
24
|
+
if (_a.chain.tailId !== this.#chainId)
|
|
25
|
+
return;
|
|
26
|
+
if (!this.#root)
|
|
27
|
+
return;
|
|
28
|
+
const { mode, onRelease, pseudoFocus, includeTrigger } = this.#options;
|
|
29
|
+
let navigationMode = "tabs";
|
|
30
|
+
if (mode === "action-menu" || mode === "selection-menu" || mode === "action-bar") {
|
|
31
|
+
navigationMode = "arrows";
|
|
32
|
+
}
|
|
33
|
+
const key = event.key;
|
|
34
|
+
const isTab = key === keys.TAB;
|
|
35
|
+
const isPrevTab = isTab && event.shiftKey;
|
|
36
|
+
const isNextTab = isTab && !event.shiftKey;
|
|
37
|
+
const isArrow = [keys.LEFT, keys.RIGHT, keys.UP, keys.DOWN].includes(key);
|
|
38
|
+
const isPrevArrow = navigationMode === "arrows" && key === (mode === "action-bar" ? keys.LEFT : keys.UP);
|
|
39
|
+
const isNextArrow = navigationMode === "arrows" && key === (mode === "action-bar" ? keys.RIGHT : keys.DOWN);
|
|
40
|
+
const isPrev = (isPrevTab && navigationMode === "tabs") || isPrevArrow;
|
|
41
|
+
const isNext = (isNextTab && navigationMode === "tabs") || isNextArrow;
|
|
42
|
+
const isFocusedOnTrigger = getActiveElement(this.#root) === this.#trigger;
|
|
43
|
+
const focusData = getFocusData({
|
|
44
|
+
root: this.#root,
|
|
45
|
+
target: isPrev ? "prev" : "next",
|
|
46
|
+
options: {
|
|
47
|
+
additionalElement: includeTrigger ? this.#trigger : undefined,
|
|
48
|
+
circular: true,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// Release the trap when tab is used in navigation modes that support arrows
|
|
52
|
+
const hasNavigatedOutside = (isTab && navigationMode === "arrows") ||
|
|
53
|
+
(mode === "content-menu" && isTab && focusData.overflow);
|
|
54
|
+
if (hasNavigatedOutside) {
|
|
55
|
+
// Prevent shift + tab event to avoid focus moving after the trap release
|
|
56
|
+
if (isPrevTab && !isFocusedOnTrigger)
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
this.release();
|
|
59
|
+
onRelease?.();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!isPrev && !isNext) {
|
|
63
|
+
// Avoid page from scrolling with arrow keys while focus it trapped
|
|
64
|
+
if (isArrow && (mode === "action-bar" || mode === "action-menu"))
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
if (!focusData.el)
|
|
70
|
+
return;
|
|
71
|
+
focusElement(focusData.el, { pseudoFocus });
|
|
72
|
+
};
|
|
73
|
+
#addListeners = () => {
|
|
74
|
+
const shadowRoot = getShadowRoot(this.#root);
|
|
75
|
+
const el = shadowRoot ?? document;
|
|
76
|
+
el.addEventListener("keydown", this.#handleKeyDown);
|
|
77
|
+
};
|
|
78
|
+
#removeListeners = () => {
|
|
79
|
+
const shadowRoot = getShadowRoot(this.#root);
|
|
80
|
+
const el = shadowRoot ?? document;
|
|
81
|
+
el.removeEventListener("keydown", this.#handleKeyDown);
|
|
82
|
+
};
|
|
83
|
+
#isLast = () => {
|
|
84
|
+
const tailItem = _a.chain.tailId && _a.chain.get(_a.chain.tailId);
|
|
85
|
+
return tailItem && tailItem.data.#root === this.#root;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Trap the focus, add observer and keyboard event listeners
|
|
89
|
+
* and create a chain item
|
|
90
|
+
*/
|
|
91
|
+
trap = (root, options = {}) => {
|
|
92
|
+
const { mode = "dialog", includeTrigger, initialFocusEl } = options;
|
|
93
|
+
this.#root = root;
|
|
94
|
+
this.#screenReaderTrap = new TrapScreenReader(root);
|
|
95
|
+
const trigger = getActiveElement(this.#root);
|
|
96
|
+
const focusable = getFocusableElements(this.#root, {
|
|
97
|
+
additionalElement: includeTrigger ? trigger : undefined,
|
|
98
|
+
});
|
|
99
|
+
const pseudoFocus = mode === "selection-menu";
|
|
100
|
+
this.#options = { ...options, pseudoFocus };
|
|
101
|
+
this.#trigger = trigger;
|
|
102
|
+
this.#mutationObserver = new MutationObserver(() => {
|
|
103
|
+
if (!this.#root)
|
|
104
|
+
return;
|
|
105
|
+
if (!this.#isLast())
|
|
106
|
+
return;
|
|
107
|
+
const currentActiveElement = getActiveElement(this.#root);
|
|
108
|
+
// Focus stayed inside the wrapper, no need to refocus
|
|
109
|
+
if (this.#root.contains(currentActiveElement))
|
|
110
|
+
return;
|
|
111
|
+
const focusable = getFocusableElements(this.#root, {
|
|
112
|
+
additionalElement: includeTrigger ? trigger : undefined,
|
|
113
|
+
});
|
|
114
|
+
if (!focusable.length)
|
|
115
|
+
return;
|
|
116
|
+
focusElement(focusable[0], { pseudoFocus });
|
|
117
|
+
});
|
|
118
|
+
this.#removeListeners();
|
|
119
|
+
this.#mutationObserver.observe(this.#root, { childList: true, subtree: true });
|
|
120
|
+
// Don't trap in case there is nothing to focus inside
|
|
121
|
+
if (!focusable.length && !initialFocusEl)
|
|
122
|
+
return;
|
|
123
|
+
this.#addListeners();
|
|
124
|
+
if (mode === "dialog")
|
|
125
|
+
this.#screenReaderTrap.trap();
|
|
126
|
+
const currentActiveElement = getActiveElement(this.#root);
|
|
127
|
+
const isLastInChain = this.#isLast();
|
|
128
|
+
// Don't add back to the chain if we're traversing back
|
|
129
|
+
if (!isLastInChain) {
|
|
130
|
+
this.#chainId = _a.chain.add(this);
|
|
131
|
+
// If the focus was moved manually (e.g. with autoFocus) - keep it there
|
|
132
|
+
if (!this.#root.contains(currentActiveElement)) {
|
|
133
|
+
focusElement(initialFocusEl || focusable[0], { pseudoFocus });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.trapped = true;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Disabled the trap focus for the element,
|
|
140
|
+
* cleanup all observers/handlers and trap for the previous element in the chain
|
|
141
|
+
*/
|
|
142
|
+
release = (releaseOptions = {}) => {
|
|
143
|
+
const { withoutFocusReturn } = releaseOptions;
|
|
144
|
+
if (!this.trapped || !this.#chainId || !this.#root)
|
|
145
|
+
return;
|
|
146
|
+
this.trapped = false;
|
|
147
|
+
if (this.#trigger && !withoutFocusReturn) {
|
|
148
|
+
this.#trigger.focus({ preventScroll: !checkKeyboardMode() });
|
|
149
|
+
}
|
|
150
|
+
_a.chain.removePreviousTill(this.#chainId, (item) => document.body.contains(item.data.#trigger));
|
|
151
|
+
this.#mutationObserver?.disconnect();
|
|
152
|
+
this.#removeListeners();
|
|
153
|
+
this.#screenReaderTrap?.release();
|
|
154
|
+
const previousItem = _a.chain.tailId && _a.chain.get(_a.chain.tailId);
|
|
155
|
+
if (previousItem && previousItem.data.#root) {
|
|
156
|
+
const trapInstance = new _a();
|
|
157
|
+
trapInstance.trap(previousItem.data.#root, previousItem.data.#options);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
_a = TrapFocus;
|
|
162
|
+
export default TrapFocus;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare class TrapScreenReader {
|
|
2
|
+
root: HTMLElement;
|
|
3
|
+
/**
|
|
4
|
+
* Elements ignored by screen reader when trap is active
|
|
5
|
+
*/
|
|
6
|
+
private hiddenElements;
|
|
7
|
+
constructor(root: HTMLElement);
|
|
8
|
+
/**
|
|
9
|
+
* Apply aria-hidden to all elements except the passed
|
|
10
|
+
*/
|
|
11
|
+
hideSiblingsFromScreenReader: (el: HTMLElement) => void;
|
|
12
|
+
release: () => void;
|
|
13
|
+
trap: () => void;
|
|
14
|
+
}
|
|
15
|
+
export default TrapScreenReader;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class TrapScreenReader {
|
|
2
|
+
root;
|
|
3
|
+
/**
|
|
4
|
+
* Elements ignored by screen reader when trap is active
|
|
5
|
+
*/
|
|
6
|
+
hiddenElements = [];
|
|
7
|
+
constructor(root) {
|
|
8
|
+
this.root = root;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Apply aria-hidden to all elements except the passed
|
|
12
|
+
*/
|
|
13
|
+
hideSiblingsFromScreenReader = (el) => {
|
|
14
|
+
let sibling = el.parentNode && el.parentNode.firstChild;
|
|
15
|
+
while (sibling) {
|
|
16
|
+
const notCurrent = sibling !== el;
|
|
17
|
+
const isValid = sibling.nodeType === 1 && !sibling.hasAttribute("aria-hidden");
|
|
18
|
+
if (notCurrent && isValid) {
|
|
19
|
+
sibling.setAttribute("aria-hidden", "true");
|
|
20
|
+
this.hiddenElements.push(sibling);
|
|
21
|
+
}
|
|
22
|
+
sibling = sibling.nextSibling;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
release = () => {
|
|
26
|
+
this.hiddenElements.forEach((el) => {
|
|
27
|
+
el.removeAttribute("aria-hidden");
|
|
28
|
+
});
|
|
29
|
+
this.hiddenElements = [];
|
|
30
|
+
};
|
|
31
|
+
trap = () => {
|
|
32
|
+
let currentEl = this.root;
|
|
33
|
+
this.release();
|
|
34
|
+
// Stop at the body level for regular pages
|
|
35
|
+
// And stop at shadow root
|
|
36
|
+
while (currentEl !== document.body && currentEl.parentElement) {
|
|
37
|
+
this.hideSiblingsFromScreenReader(currentEl);
|
|
38
|
+
currentEl = currentEl.parentElement;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export default TrapScreenReader;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FocusableElement, FocusableOptions } from "./types";
|
|
2
|
+
export declare const focusableSelector = "a,button,input:not([type=\"hidden\"]),textarea,select,details,[tabindex],[contenteditable]";
|
|
3
|
+
export declare const getActiveElement: (originEl?: HTMLElement | null) => HTMLButtonElement;
|
|
4
|
+
export declare const focusElement: (el: FocusableElement, options?: {
|
|
5
|
+
pseudoFocus?: boolean;
|
|
6
|
+
}) => void;
|
|
7
|
+
export declare const getFocusableElements: (rootEl: HTMLElement, options?: FocusableOptions) => FocusableElement[];
|
|
8
|
+
export declare const getFocusData: (args: {
|
|
9
|
+
root: HTMLElement;
|
|
10
|
+
target: "next" | "prev" | "first" | "last";
|
|
11
|
+
options?: FocusableOptions & {
|
|
12
|
+
circular?: boolean;
|
|
13
|
+
};
|
|
14
|
+
}) => {
|
|
15
|
+
overflow: boolean;
|
|
16
|
+
el: FocusableElement;
|
|
17
|
+
focusableElements: FocusableElement[];
|
|
18
|
+
};
|
|
19
|
+
export declare const focusNextElement: (root: HTMLElement, options?: {
|
|
20
|
+
circular?: boolean;
|
|
21
|
+
}) => {
|
|
22
|
+
el: FocusableElement;
|
|
23
|
+
focusableElements: FocusableElement[];
|
|
24
|
+
};
|
|
25
|
+
export declare const focusPreviousElement: (root: HTMLElement, options?: {
|
|
26
|
+
circular?: boolean;
|
|
27
|
+
}) => {
|
|
28
|
+
el: FocusableElement;
|
|
29
|
+
focusableElements: FocusableElement[];
|
|
30
|
+
};
|
|
31
|
+
export declare const focusFirstElement: (root: HTMLElement) => {
|
|
32
|
+
el: FocusableElement;
|
|
33
|
+
focusableElements: FocusableElement[];
|
|
34
|
+
};
|
|
35
|
+
export declare const focusLastElement: (root: HTMLElement) => {
|
|
36
|
+
el: FocusableElement;
|
|
37
|
+
focusableElements: FocusableElement[];
|
|
38
|
+
};
|