@lessonkit/accessibility 0.2.1 → 0.3.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 +10 -1
- package/dist/index.cjs +188 -0
- package/dist/index.d.cts +33 -1
- package/dist/index.d.ts +33 -1
- package/dist/index.js +183 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,9 +12,18 @@ Small accessibility utilities used by LessonKit packages and apps.
|
|
|
12
12
|
npm install @lessonkit/accessibility
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
## Included (0.
|
|
15
|
+
## Included (0.3.0)
|
|
16
16
|
|
|
17
17
|
- `prefersReducedMotion()`
|
|
18
|
+
- `getReducedMotionPreference()`
|
|
19
|
+
- `shouldAnimate({ default })`
|
|
18
20
|
- `focusFirst(container)`
|
|
21
|
+
- `getFocusableElements(container)`
|
|
22
|
+
- `trapFocus(container, opts)`
|
|
23
|
+
- `createRovingTabIndex(opts)`
|
|
19
24
|
- `visuallyHiddenStyle` — inline styles for screen-reader-only content (used by `@lessonkit/react` `Quiz`)
|
|
20
25
|
|
|
26
|
+
## Guidance
|
|
27
|
+
|
|
28
|
+
- See [`docs/ACCESSIBILITY.md`](../../docs/ACCESSIBILITY.md) for keyboard and screen-reader guidance.
|
|
29
|
+
|
package/dist/index.cjs
CHANGED
|
@@ -20,8 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
createRovingTabIndex: () => createRovingTabIndex,
|
|
23
24
|
focusFirst: () => focusFirst,
|
|
25
|
+
getFocusableElements: () => getFocusableElements,
|
|
26
|
+
getReducedMotionPreference: () => getReducedMotionPreference,
|
|
24
27
|
prefersReducedMotion: () => prefersReducedMotion,
|
|
28
|
+
shouldAnimate: () => shouldAnimate,
|
|
29
|
+
trapFocus: () => trapFocus,
|
|
25
30
|
visuallyHiddenStyle: () => visuallyHiddenStyle
|
|
26
31
|
});
|
|
27
32
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -36,9 +41,33 @@ var visuallyHiddenStyle = {
|
|
|
36
41
|
whiteSpace: "nowrap",
|
|
37
42
|
border: 0
|
|
38
43
|
};
|
|
44
|
+
var FOCUSABLE_SELECTORS = [
|
|
45
|
+
"a[href]",
|
|
46
|
+
"area[href]",
|
|
47
|
+
"button:not([disabled])",
|
|
48
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
49
|
+
"select:not([disabled])",
|
|
50
|
+
"textarea:not([disabled])",
|
|
51
|
+
// Prefer explicit tabindex, but allow contenteditable too.
|
|
52
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
53
|
+
"[contenteditable='true']"
|
|
54
|
+
].join(",");
|
|
55
|
+
function isHTMLElement(v) {
|
|
56
|
+
return typeof HTMLElement !== "undefined" && v instanceof HTMLElement;
|
|
57
|
+
}
|
|
39
58
|
function prefersReducedMotion() {
|
|
40
59
|
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
41
60
|
}
|
|
61
|
+
function getReducedMotionPreference() {
|
|
62
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "unknown";
|
|
63
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference";
|
|
64
|
+
}
|
|
65
|
+
function shouldAnimate(opts) {
|
|
66
|
+
const pref = getReducedMotionPreference();
|
|
67
|
+
if (pref === "reduce") return false;
|
|
68
|
+
if (pref === "no-preference") return true;
|
|
69
|
+
return opts?.default ?? true;
|
|
70
|
+
}
|
|
42
71
|
function focusFirst(container) {
|
|
43
72
|
if (!container) return false;
|
|
44
73
|
const el = container.querySelector(
|
|
@@ -54,9 +83,168 @@ function focusFirst(container) {
|
|
|
54
83
|
el?.focus();
|
|
55
84
|
return Boolean(el);
|
|
56
85
|
}
|
|
86
|
+
function getFocusableElements(container) {
|
|
87
|
+
const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS));
|
|
88
|
+
return candidates.filter((n) => isHTMLElement(n) && !n.hasAttribute("disabled"));
|
|
89
|
+
}
|
|
90
|
+
function trapFocus(container, opts) {
|
|
91
|
+
const restoreFocus = opts?.restoreFocus ?? true;
|
|
92
|
+
const allowOutsideClick = opts?.allowOutsideClick ?? false;
|
|
93
|
+
let active = false;
|
|
94
|
+
let prevFocused = null;
|
|
95
|
+
let removeListeners = null;
|
|
96
|
+
const onKeyDown = (e) => {
|
|
97
|
+
if (!active) return;
|
|
98
|
+
if (e.key !== "Tab") return;
|
|
99
|
+
const focusables = getFocusableElements(container);
|
|
100
|
+
if (!focusables.length) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
container.focus?.();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const current = document.activeElement;
|
|
106
|
+
const first = focusables[0];
|
|
107
|
+
const last = focusables[focusables.length - 1];
|
|
108
|
+
if (e.shiftKey) {
|
|
109
|
+
if (current === first || !container.contains(current)) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
last.focus();
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
if (current === last || !container.contains(current)) {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
first.focus();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const onFocusIn = (e) => {
|
|
121
|
+
if (!active) return;
|
|
122
|
+
const target = e.target;
|
|
123
|
+
if (!isHTMLElement(target)) return;
|
|
124
|
+
if (container.contains(target)) return;
|
|
125
|
+
const focusables = getFocusableElements(container);
|
|
126
|
+
(focusables[0] ?? container).focus?.();
|
|
127
|
+
};
|
|
128
|
+
const onPointerDownCapture = (e) => {
|
|
129
|
+
if (!active) return;
|
|
130
|
+
if (allowOutsideClick) return;
|
|
131
|
+
const target = e.target;
|
|
132
|
+
if (!isHTMLElement(target)) return;
|
|
133
|
+
if (container.contains(target)) return;
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
};
|
|
137
|
+
const activate = () => {
|
|
138
|
+
if (active) return;
|
|
139
|
+
active = true;
|
|
140
|
+
prevFocused = restoreFocus && isHTMLElement(document.activeElement) ? document.activeElement : null;
|
|
141
|
+
document.addEventListener("keydown", onKeyDown);
|
|
142
|
+
document.addEventListener("focusin", onFocusIn);
|
|
143
|
+
document.addEventListener("mousedown", onPointerDownCapture, true);
|
|
144
|
+
removeListeners = () => {
|
|
145
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
146
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
147
|
+
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
|
148
|
+
};
|
|
149
|
+
const focusables = getFocusableElements(container);
|
|
150
|
+
const requested = opts?.initialFocus;
|
|
151
|
+
if (requested === "first" || requested === void 0) {
|
|
152
|
+
(focusables[0] ?? container).focus?.();
|
|
153
|
+
} else if (isHTMLElement(requested)) {
|
|
154
|
+
requested.focus();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const deactivate = () => {
|
|
158
|
+
if (!active) return;
|
|
159
|
+
active = false;
|
|
160
|
+
removeListeners?.();
|
|
161
|
+
removeListeners = null;
|
|
162
|
+
const toRestore = prevFocused;
|
|
163
|
+
prevFocused = null;
|
|
164
|
+
if (restoreFocus) toRestore?.focus?.();
|
|
165
|
+
};
|
|
166
|
+
return { activate, deactivate };
|
|
167
|
+
}
|
|
168
|
+
function createRovingTabIndex(opts) {
|
|
169
|
+
const itemCount = Math.max(0, opts.itemCount);
|
|
170
|
+
const loop = opts.loop ?? true;
|
|
171
|
+
const orientation = opts.orientation ?? "both";
|
|
172
|
+
let activeIndex = clampIndex(opts.initialIndex ?? 0, itemCount);
|
|
173
|
+
const setActiveIndex = (next) => {
|
|
174
|
+
activeIndex = clampIndex(next, itemCount);
|
|
175
|
+
};
|
|
176
|
+
const move = (delta) => {
|
|
177
|
+
if (!itemCount) return;
|
|
178
|
+
const next = activeIndex + delta;
|
|
179
|
+
if (loop) {
|
|
180
|
+
activeIndex = (next % itemCount + itemCount) % itemCount;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
activeIndex = clampIndex(next, itemCount);
|
|
184
|
+
};
|
|
185
|
+
const onKeyDown = (e) => {
|
|
186
|
+
if (!itemCount) return;
|
|
187
|
+
switch (e.key) {
|
|
188
|
+
case "Home":
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
activeIndex = 0;
|
|
191
|
+
return;
|
|
192
|
+
case "End":
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
activeIndex = itemCount - 1;
|
|
195
|
+
return;
|
|
196
|
+
case "ArrowLeft":
|
|
197
|
+
if (orientation === "vertical") return;
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
move(-1);
|
|
200
|
+
return;
|
|
201
|
+
case "ArrowRight":
|
|
202
|
+
if (orientation === "vertical") return;
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
move(1);
|
|
205
|
+
return;
|
|
206
|
+
case "ArrowUp":
|
|
207
|
+
if (orientation === "horizontal") return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
move(-1);
|
|
210
|
+
return;
|
|
211
|
+
case "ArrowDown":
|
|
212
|
+
if (orientation === "horizontal") return;
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
move(1);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const getItemProps = (index) => ({
|
|
219
|
+
tabIndex: index === activeIndex ? 0 : -1,
|
|
220
|
+
id: opts.getId ? opts.getId(index) : void 0,
|
|
221
|
+
onKeyDown,
|
|
222
|
+
onFocus: () => {
|
|
223
|
+
setActiveIndex(index);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
get activeIndex() {
|
|
228
|
+
return activeIndex;
|
|
229
|
+
},
|
|
230
|
+
setActiveIndex,
|
|
231
|
+
getItemProps
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function clampIndex(index, itemCount) {
|
|
235
|
+
if (itemCount <= 0) return 0;
|
|
236
|
+
if (index < 0) return 0;
|
|
237
|
+
if (index >= itemCount) return itemCount - 1;
|
|
238
|
+
return index;
|
|
239
|
+
}
|
|
57
240
|
// Annotate the CommonJS export names for ESM import in node:
|
|
58
241
|
0 && (module.exports = {
|
|
242
|
+
createRovingTabIndex,
|
|
59
243
|
focusFirst,
|
|
244
|
+
getFocusableElements,
|
|
245
|
+
getReducedMotionPreference,
|
|
60
246
|
prefersReducedMotion,
|
|
247
|
+
shouldAnimate,
|
|
248
|
+
trapFocus,
|
|
61
249
|
visuallyHiddenStyle
|
|
62
250
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,38 @@ type FocusContainer = {
|
|
|
7
7
|
querySelector<T>(selectors: string): T | null;
|
|
8
8
|
};
|
|
9
9
|
declare function prefersReducedMotion(): boolean;
|
|
10
|
+
declare function getReducedMotionPreference(): "reduce" | "no-preference" | "unknown";
|
|
11
|
+
declare function shouldAnimate(opts?: {
|
|
12
|
+
default?: boolean;
|
|
13
|
+
}): boolean;
|
|
10
14
|
declare function focusFirst(container: FocusContainer | null): boolean;
|
|
15
|
+
declare function getFocusableElements(container: Element): HTMLElement[];
|
|
16
|
+
declare function trapFocus(container: HTMLElement, opts?: {
|
|
17
|
+
initialFocus?: HTMLElement | "first";
|
|
18
|
+
restoreFocus?: boolean;
|
|
19
|
+
allowOutsideClick?: boolean;
|
|
20
|
+
}): {
|
|
21
|
+
activate: () => void;
|
|
22
|
+
deactivate: () => void;
|
|
23
|
+
};
|
|
24
|
+
declare function createRovingTabIndex(opts: {
|
|
25
|
+
itemCount: number;
|
|
26
|
+
getId?: (index: number) => string;
|
|
27
|
+
orientation?: "horizontal" | "vertical" | "both";
|
|
28
|
+
loop?: boolean;
|
|
29
|
+
initialIndex?: number;
|
|
30
|
+
}): {
|
|
31
|
+
get activeIndex(): number;
|
|
32
|
+
setActiveIndex: (next: number) => void;
|
|
33
|
+
getItemProps: (index: number) => {
|
|
34
|
+
tabIndex: 0 | -1;
|
|
35
|
+
onKeyDown: (e: KeyboardEvent | {
|
|
36
|
+
key: string;
|
|
37
|
+
preventDefault: () => void;
|
|
38
|
+
}) => void;
|
|
39
|
+
onFocus: () => void;
|
|
40
|
+
id?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
11
43
|
|
|
12
|
-
export { type FocusContainer, type Focusable, focusFirst, prefersReducedMotion, visuallyHiddenStyle };
|
|
44
|
+
export { type FocusContainer, type Focusable, createRovingTabIndex, focusFirst, getFocusableElements, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, trapFocus, visuallyHiddenStyle };
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,38 @@ type FocusContainer = {
|
|
|
7
7
|
querySelector<T>(selectors: string): T | null;
|
|
8
8
|
};
|
|
9
9
|
declare function prefersReducedMotion(): boolean;
|
|
10
|
+
declare function getReducedMotionPreference(): "reduce" | "no-preference" | "unknown";
|
|
11
|
+
declare function shouldAnimate(opts?: {
|
|
12
|
+
default?: boolean;
|
|
13
|
+
}): boolean;
|
|
10
14
|
declare function focusFirst(container: FocusContainer | null): boolean;
|
|
15
|
+
declare function getFocusableElements(container: Element): HTMLElement[];
|
|
16
|
+
declare function trapFocus(container: HTMLElement, opts?: {
|
|
17
|
+
initialFocus?: HTMLElement | "first";
|
|
18
|
+
restoreFocus?: boolean;
|
|
19
|
+
allowOutsideClick?: boolean;
|
|
20
|
+
}): {
|
|
21
|
+
activate: () => void;
|
|
22
|
+
deactivate: () => void;
|
|
23
|
+
};
|
|
24
|
+
declare function createRovingTabIndex(opts: {
|
|
25
|
+
itemCount: number;
|
|
26
|
+
getId?: (index: number) => string;
|
|
27
|
+
orientation?: "horizontal" | "vertical" | "both";
|
|
28
|
+
loop?: boolean;
|
|
29
|
+
initialIndex?: number;
|
|
30
|
+
}): {
|
|
31
|
+
get activeIndex(): number;
|
|
32
|
+
setActiveIndex: (next: number) => void;
|
|
33
|
+
getItemProps: (index: number) => {
|
|
34
|
+
tabIndex: 0 | -1;
|
|
35
|
+
onKeyDown: (e: KeyboardEvent | {
|
|
36
|
+
key: string;
|
|
37
|
+
preventDefault: () => void;
|
|
38
|
+
}) => void;
|
|
39
|
+
onFocus: () => void;
|
|
40
|
+
id?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
11
43
|
|
|
12
|
-
export { type FocusContainer, type Focusable, focusFirst, prefersReducedMotion, visuallyHiddenStyle };
|
|
44
|
+
export { type FocusContainer, type Focusable, createRovingTabIndex, focusFirst, getFocusableElements, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, trapFocus, visuallyHiddenStyle };
|
package/dist/index.js
CHANGED
|
@@ -10,9 +10,33 @@ var visuallyHiddenStyle = {
|
|
|
10
10
|
whiteSpace: "nowrap",
|
|
11
11
|
border: 0
|
|
12
12
|
};
|
|
13
|
+
var FOCUSABLE_SELECTORS = [
|
|
14
|
+
"a[href]",
|
|
15
|
+
"area[href]",
|
|
16
|
+
"button:not([disabled])",
|
|
17
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
18
|
+
"select:not([disabled])",
|
|
19
|
+
"textarea:not([disabled])",
|
|
20
|
+
// Prefer explicit tabindex, but allow contenteditable too.
|
|
21
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
22
|
+
"[contenteditable='true']"
|
|
23
|
+
].join(",");
|
|
24
|
+
function isHTMLElement(v) {
|
|
25
|
+
return typeof HTMLElement !== "undefined" && v instanceof HTMLElement;
|
|
26
|
+
}
|
|
13
27
|
function prefersReducedMotion() {
|
|
14
28
|
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
15
29
|
}
|
|
30
|
+
function getReducedMotionPreference() {
|
|
31
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "unknown";
|
|
32
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference";
|
|
33
|
+
}
|
|
34
|
+
function shouldAnimate(opts) {
|
|
35
|
+
const pref = getReducedMotionPreference();
|
|
36
|
+
if (pref === "reduce") return false;
|
|
37
|
+
if (pref === "no-preference") return true;
|
|
38
|
+
return opts?.default ?? true;
|
|
39
|
+
}
|
|
16
40
|
function focusFirst(container) {
|
|
17
41
|
if (!container) return false;
|
|
18
42
|
const el = container.querySelector(
|
|
@@ -28,8 +52,167 @@ function focusFirst(container) {
|
|
|
28
52
|
el?.focus();
|
|
29
53
|
return Boolean(el);
|
|
30
54
|
}
|
|
55
|
+
function getFocusableElements(container) {
|
|
56
|
+
const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS));
|
|
57
|
+
return candidates.filter((n) => isHTMLElement(n) && !n.hasAttribute("disabled"));
|
|
58
|
+
}
|
|
59
|
+
function trapFocus(container, opts) {
|
|
60
|
+
const restoreFocus = opts?.restoreFocus ?? true;
|
|
61
|
+
const allowOutsideClick = opts?.allowOutsideClick ?? false;
|
|
62
|
+
let active = false;
|
|
63
|
+
let prevFocused = null;
|
|
64
|
+
let removeListeners = null;
|
|
65
|
+
const onKeyDown = (e) => {
|
|
66
|
+
if (!active) return;
|
|
67
|
+
if (e.key !== "Tab") return;
|
|
68
|
+
const focusables = getFocusableElements(container);
|
|
69
|
+
if (!focusables.length) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
container.focus?.();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const current = document.activeElement;
|
|
75
|
+
const first = focusables[0];
|
|
76
|
+
const last = focusables[focusables.length - 1];
|
|
77
|
+
if (e.shiftKey) {
|
|
78
|
+
if (current === first || !container.contains(current)) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
last.focus();
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
if (current === last || !container.contains(current)) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
first.focus();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const onFocusIn = (e) => {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
const target = e.target;
|
|
92
|
+
if (!isHTMLElement(target)) return;
|
|
93
|
+
if (container.contains(target)) return;
|
|
94
|
+
const focusables = getFocusableElements(container);
|
|
95
|
+
(focusables[0] ?? container).focus?.();
|
|
96
|
+
};
|
|
97
|
+
const onPointerDownCapture = (e) => {
|
|
98
|
+
if (!active) return;
|
|
99
|
+
if (allowOutsideClick) return;
|
|
100
|
+
const target = e.target;
|
|
101
|
+
if (!isHTMLElement(target)) return;
|
|
102
|
+
if (container.contains(target)) return;
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
};
|
|
106
|
+
const activate = () => {
|
|
107
|
+
if (active) return;
|
|
108
|
+
active = true;
|
|
109
|
+
prevFocused = restoreFocus && isHTMLElement(document.activeElement) ? document.activeElement : null;
|
|
110
|
+
document.addEventListener("keydown", onKeyDown);
|
|
111
|
+
document.addEventListener("focusin", onFocusIn);
|
|
112
|
+
document.addEventListener("mousedown", onPointerDownCapture, true);
|
|
113
|
+
removeListeners = () => {
|
|
114
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
115
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
116
|
+
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
|
117
|
+
};
|
|
118
|
+
const focusables = getFocusableElements(container);
|
|
119
|
+
const requested = opts?.initialFocus;
|
|
120
|
+
if (requested === "first" || requested === void 0) {
|
|
121
|
+
(focusables[0] ?? container).focus?.();
|
|
122
|
+
} else if (isHTMLElement(requested)) {
|
|
123
|
+
requested.focus();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const deactivate = () => {
|
|
127
|
+
if (!active) return;
|
|
128
|
+
active = false;
|
|
129
|
+
removeListeners?.();
|
|
130
|
+
removeListeners = null;
|
|
131
|
+
const toRestore = prevFocused;
|
|
132
|
+
prevFocused = null;
|
|
133
|
+
if (restoreFocus) toRestore?.focus?.();
|
|
134
|
+
};
|
|
135
|
+
return { activate, deactivate };
|
|
136
|
+
}
|
|
137
|
+
function createRovingTabIndex(opts) {
|
|
138
|
+
const itemCount = Math.max(0, opts.itemCount);
|
|
139
|
+
const loop = opts.loop ?? true;
|
|
140
|
+
const orientation = opts.orientation ?? "both";
|
|
141
|
+
let activeIndex = clampIndex(opts.initialIndex ?? 0, itemCount);
|
|
142
|
+
const setActiveIndex = (next) => {
|
|
143
|
+
activeIndex = clampIndex(next, itemCount);
|
|
144
|
+
};
|
|
145
|
+
const move = (delta) => {
|
|
146
|
+
if (!itemCount) return;
|
|
147
|
+
const next = activeIndex + delta;
|
|
148
|
+
if (loop) {
|
|
149
|
+
activeIndex = (next % itemCount + itemCount) % itemCount;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
activeIndex = clampIndex(next, itemCount);
|
|
153
|
+
};
|
|
154
|
+
const onKeyDown = (e) => {
|
|
155
|
+
if (!itemCount) return;
|
|
156
|
+
switch (e.key) {
|
|
157
|
+
case "Home":
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
activeIndex = 0;
|
|
160
|
+
return;
|
|
161
|
+
case "End":
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
activeIndex = itemCount - 1;
|
|
164
|
+
return;
|
|
165
|
+
case "ArrowLeft":
|
|
166
|
+
if (orientation === "vertical") return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
move(-1);
|
|
169
|
+
return;
|
|
170
|
+
case "ArrowRight":
|
|
171
|
+
if (orientation === "vertical") return;
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
move(1);
|
|
174
|
+
return;
|
|
175
|
+
case "ArrowUp":
|
|
176
|
+
if (orientation === "horizontal") return;
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
move(-1);
|
|
179
|
+
return;
|
|
180
|
+
case "ArrowDown":
|
|
181
|
+
if (orientation === "horizontal") return;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
move(1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const getItemProps = (index) => ({
|
|
188
|
+
tabIndex: index === activeIndex ? 0 : -1,
|
|
189
|
+
id: opts.getId ? opts.getId(index) : void 0,
|
|
190
|
+
onKeyDown,
|
|
191
|
+
onFocus: () => {
|
|
192
|
+
setActiveIndex(index);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
get activeIndex() {
|
|
197
|
+
return activeIndex;
|
|
198
|
+
},
|
|
199
|
+
setActiveIndex,
|
|
200
|
+
getItemProps
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function clampIndex(index, itemCount) {
|
|
204
|
+
if (itemCount <= 0) return 0;
|
|
205
|
+
if (index < 0) return 0;
|
|
206
|
+
if (index >= itemCount) return itemCount - 1;
|
|
207
|
+
return index;
|
|
208
|
+
}
|
|
31
209
|
export {
|
|
210
|
+
createRovingTabIndex,
|
|
32
211
|
focusFirst,
|
|
212
|
+
getFocusableElements,
|
|
213
|
+
getReducedMotionPreference,
|
|
33
214
|
prefersReducedMotion,
|
|
215
|
+
shouldAnimate,
|
|
216
|
+
trapFocus,
|
|
34
217
|
visuallyHiddenStyle
|
|
35
218
|
};
|