@y14e/roving-tabindex 1.0.1 → 1.1.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 CHANGED
@@ -43,10 +43,15 @@ const cleanup = createRovingTabIndex(container, options);
43
43
  interface RovingTabIndexOptions {
44
44
  direction?: 'horizontal' | 'vertical'; // default: both (undefined)
45
45
  selector?: string;
46
+ typeahead?: boolean; // default: false
46
47
  wrap?: boolean; // default: false
47
48
  }
48
49
  ```
49
50
 
51
+ ### `typeahead`
52
+
53
+ If `true`, enables character-based focus navigation. Typing a character moves focus to the next matching element.
54
+
50
55
  ### `wrap`
51
56
 
52
57
  If `true`, wraps around to the first or last element when reaching the end.
package/dist/index.cjs CHANGED
@@ -1,5 +1,36 @@
1
1
  'use strict';
2
2
 
3
+ // node_modules/@y14e/attributes-utils/dist/index.js
4
+ var snapshots = /* @__PURE__ */ new WeakMap();
5
+ function restoreAttributes(elements) {
6
+ elements.forEach((element) => {
7
+ const snapshot = snapshots.get(element);
8
+ if (!snapshot) {
9
+ return;
10
+ }
11
+ for (const [attribute, value] of snapshot.entries()) {
12
+ if (value === null) {
13
+ element.removeAttribute(attribute);
14
+ } else {
15
+ element.setAttribute(attribute, value);
16
+ }
17
+ }
18
+ snapshots.delete(element);
19
+ });
20
+ }
21
+ function saveAttributes(elements, attributes) {
22
+ elements.forEach((element) => {
23
+ let snapshot = snapshots.get(element);
24
+ if (!snapshot) {
25
+ snapshot = /* @__PURE__ */ new Map();
26
+ snapshots.set(element, snapshot);
27
+ }
28
+ attributes.forEach((attribute) => {
29
+ snapshot.set(attribute, element.getAttribute(attribute));
30
+ });
31
+ });
32
+ }
33
+
3
34
  // node_modules/power-focusable/dist/index.js
4
35
  var FOCUSABLE_SELECTOR = `:is(a[href], area[href], button, embed, iframe, input:not([type="hidden" i]), object, select, details > summary:first-of-type, textarea, [contenteditable]:not([contenteditable="false" i]), [controls], [tabindex]):not(:disabled, [hidden], [inert], [tabindex="-1"])`;
5
36
  function getFocusables(container = document.body, options = {}) {
@@ -207,7 +238,7 @@ function createRovingTabIndex(container, options = {}) {
207
238
  if (!(container instanceof Element)) {
208
239
  throw new Error("Invalid container element");
209
240
  }
210
- const { direction, selector, wrap = false } = options;
241
+ const { direction, selector, typeahead = false, wrap = false } = options;
211
242
  if (direction && !["horizontal", "vertical"].includes(direction)) {
212
243
  console.warn("Invalid direction. Fallback: both (undefined).");
213
244
  Object.assign(options, { direction: void 0 });
@@ -216,6 +247,10 @@ function createRovingTabIndex(container, options = {}) {
216
247
  console.warn("Invalid selector. Fallback: all focusable elements.");
217
248
  Object.assign(options, { selector: void 0 });
218
249
  }
250
+ if (typeof typeahead !== "boolean") {
251
+ console.warn("Invalid typeahead. Fallback: false.");
252
+ Object.assign(options, { typeahead: false });
253
+ }
219
254
  if (typeof wrap !== "boolean") {
220
255
  console.warn("Invalid wrap. Fallback: false.");
221
256
  Object.assign(options, { wrap: false });
@@ -227,7 +262,7 @@ var RovingTabIndex = class {
227
262
  #container;
228
263
  #options;
229
264
  #focusables = /* @__PURE__ */ new Set();
230
- #tabIndexes = /* @__PURE__ */ new Map();
265
+ #focusablesByFirstChar = /* @__PURE__ */ new Map();
231
266
  #selectorFilter;
232
267
  #controller = null;
233
268
  #isDestroyed = false;
@@ -244,16 +279,9 @@ var RovingTabIndex = class {
244
279
  this.#isDestroyed = true;
245
280
  this.#controller?.abort();
246
281
  this.#controller = null;
247
- this.#focusables.forEach((focusable) => {
248
- const index = this.#tabIndexes.get(focusable);
249
- if (index == null) {
250
- focusable.removeAttribute("tabindex");
251
- } else {
252
- focusable.setAttribute("tabindex", index);
253
- }
254
- });
282
+ restoreAttributes([...this.#focusables]);
255
283
  this.#focusables.clear();
256
- this.#tabIndexes.clear();
284
+ this.#focusablesByFirstChar.clear();
257
285
  this.#container.removeAttribute("data-roving-tabindex-initialized");
258
286
  }
259
287
  #initialize() {
@@ -273,7 +301,7 @@ var RovingTabIndex = class {
273
301
  if (altKey || ctrlKey || metaKey) {
274
302
  return;
275
303
  }
276
- const { direction } = this.#options;
304
+ const { direction, typeahead, wrap } = this.#options;
277
305
  const isBoth = !direction;
278
306
  const isHorizontal = direction === "horizontal";
279
307
  if (![
@@ -282,7 +310,9 @@ var RovingTabIndex = class {
282
310
  ...isBoth ? ["ArrowLeft", "ArrowUp"] : [`Arrow${isHorizontal ? "Left" : "Up"}`],
283
311
  ...isBoth ? ["ArrowRight", "ArrowDown"] : [`Arrow${isHorizontal ? "Right" : "Down"}`]
284
312
  ].includes(key)) {
285
- return;
313
+ if (!typeahead || !/^\S$/i.test(key) || !this.#focusablesByFirstChar.has(key.toLowerCase())) {
314
+ return;
315
+ }
286
316
  }
287
317
  const active = getActiveElement();
288
318
  if (!(active instanceof HTMLElement)) {
@@ -295,9 +325,9 @@ var RovingTabIndex = class {
295
325
  event.preventDefault();
296
326
  event.stopPropagation();
297
327
  const currentIndex = focusables.indexOf(active);
298
- let rawIndex;
328
+ let rawIndex = currentIndex;
299
329
  let newIndex = currentIndex;
300
- const { wrap = false } = this.#options;
330
+ let target = focusables;
301
331
  switch (key) {
302
332
  case "End":
303
333
  newIndex = -1;
@@ -315,8 +345,18 @@ var RovingTabIndex = class {
315
345
  rawIndex = currentIndex + 1;
316
346
  newIndex = wrap ? rawIndex % focusables.length : Math.min(rawIndex, focusables.length - 1);
317
347
  break;
348
+ default: {
349
+ if (!typeahead) {
350
+ break;
351
+ }
352
+ target = this.#focusablesByFirstChar.get(key.toLowerCase()) ?? [];
353
+ const foundIndex = target.findIndex(
354
+ (focusable2) => focusables.indexOf(focusable2) > currentIndex
355
+ );
356
+ newIndex = foundIndex !== -1 ? foundIndex : 0;
357
+ }
318
358
  }
319
- const focusable = focusables.at(newIndex);
359
+ const focusable = target.at(newIndex);
320
360
  if (!focusable) {
321
361
  return;
322
362
  }
@@ -336,23 +376,36 @@ var RovingTabIndex = class {
336
376
  return;
337
377
  }
338
378
  if (focusable.isConnected) {
339
- const index = this.#tabIndexes.get(focusable);
340
- if (index == null) {
341
- focusable.removeAttribute("tabindex");
342
- } else {
343
- focusable.setAttribute("tabindex", index);
344
- }
379
+ restoreAttributes([focusable]);
345
380
  }
346
381
  this.#focusables.delete(focusable);
347
- this.#tabIndexes.delete(focusable);
382
+ this.#focusablesByFirstChar.forEach((focusables) => {
383
+ const index = focusables.indexOf(focusable);
384
+ if (index !== -1) {
385
+ focusables.splice(index, 1);
386
+ }
387
+ });
348
388
  });
349
389
  current.forEach((c) => {
350
390
  if (this.#focusables.has(c)) {
351
391
  return;
352
392
  }
353
393
  this.#focusables.add(c);
354
- this.#tabIndexes.set(c, c.getAttribute("tabindex"));
394
+ saveAttributes([c], ["tabindex"]);
355
395
  c.setAttribute("tabindex", "-1");
396
+ if (!this.#options.typeahead) {
397
+ return;
398
+ }
399
+ const shortcuts = c.ariaKeyShortcuts;
400
+ const keys = (shortcuts?.split(/\s+/) ?? [c.textContent?.trim()[0] ?? ""]).filter((key) => /^\S$/i.test(key)).map((key) => key.toLowerCase());
401
+ keys.forEach((key) => {
402
+ const focusables = this.#focusablesByFirstChar.get(key) ?? [];
403
+ focusables.push(c);
404
+ this.#focusablesByFirstChar.set(key, focusables);
405
+ });
406
+ const first = keys[0];
407
+ saveAttributes([c], ["aria-keyshortcuts"]);
408
+ !shortcuts && first && c.setAttribute("aria-keyshortcuts", first);
356
409
  });
357
410
  if (active && this.#focusables.has(active)) {
358
411
  this.#focusables.forEach((focusable) => {
@@ -391,7 +444,7 @@ function getActiveElement() {
391
444
  * Lightweight roving tabindex utility with fully focus management.
392
445
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
393
446
  *
394
- * @version 1.0.1
447
+ * @version 1.1.0
395
448
  * @author Yusuke Kamiyamane
396
449
  * @license MIT
397
450
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -399,6 +452,17 @@ function getActiveElement() {
399
452
  */
400
453
  /*! Bundled license information:
401
454
 
455
+ @y14e/attributes-utils/dist/index.js:
456
+ (**
457
+ * Attributes Utils
458
+ *
459
+ * @version 1.0.0
460
+ * @author Yusuke Kamiyamane
461
+ * @license MIT
462
+ * @copyright Copyright (c) Yusuke Kamiyamane
463
+ * @see {@link https://github.com/y14e/attributes-utils}
464
+ *)
465
+
402
466
  power-focusable/dist/index.js:
403
467
  (**
404
468
  * Power Focusable
package/dist/index.d.cts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Lightweight roving tabindex utility with fully focus management.
4
4
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
5
5
  *
6
- * @version 1.0.1
6
+ * @version 1.1.0
7
7
  * @author Yusuke Kamiyamane
8
8
  * @license MIT
9
9
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -12,6 +12,7 @@
12
12
  interface RovingTabIndexOptions {
13
13
  readonly direction?: 'horizontal' | 'vertical';
14
14
  readonly selector?: string;
15
+ readonly typeahead?: boolean;
15
16
  readonly wrap?: boolean;
16
17
  }
17
18
  declare function createRovingTabIndex(container: Element, options?: RovingTabIndexOptions): () => void;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Lightweight roving tabindex utility with fully focus management.
4
4
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
5
5
  *
6
- * @version 1.0.1
6
+ * @version 1.1.0
7
7
  * @author Yusuke Kamiyamane
8
8
  * @license MIT
9
9
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -12,6 +12,7 @@
12
12
  interface RovingTabIndexOptions {
13
13
  readonly direction?: 'horizontal' | 'vertical';
14
14
  readonly selector?: string;
15
+ readonly typeahead?: boolean;
15
16
  readonly wrap?: boolean;
16
17
  }
17
18
  declare function createRovingTabIndex(container: Element, options?: RovingTabIndexOptions): () => void;
package/dist/index.js CHANGED
@@ -1,3 +1,34 @@
1
+ // node_modules/@y14e/attributes-utils/dist/index.js
2
+ var snapshots = /* @__PURE__ */ new WeakMap();
3
+ function restoreAttributes(elements) {
4
+ elements.forEach((element) => {
5
+ const snapshot = snapshots.get(element);
6
+ if (!snapshot) {
7
+ return;
8
+ }
9
+ for (const [attribute, value] of snapshot.entries()) {
10
+ if (value === null) {
11
+ element.removeAttribute(attribute);
12
+ } else {
13
+ element.setAttribute(attribute, value);
14
+ }
15
+ }
16
+ snapshots.delete(element);
17
+ });
18
+ }
19
+ function saveAttributes(elements, attributes) {
20
+ elements.forEach((element) => {
21
+ let snapshot = snapshots.get(element);
22
+ if (!snapshot) {
23
+ snapshot = /* @__PURE__ */ new Map();
24
+ snapshots.set(element, snapshot);
25
+ }
26
+ attributes.forEach((attribute) => {
27
+ snapshot.set(attribute, element.getAttribute(attribute));
28
+ });
29
+ });
30
+ }
31
+
1
32
  // node_modules/power-focusable/dist/index.js
2
33
  var FOCUSABLE_SELECTOR = `:is(a[href], area[href], button, embed, iframe, input:not([type="hidden" i]), object, select, details > summary:first-of-type, textarea, [contenteditable]:not([contenteditable="false" i]), [controls], [tabindex]):not(:disabled, [hidden], [inert], [tabindex="-1"])`;
3
34
  function getFocusables(container = document.body, options = {}) {
@@ -205,7 +236,7 @@ function createRovingTabIndex(container, options = {}) {
205
236
  if (!(container instanceof Element)) {
206
237
  throw new Error("Invalid container element");
207
238
  }
208
- const { direction, selector, wrap = false } = options;
239
+ const { direction, selector, typeahead = false, wrap = false } = options;
209
240
  if (direction && !["horizontal", "vertical"].includes(direction)) {
210
241
  console.warn("Invalid direction. Fallback: both (undefined).");
211
242
  Object.assign(options, { direction: void 0 });
@@ -214,6 +245,10 @@ function createRovingTabIndex(container, options = {}) {
214
245
  console.warn("Invalid selector. Fallback: all focusable elements.");
215
246
  Object.assign(options, { selector: void 0 });
216
247
  }
248
+ if (typeof typeahead !== "boolean") {
249
+ console.warn("Invalid typeahead. Fallback: false.");
250
+ Object.assign(options, { typeahead: false });
251
+ }
217
252
  if (typeof wrap !== "boolean") {
218
253
  console.warn("Invalid wrap. Fallback: false.");
219
254
  Object.assign(options, { wrap: false });
@@ -225,7 +260,7 @@ var RovingTabIndex = class {
225
260
  #container;
226
261
  #options;
227
262
  #focusables = /* @__PURE__ */ new Set();
228
- #tabIndexes = /* @__PURE__ */ new Map();
263
+ #focusablesByFirstChar = /* @__PURE__ */ new Map();
229
264
  #selectorFilter;
230
265
  #controller = null;
231
266
  #isDestroyed = false;
@@ -242,16 +277,9 @@ var RovingTabIndex = class {
242
277
  this.#isDestroyed = true;
243
278
  this.#controller?.abort();
244
279
  this.#controller = null;
245
- this.#focusables.forEach((focusable) => {
246
- const index = this.#tabIndexes.get(focusable);
247
- if (index == null) {
248
- focusable.removeAttribute("tabindex");
249
- } else {
250
- focusable.setAttribute("tabindex", index);
251
- }
252
- });
280
+ restoreAttributes([...this.#focusables]);
253
281
  this.#focusables.clear();
254
- this.#tabIndexes.clear();
282
+ this.#focusablesByFirstChar.clear();
255
283
  this.#container.removeAttribute("data-roving-tabindex-initialized");
256
284
  }
257
285
  #initialize() {
@@ -271,7 +299,7 @@ var RovingTabIndex = class {
271
299
  if (altKey || ctrlKey || metaKey) {
272
300
  return;
273
301
  }
274
- const { direction } = this.#options;
302
+ const { direction, typeahead, wrap } = this.#options;
275
303
  const isBoth = !direction;
276
304
  const isHorizontal = direction === "horizontal";
277
305
  if (![
@@ -280,7 +308,9 @@ var RovingTabIndex = class {
280
308
  ...isBoth ? ["ArrowLeft", "ArrowUp"] : [`Arrow${isHorizontal ? "Left" : "Up"}`],
281
309
  ...isBoth ? ["ArrowRight", "ArrowDown"] : [`Arrow${isHorizontal ? "Right" : "Down"}`]
282
310
  ].includes(key)) {
283
- return;
311
+ if (!typeahead || !/^\S$/i.test(key) || !this.#focusablesByFirstChar.has(key.toLowerCase())) {
312
+ return;
313
+ }
284
314
  }
285
315
  const active = getActiveElement();
286
316
  if (!(active instanceof HTMLElement)) {
@@ -293,9 +323,9 @@ var RovingTabIndex = class {
293
323
  event.preventDefault();
294
324
  event.stopPropagation();
295
325
  const currentIndex = focusables.indexOf(active);
296
- let rawIndex;
326
+ let rawIndex = currentIndex;
297
327
  let newIndex = currentIndex;
298
- const { wrap = false } = this.#options;
328
+ let target = focusables;
299
329
  switch (key) {
300
330
  case "End":
301
331
  newIndex = -1;
@@ -313,8 +343,18 @@ var RovingTabIndex = class {
313
343
  rawIndex = currentIndex + 1;
314
344
  newIndex = wrap ? rawIndex % focusables.length : Math.min(rawIndex, focusables.length - 1);
315
345
  break;
346
+ default: {
347
+ if (!typeahead) {
348
+ break;
349
+ }
350
+ target = this.#focusablesByFirstChar.get(key.toLowerCase()) ?? [];
351
+ const foundIndex = target.findIndex(
352
+ (focusable2) => focusables.indexOf(focusable2) > currentIndex
353
+ );
354
+ newIndex = foundIndex !== -1 ? foundIndex : 0;
355
+ }
316
356
  }
317
- const focusable = focusables.at(newIndex);
357
+ const focusable = target.at(newIndex);
318
358
  if (!focusable) {
319
359
  return;
320
360
  }
@@ -334,23 +374,36 @@ var RovingTabIndex = class {
334
374
  return;
335
375
  }
336
376
  if (focusable.isConnected) {
337
- const index = this.#tabIndexes.get(focusable);
338
- if (index == null) {
339
- focusable.removeAttribute("tabindex");
340
- } else {
341
- focusable.setAttribute("tabindex", index);
342
- }
377
+ restoreAttributes([focusable]);
343
378
  }
344
379
  this.#focusables.delete(focusable);
345
- this.#tabIndexes.delete(focusable);
380
+ this.#focusablesByFirstChar.forEach((focusables) => {
381
+ const index = focusables.indexOf(focusable);
382
+ if (index !== -1) {
383
+ focusables.splice(index, 1);
384
+ }
385
+ });
346
386
  });
347
387
  current.forEach((c) => {
348
388
  if (this.#focusables.has(c)) {
349
389
  return;
350
390
  }
351
391
  this.#focusables.add(c);
352
- this.#tabIndexes.set(c, c.getAttribute("tabindex"));
392
+ saveAttributes([c], ["tabindex"]);
353
393
  c.setAttribute("tabindex", "-1");
394
+ if (!this.#options.typeahead) {
395
+ return;
396
+ }
397
+ const shortcuts = c.ariaKeyShortcuts;
398
+ const keys = (shortcuts?.split(/\s+/) ?? [c.textContent?.trim()[0] ?? ""]).filter((key) => /^\S$/i.test(key)).map((key) => key.toLowerCase());
399
+ keys.forEach((key) => {
400
+ const focusables = this.#focusablesByFirstChar.get(key) ?? [];
401
+ focusables.push(c);
402
+ this.#focusablesByFirstChar.set(key, focusables);
403
+ });
404
+ const first = keys[0];
405
+ saveAttributes([c], ["aria-keyshortcuts"]);
406
+ !shortcuts && first && c.setAttribute("aria-keyshortcuts", first);
354
407
  });
355
408
  if (active && this.#focusables.has(active)) {
356
409
  this.#focusables.forEach((focusable) => {
@@ -389,7 +442,7 @@ function getActiveElement() {
389
442
  * Lightweight roving tabindex utility with fully focus management.
390
443
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
391
444
  *
392
- * @version 1.0.1
445
+ * @version 1.1.0
393
446
  * @author Yusuke Kamiyamane
394
447
  * @license MIT
395
448
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -397,6 +450,17 @@ function getActiveElement() {
397
450
  */
398
451
  /*! Bundled license information:
399
452
 
453
+ @y14e/attributes-utils/dist/index.js:
454
+ (**
455
+ * Attributes Utils
456
+ *
457
+ * @version 1.0.0
458
+ * @author Yusuke Kamiyamane
459
+ * @license MIT
460
+ * @copyright Copyright (c) Yusuke Kamiyamane
461
+ * @see {@link https://github.com/y14e/attributes-utils}
462
+ *)
463
+
400
464
  power-focusable/dist/index.js:
401
465
  (**
402
466
  * Power Focusable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@y14e/roving-tabindex",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight roving tabindex utility with fully focus management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "homepage": "https://github.com/y14e/roving-tabindex#readme",
50
50
  "devDependencies": {
51
+ "@y14e/attributes-utils": "^1.0.0",
51
52
  "bun-types": "latest",
52
53
  "power-focusable": "^4.1.5",
53
54
  "tsup": "^8.0.0",