@y14e/roving-tabindex 1.0.2 → 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
@@ -238,7 +238,7 @@ function createRovingTabIndex(container, options = {}) {
238
238
  if (!(container instanceof Element)) {
239
239
  throw new Error("Invalid container element");
240
240
  }
241
- const { direction, selector, wrap = false } = options;
241
+ const { direction, selector, typeahead = false, wrap = false } = options;
242
242
  if (direction && !["horizontal", "vertical"].includes(direction)) {
243
243
  console.warn("Invalid direction. Fallback: both (undefined).");
244
244
  Object.assign(options, { direction: void 0 });
@@ -247,6 +247,10 @@ function createRovingTabIndex(container, options = {}) {
247
247
  console.warn("Invalid selector. Fallback: all focusable elements.");
248
248
  Object.assign(options, { selector: void 0 });
249
249
  }
250
+ if (typeof typeahead !== "boolean") {
251
+ console.warn("Invalid typeahead. Fallback: false.");
252
+ Object.assign(options, { typeahead: false });
253
+ }
250
254
  if (typeof wrap !== "boolean") {
251
255
  console.warn("Invalid wrap. Fallback: false.");
252
256
  Object.assign(options, { wrap: false });
@@ -258,6 +262,7 @@ var RovingTabIndex = class {
258
262
  #container;
259
263
  #options;
260
264
  #focusables = /* @__PURE__ */ new Set();
265
+ #focusablesByFirstChar = /* @__PURE__ */ new Map();
261
266
  #selectorFilter;
262
267
  #controller = null;
263
268
  #isDestroyed = false;
@@ -276,6 +281,7 @@ var RovingTabIndex = class {
276
281
  this.#controller = null;
277
282
  restoreAttributes([...this.#focusables]);
278
283
  this.#focusables.clear();
284
+ this.#focusablesByFirstChar.clear();
279
285
  this.#container.removeAttribute("data-roving-tabindex-initialized");
280
286
  }
281
287
  #initialize() {
@@ -295,7 +301,7 @@ var RovingTabIndex = class {
295
301
  if (altKey || ctrlKey || metaKey) {
296
302
  return;
297
303
  }
298
- const { direction } = this.#options;
304
+ const { direction, typeahead, wrap } = this.#options;
299
305
  const isBoth = !direction;
300
306
  const isHorizontal = direction === "horizontal";
301
307
  if (![
@@ -304,7 +310,9 @@ var RovingTabIndex = class {
304
310
  ...isBoth ? ["ArrowLeft", "ArrowUp"] : [`Arrow${isHorizontal ? "Left" : "Up"}`],
305
311
  ...isBoth ? ["ArrowRight", "ArrowDown"] : [`Arrow${isHorizontal ? "Right" : "Down"}`]
306
312
  ].includes(key)) {
307
- return;
313
+ if (!typeahead || !/^\S$/i.test(key) || !this.#focusablesByFirstChar.has(key.toLowerCase())) {
314
+ return;
315
+ }
308
316
  }
309
317
  const active = getActiveElement();
310
318
  if (!(active instanceof HTMLElement)) {
@@ -317,9 +325,9 @@ var RovingTabIndex = class {
317
325
  event.preventDefault();
318
326
  event.stopPropagation();
319
327
  const currentIndex = focusables.indexOf(active);
320
- let rawIndex;
328
+ let rawIndex = currentIndex;
321
329
  let newIndex = currentIndex;
322
- const { wrap = false } = this.#options;
330
+ let target = focusables;
323
331
  switch (key) {
324
332
  case "End":
325
333
  newIndex = -1;
@@ -337,8 +345,18 @@ var RovingTabIndex = class {
337
345
  rawIndex = currentIndex + 1;
338
346
  newIndex = wrap ? rawIndex % focusables.length : Math.min(rawIndex, focusables.length - 1);
339
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
+ }
340
358
  }
341
- const focusable = focusables.at(newIndex);
359
+ const focusable = target.at(newIndex);
342
360
  if (!focusable) {
343
361
  return;
344
362
  }
@@ -361,6 +379,12 @@ var RovingTabIndex = class {
361
379
  restoreAttributes([focusable]);
362
380
  }
363
381
  this.#focusables.delete(focusable);
382
+ this.#focusablesByFirstChar.forEach((focusables) => {
383
+ const index = focusables.indexOf(focusable);
384
+ if (index !== -1) {
385
+ focusables.splice(index, 1);
386
+ }
387
+ });
364
388
  });
365
389
  current.forEach((c) => {
366
390
  if (this.#focusables.has(c)) {
@@ -369,6 +393,19 @@ var RovingTabIndex = class {
369
393
  this.#focusables.add(c);
370
394
  saveAttributes([c], ["tabindex"]);
371
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);
372
409
  });
373
410
  if (active && this.#focusables.has(active)) {
374
411
  this.#focusables.forEach((focusable) => {
@@ -407,7 +444,7 @@ function getActiveElement() {
407
444
  * Lightweight roving tabindex utility with fully focus management.
408
445
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
409
446
  *
410
- * @version 1.0.2
447
+ * @version 1.1.0
411
448
  * @author Yusuke Kamiyamane
412
449
  * @license MIT
413
450
  * @copyright Copyright (c) Yusuke Kamiyamane
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.2
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.2
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
@@ -236,7 +236,7 @@ function createRovingTabIndex(container, options = {}) {
236
236
  if (!(container instanceof Element)) {
237
237
  throw new Error("Invalid container element");
238
238
  }
239
- const { direction, selector, wrap = false } = options;
239
+ const { direction, selector, typeahead = false, wrap = false } = options;
240
240
  if (direction && !["horizontal", "vertical"].includes(direction)) {
241
241
  console.warn("Invalid direction. Fallback: both (undefined).");
242
242
  Object.assign(options, { direction: void 0 });
@@ -245,6 +245,10 @@ function createRovingTabIndex(container, options = {}) {
245
245
  console.warn("Invalid selector. Fallback: all focusable elements.");
246
246
  Object.assign(options, { selector: void 0 });
247
247
  }
248
+ if (typeof typeahead !== "boolean") {
249
+ console.warn("Invalid typeahead. Fallback: false.");
250
+ Object.assign(options, { typeahead: false });
251
+ }
248
252
  if (typeof wrap !== "boolean") {
249
253
  console.warn("Invalid wrap. Fallback: false.");
250
254
  Object.assign(options, { wrap: false });
@@ -256,6 +260,7 @@ var RovingTabIndex = class {
256
260
  #container;
257
261
  #options;
258
262
  #focusables = /* @__PURE__ */ new Set();
263
+ #focusablesByFirstChar = /* @__PURE__ */ new Map();
259
264
  #selectorFilter;
260
265
  #controller = null;
261
266
  #isDestroyed = false;
@@ -274,6 +279,7 @@ var RovingTabIndex = class {
274
279
  this.#controller = null;
275
280
  restoreAttributes([...this.#focusables]);
276
281
  this.#focusables.clear();
282
+ this.#focusablesByFirstChar.clear();
277
283
  this.#container.removeAttribute("data-roving-tabindex-initialized");
278
284
  }
279
285
  #initialize() {
@@ -293,7 +299,7 @@ var RovingTabIndex = class {
293
299
  if (altKey || ctrlKey || metaKey) {
294
300
  return;
295
301
  }
296
- const { direction } = this.#options;
302
+ const { direction, typeahead, wrap } = this.#options;
297
303
  const isBoth = !direction;
298
304
  const isHorizontal = direction === "horizontal";
299
305
  if (![
@@ -302,7 +308,9 @@ var RovingTabIndex = class {
302
308
  ...isBoth ? ["ArrowLeft", "ArrowUp"] : [`Arrow${isHorizontal ? "Left" : "Up"}`],
303
309
  ...isBoth ? ["ArrowRight", "ArrowDown"] : [`Arrow${isHorizontal ? "Right" : "Down"}`]
304
310
  ].includes(key)) {
305
- return;
311
+ if (!typeahead || !/^\S$/i.test(key) || !this.#focusablesByFirstChar.has(key.toLowerCase())) {
312
+ return;
313
+ }
306
314
  }
307
315
  const active = getActiveElement();
308
316
  if (!(active instanceof HTMLElement)) {
@@ -315,9 +323,9 @@ var RovingTabIndex = class {
315
323
  event.preventDefault();
316
324
  event.stopPropagation();
317
325
  const currentIndex = focusables.indexOf(active);
318
- let rawIndex;
326
+ let rawIndex = currentIndex;
319
327
  let newIndex = currentIndex;
320
- const { wrap = false } = this.#options;
328
+ let target = focusables;
321
329
  switch (key) {
322
330
  case "End":
323
331
  newIndex = -1;
@@ -335,8 +343,18 @@ var RovingTabIndex = class {
335
343
  rawIndex = currentIndex + 1;
336
344
  newIndex = wrap ? rawIndex % focusables.length : Math.min(rawIndex, focusables.length - 1);
337
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
+ }
338
356
  }
339
- const focusable = focusables.at(newIndex);
357
+ const focusable = target.at(newIndex);
340
358
  if (!focusable) {
341
359
  return;
342
360
  }
@@ -359,6 +377,12 @@ var RovingTabIndex = class {
359
377
  restoreAttributes([focusable]);
360
378
  }
361
379
  this.#focusables.delete(focusable);
380
+ this.#focusablesByFirstChar.forEach((focusables) => {
381
+ const index = focusables.indexOf(focusable);
382
+ if (index !== -1) {
383
+ focusables.splice(index, 1);
384
+ }
385
+ });
362
386
  });
363
387
  current.forEach((c) => {
364
388
  if (this.#focusables.has(c)) {
@@ -367,6 +391,19 @@ var RovingTabIndex = class {
367
391
  this.#focusables.add(c);
368
392
  saveAttributes([c], ["tabindex"]);
369
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);
370
407
  });
371
408
  if (active && this.#focusables.has(active)) {
372
409
  this.#focusables.forEach((focusable) => {
@@ -405,7 +442,7 @@ function getActiveElement() {
405
442
  * Lightweight roving tabindex utility with fully focus management.
406
443
  * Designed for accessible menus, tabs, toolbars, and composite widgets.
407
444
  *
408
- * @version 1.0.2
445
+ * @version 1.1.0
409
446
  * @author Yusuke Kamiyamane
410
447
  * @license MIT
411
448
  * @copyright Copyright (c) Yusuke Kamiyamane
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@y14e/roving-tabindex",
3
- "version": "1.0.2",
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",