@lessonkit/accessibility 1.2.0 → 1.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/dist/index.cjs CHANGED
@@ -23,9 +23,11 @@ __export(index_exports, {
23
23
  createRovingTabIndex: () => createRovingTabIndex,
24
24
  focusFirst: () => focusFirst,
25
25
  getFocusableElements: () => getFocusableElements,
26
+ getPrefersReducedMotionSnapshot: () => getPrefersReducedMotionSnapshot,
26
27
  getReducedMotionPreference: () => getReducedMotionPreference,
27
28
  prefersReducedMotion: () => prefersReducedMotion,
28
29
  shouldAnimate: () => shouldAnimate,
30
+ subscribeReducedMotion: () => subscribeReducedMotion,
29
31
  trapFocus: () => trapFocus,
30
32
  visuallyHiddenStyle: () => visuallyHiddenStyle
31
33
  });
@@ -56,8 +58,23 @@ function isHTMLElement(v) {
56
58
  return typeof HTMLElement !== "undefined" && v instanceof HTMLElement;
57
59
  }
58
60
  function prefersReducedMotion() {
61
+ return getPrefersReducedMotionSnapshot();
62
+ }
63
+ function getPrefersReducedMotionSnapshot() {
59
64
  return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
60
65
  }
66
+ function subscribeReducedMotion(listener) {
67
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
68
+ listener(false);
69
+ return () => {
70
+ };
71
+ }
72
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
73
+ const onChange = () => listener(mq.matches);
74
+ listener(mq.matches);
75
+ mq.addEventListener("change", onChange);
76
+ return () => mq.removeEventListener("change", onChange);
77
+ }
61
78
  function getReducedMotionPreference() {
62
79
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "unknown";
63
80
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference";
@@ -70,22 +87,29 @@ function shouldAnimate(opts) {
70
87
  }
71
88
  function focusFirst(container) {
72
89
  if (!container) return false;
73
- const el = container.querySelector(
74
- [
75
- "a[href]",
76
- "button:not([disabled])",
77
- "input:not([disabled])",
78
- "select:not([disabled])",
79
- "textarea:not([disabled])",
80
- "[tabindex]:not([tabindex='-1'])"
81
- ].join(",")
82
- );
90
+ const el = container.querySelector(FOCUSABLE_SELECTORS);
83
91
  el?.focus();
84
92
  return Boolean(el);
85
93
  }
86
94
  function getFocusableElements(container) {
87
95
  const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS));
88
- return candidates.filter((n) => isHTMLElement(n) && !n.hasAttribute("disabled"));
96
+ return candidates.filter(
97
+ (n) => isHTMLElement(n) && !n.hasAttribute("disabled") && isVisibleFocusable(n)
98
+ );
99
+ }
100
+ function isVisibleFocusable(el) {
101
+ if (el.closest("[inert], [aria-hidden='true']")) return false;
102
+ if (typeof el.checkVisibility === "function") {
103
+ try {
104
+ return el.checkVisibility();
105
+ } catch {
106
+ }
107
+ }
108
+ if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
109
+ const style = window.getComputedStyle(el);
110
+ if (style.display === "none" || style.visibility === "hidden") return false;
111
+ }
112
+ return true;
89
113
  }
90
114
  function trapFocus(container, opts) {
91
115
  const restoreFocus = opts?.restoreFocus ?? true;
@@ -140,10 +164,12 @@ function trapFocus(container, opts) {
140
164
  prevFocused = restoreFocus && isHTMLElement(document.activeElement) ? document.activeElement : null;
141
165
  document.addEventListener("keydown", onKeyDown);
142
166
  document.addEventListener("focusin", onFocusIn);
167
+ document.addEventListener("pointerdown", onPointerDownCapture, true);
143
168
  document.addEventListener("mousedown", onPointerDownCapture, true);
144
169
  removeListeners = () => {
145
170
  document.removeEventListener("keydown", onKeyDown);
146
171
  document.removeEventListener("focusin", onFocusIn);
172
+ document.removeEventListener("pointerdown", onPointerDownCapture, true);
147
173
  document.removeEventListener("mousedown", onPointerDownCapture, true);
148
174
  };
149
175
  const focusables = getFocusableElements(container);
@@ -170,28 +196,41 @@ function createRovingTabIndex(opts) {
170
196
  const loop = opts.loop ?? true;
171
197
  const orientation = opts.orientation ?? "both";
172
198
  let activeIndex = clampIndex(opts.initialIndex ?? 0, itemCount);
199
+ const focusActiveItem = () => {
200
+ if (!opts.getId || typeof document === "undefined") return;
201
+ const id = opts.getId(activeIndex);
202
+ if (!id) return;
203
+ document.getElementById(id)?.focus();
204
+ };
205
+ const notifyActiveChange = () => {
206
+ opts.onActiveIndexChange?.(activeIndex);
207
+ };
173
208
  const setActiveIndex = (next) => {
174
209
  activeIndex = clampIndex(next, itemCount);
210
+ notifyActiveChange();
211
+ focusActiveItem();
175
212
  };
176
213
  const move = (delta) => {
177
214
  if (!itemCount) return;
178
215
  const next = activeIndex + delta;
179
216
  if (loop) {
180
217
  activeIndex = (next % itemCount + itemCount) % itemCount;
181
- return;
218
+ } else {
219
+ activeIndex = clampIndex(next, itemCount);
182
220
  }
183
- activeIndex = clampIndex(next, itemCount);
221
+ notifyActiveChange();
222
+ focusActiveItem();
184
223
  };
185
224
  const onKeyDown = (e) => {
186
225
  if (!itemCount) return;
187
226
  switch (e.key) {
188
227
  case "Home":
189
228
  e.preventDefault();
190
- activeIndex = 0;
229
+ setActiveIndex(0);
191
230
  return;
192
231
  case "End":
193
232
  e.preventDefault();
194
- activeIndex = itemCount - 1;
233
+ setActiveIndex(itemCount - 1);
195
234
  return;
196
235
  case "ArrowLeft":
197
236
  if (orientation === "vertical") return;
@@ -242,9 +281,11 @@ function clampIndex(index, itemCount) {
242
281
  createRovingTabIndex,
243
282
  focusFirst,
244
283
  getFocusableElements,
284
+ getPrefersReducedMotionSnapshot,
245
285
  getReducedMotionPreference,
246
286
  prefersReducedMotion,
247
287
  shouldAnimate,
288
+ subscribeReducedMotion,
248
289
  trapFocus,
249
290
  visuallyHiddenStyle
250
291
  });
package/dist/index.d.cts CHANGED
@@ -23,6 +23,8 @@ type RovingTabIndexOptions = {
23
23
  orientation?: "horizontal" | "vertical" | "both";
24
24
  loop?: boolean;
25
25
  initialIndex?: number;
26
+ /** Called when the active index changes so consumers can re-render. */
27
+ onActiveIndexChange?: (index: number) => void;
26
28
  };
27
29
  /** Screen-reader-only styles (no external CSS required). */
28
30
  declare const visuallyHiddenStyle: VisuallyHiddenStyle;
@@ -30,6 +32,10 @@ type FocusContainer = {
30
32
  querySelector<T>(selectors: string): T | null;
31
33
  };
32
34
  declare function prefersReducedMotion(): boolean;
35
+ /** One-shot read of the prefers-reduced-motion media query. */
36
+ declare function getPrefersReducedMotionSnapshot(): boolean;
37
+ /** Subscribe to OS reduced-motion preference changes; returns an unsubscribe function. */
38
+ declare function subscribeReducedMotion(listener: (reduce: boolean) => void): () => void;
33
39
  declare function getReducedMotionPreference(): "reduce" | "no-preference" | "unknown";
34
40
  declare function shouldAnimate(opts?: {
35
41
  default?: boolean;
@@ -54,4 +60,4 @@ declare function createRovingTabIndex(opts: RovingTabIndexOptions): {
54
60
  };
55
61
  };
56
62
 
57
- export { type FocusContainer, type Focusable, type RovingTabIndexOptions, type TrapFocusOptions, type VisuallyHiddenStyle, createRovingTabIndex, focusFirst, getFocusableElements, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, trapFocus, visuallyHiddenStyle };
63
+ export { type FocusContainer, type Focusable, type RovingTabIndexOptions, type TrapFocusOptions, type VisuallyHiddenStyle, createRovingTabIndex, focusFirst, getFocusableElements, getPrefersReducedMotionSnapshot, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, subscribeReducedMotion, trapFocus, visuallyHiddenStyle };
package/dist/index.d.ts CHANGED
@@ -23,6 +23,8 @@ type RovingTabIndexOptions = {
23
23
  orientation?: "horizontal" | "vertical" | "both";
24
24
  loop?: boolean;
25
25
  initialIndex?: number;
26
+ /** Called when the active index changes so consumers can re-render. */
27
+ onActiveIndexChange?: (index: number) => void;
26
28
  };
27
29
  /** Screen-reader-only styles (no external CSS required). */
28
30
  declare const visuallyHiddenStyle: VisuallyHiddenStyle;
@@ -30,6 +32,10 @@ type FocusContainer = {
30
32
  querySelector<T>(selectors: string): T | null;
31
33
  };
32
34
  declare function prefersReducedMotion(): boolean;
35
+ /** One-shot read of the prefers-reduced-motion media query. */
36
+ declare function getPrefersReducedMotionSnapshot(): boolean;
37
+ /** Subscribe to OS reduced-motion preference changes; returns an unsubscribe function. */
38
+ declare function subscribeReducedMotion(listener: (reduce: boolean) => void): () => void;
33
39
  declare function getReducedMotionPreference(): "reduce" | "no-preference" | "unknown";
34
40
  declare function shouldAnimate(opts?: {
35
41
  default?: boolean;
@@ -54,4 +60,4 @@ declare function createRovingTabIndex(opts: RovingTabIndexOptions): {
54
60
  };
55
61
  };
56
62
 
57
- export { type FocusContainer, type Focusable, type RovingTabIndexOptions, type TrapFocusOptions, type VisuallyHiddenStyle, createRovingTabIndex, focusFirst, getFocusableElements, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, trapFocus, visuallyHiddenStyle };
63
+ export { type FocusContainer, type Focusable, type RovingTabIndexOptions, type TrapFocusOptions, type VisuallyHiddenStyle, createRovingTabIndex, focusFirst, getFocusableElements, getPrefersReducedMotionSnapshot, getReducedMotionPreference, prefersReducedMotion, shouldAnimate, subscribeReducedMotion, trapFocus, visuallyHiddenStyle };
package/dist/index.js CHANGED
@@ -25,8 +25,23 @@ function isHTMLElement(v) {
25
25
  return typeof HTMLElement !== "undefined" && v instanceof HTMLElement;
26
26
  }
27
27
  function prefersReducedMotion() {
28
+ return getPrefersReducedMotionSnapshot();
29
+ }
30
+ function getPrefersReducedMotionSnapshot() {
28
31
  return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
29
32
  }
33
+ function subscribeReducedMotion(listener) {
34
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
35
+ listener(false);
36
+ return () => {
37
+ };
38
+ }
39
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
40
+ const onChange = () => listener(mq.matches);
41
+ listener(mq.matches);
42
+ mq.addEventListener("change", onChange);
43
+ return () => mq.removeEventListener("change", onChange);
44
+ }
30
45
  function getReducedMotionPreference() {
31
46
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "unknown";
32
47
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference";
@@ -39,22 +54,29 @@ function shouldAnimate(opts) {
39
54
  }
40
55
  function focusFirst(container) {
41
56
  if (!container) return false;
42
- const el = container.querySelector(
43
- [
44
- "a[href]",
45
- "button:not([disabled])",
46
- "input:not([disabled])",
47
- "select:not([disabled])",
48
- "textarea:not([disabled])",
49
- "[tabindex]:not([tabindex='-1'])"
50
- ].join(",")
51
- );
57
+ const el = container.querySelector(FOCUSABLE_SELECTORS);
52
58
  el?.focus();
53
59
  return Boolean(el);
54
60
  }
55
61
  function getFocusableElements(container) {
56
62
  const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS));
57
- return candidates.filter((n) => isHTMLElement(n) && !n.hasAttribute("disabled"));
63
+ return candidates.filter(
64
+ (n) => isHTMLElement(n) && !n.hasAttribute("disabled") && isVisibleFocusable(n)
65
+ );
66
+ }
67
+ function isVisibleFocusable(el) {
68
+ if (el.closest("[inert], [aria-hidden='true']")) return false;
69
+ if (typeof el.checkVisibility === "function") {
70
+ try {
71
+ return el.checkVisibility();
72
+ } catch {
73
+ }
74
+ }
75
+ if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
76
+ const style = window.getComputedStyle(el);
77
+ if (style.display === "none" || style.visibility === "hidden") return false;
78
+ }
79
+ return true;
58
80
  }
59
81
  function trapFocus(container, opts) {
60
82
  const restoreFocus = opts?.restoreFocus ?? true;
@@ -109,10 +131,12 @@ function trapFocus(container, opts) {
109
131
  prevFocused = restoreFocus && isHTMLElement(document.activeElement) ? document.activeElement : null;
110
132
  document.addEventListener("keydown", onKeyDown);
111
133
  document.addEventListener("focusin", onFocusIn);
134
+ document.addEventListener("pointerdown", onPointerDownCapture, true);
112
135
  document.addEventListener("mousedown", onPointerDownCapture, true);
113
136
  removeListeners = () => {
114
137
  document.removeEventListener("keydown", onKeyDown);
115
138
  document.removeEventListener("focusin", onFocusIn);
139
+ document.removeEventListener("pointerdown", onPointerDownCapture, true);
116
140
  document.removeEventListener("mousedown", onPointerDownCapture, true);
117
141
  };
118
142
  const focusables = getFocusableElements(container);
@@ -139,28 +163,41 @@ function createRovingTabIndex(opts) {
139
163
  const loop = opts.loop ?? true;
140
164
  const orientation = opts.orientation ?? "both";
141
165
  let activeIndex = clampIndex(opts.initialIndex ?? 0, itemCount);
166
+ const focusActiveItem = () => {
167
+ if (!opts.getId || typeof document === "undefined") return;
168
+ const id = opts.getId(activeIndex);
169
+ if (!id) return;
170
+ document.getElementById(id)?.focus();
171
+ };
172
+ const notifyActiveChange = () => {
173
+ opts.onActiveIndexChange?.(activeIndex);
174
+ };
142
175
  const setActiveIndex = (next) => {
143
176
  activeIndex = clampIndex(next, itemCount);
177
+ notifyActiveChange();
178
+ focusActiveItem();
144
179
  };
145
180
  const move = (delta) => {
146
181
  if (!itemCount) return;
147
182
  const next = activeIndex + delta;
148
183
  if (loop) {
149
184
  activeIndex = (next % itemCount + itemCount) % itemCount;
150
- return;
185
+ } else {
186
+ activeIndex = clampIndex(next, itemCount);
151
187
  }
152
- activeIndex = clampIndex(next, itemCount);
188
+ notifyActiveChange();
189
+ focusActiveItem();
153
190
  };
154
191
  const onKeyDown = (e) => {
155
192
  if (!itemCount) return;
156
193
  switch (e.key) {
157
194
  case "Home":
158
195
  e.preventDefault();
159
- activeIndex = 0;
196
+ setActiveIndex(0);
160
197
  return;
161
198
  case "End":
162
199
  e.preventDefault();
163
- activeIndex = itemCount - 1;
200
+ setActiveIndex(itemCount - 1);
164
201
  return;
165
202
  case "ArrowLeft":
166
203
  if (orientation === "vertical") return;
@@ -210,9 +247,11 @@ export {
210
247
  createRovingTabIndex,
211
248
  focusFirst,
212
249
  getFocusableElements,
250
+ getPrefersReducedMotionSnapshot,
213
251
  getReducedMotionPreference,
214
252
  prefersReducedMotion,
215
253
  shouldAnimate,
254
+ subscribeReducedMotion,
216
255
  trapFocus,
217
256
  visuallyHiddenStyle
218
257
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/accessibility",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "description": "Accessibility utilities for LessonKit packages and apps.",
6
6
  "license": "Apache-2.0",