@lessonkit/accessibility 0.2.1 → 0.3.1

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 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.2.1)
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/accessibility",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Accessibility utilities for LessonKit packages and apps.",
6
6
  "license": "Apache-2.0",