@fusion.dev/helix-core 0.142.1 → 0.144.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.
Files changed (112) hide show
  1. package/custom-elements.json +10436 -3804
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +65 -0
  3. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +77 -16
  4. package/dist/controllers/arrival-controller/arrival-controller.d.ts +12 -0
  5. package/dist/controllers/arrival-controller/arrival-controller.js +10 -0
  6. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.d.ts +99 -0
  7. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +107 -2
  8. package/dist/controllers/chart-pointer-controller/reading-step.d.ts +57 -0
  9. package/dist/controllers/chart-pointer-controller/reading-step.js +45 -0
  10. package/dist/controllers/copy-controller/copy-controller.d.ts +11 -6
  11. package/dist/controllers/copy-controller/copy-controller.js +16 -9
  12. package/dist/controllers/delay-controller/delay-controller.d.ts +50 -0
  13. package/dist/controllers/delay-controller/delay-controller.js +54 -0
  14. package/dist/controllers/dismiss-controller/dismiss-controller.js +40 -3
  15. package/dist/controllers/focus-scope-controller/focus-scope-controller.d.ts +56 -0
  16. package/dist/controllers/focus-scope-controller/focus-scope-controller.js +71 -0
  17. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +79 -1
  18. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +80 -10
  19. package/dist/controllers/open-phase-controller/open-phase-controller.d.ts +49 -0
  20. package/dist/controllers/open-phase-controller/open-phase-controller.js +41 -0
  21. package/dist/controllers/popover-controller/popover-controller.d.ts +11 -0
  22. package/dist/controllers/popover-controller/popover-controller.js +6 -0
  23. package/dist/controllers/source-controller/source-controller.d.ts +38 -0
  24. package/dist/controllers/source-controller/source-controller.js +71 -2
  25. package/dist/controllers/status-controller/status-controller.d.ts +47 -0
  26. package/dist/controllers/status-controller/status-controller.js +34 -0
  27. package/dist/controllers/swipe-controller/swipe-controller.d.ts +61 -0
  28. package/dist/controllers/swipe-controller/swipe-controller.js +106 -0
  29. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +16 -0
  30. package/dist/controllers/virtualize-controller/virtualize-controller.js +24 -2
  31. package/dist/events/events.d.ts +137 -4
  32. package/dist/events/events.js +96 -0
  33. package/dist/helix-element/react-bridge.js +6 -4
  34. package/dist/helix-form-element/helix-form-element.d.ts +131 -2
  35. package/dist/helix-form-element/helix-form-element.js +216 -6
  36. package/dist/index.d.ts +30 -9
  37. package/dist/index.js +29 -8
  38. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +15 -2
  39. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +9 -1
  40. package/dist/mixins/mixin-collection/mixin-collection.d.ts +4 -2
  41. package/dist/mixins/mixin-collection/mixin-collection.js +8 -6
  42. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.d.ts +10 -0
  43. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +9 -0
  44. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +19 -3
  45. package/dist/mixins/mixin-dismissible/mixin-dismissible.d.ts +2 -0
  46. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +43 -0
  47. package/dist/mixins/mixin-field/mixin-field.d.ts +2 -0
  48. package/dist/mixins/mixin-field/mixin-field.js +46 -8
  49. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +23 -8
  50. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +25 -1
  51. package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +29 -5
  52. package/dist/mixins/mixin-picker-field/mixin-picker-field.js +94 -1
  53. package/dist/mixins/mixin-segmented-field/mixin-segmented-field.d.ts +67 -0
  54. package/dist/mixins/mixin-segmented-field/mixin-segmented-field.js +243 -0
  55. package/dist/utils/arrow/arrow.d.ts +15 -0
  56. package/dist/utils/arrow/arrow.js +2 -0
  57. package/dist/utils/audit-name/audit-name.d.ts +28 -0
  58. package/dist/utils/audit-name/audit-name.js +95 -0
  59. package/dist/utils/calendar/calendar.d.ts +117 -0
  60. package/dist/utils/calendar/calendar.js +96 -0
  61. package/dist/utils/chord/chord.d.ts +43 -0
  62. package/dist/utils/chord/chord.js +53 -0
  63. package/dist/utils/clock/clock.d.ts +87 -0
  64. package/dist/utils/clock/clock.js +68 -0
  65. package/dist/utils/color-name/color-name.d.ts +28 -0
  66. package/dist/utils/color-name/color-name.js +150 -0
  67. package/dist/utils/count-label/count-label.d.ts +13 -0
  68. package/dist/utils/count-label/count-label.js +5 -0
  69. package/dist/utils/css-color/css-color.d.ts +12 -0
  70. package/dist/utils/css-color/css-color.js +2 -2
  71. package/dist/utils/date-segments/date-segments.d.ts +122 -0
  72. package/dist/utils/date-segments/date-segments.js +206 -0
  73. package/dist/utils/draw-dismiss/draw-dismiss.d.ts +30 -0
  74. package/dist/utils/draw-dismiss/draw-dismiss.js +16 -0
  75. package/dist/utils/draw-tooltip/draw-tooltip.d.ts +15 -18
  76. package/dist/utils/draw-tooltip/draw-tooltip.js +16 -5
  77. package/dist/utils/draw-tooltip/spoken-reading.d.ts +10 -0
  78. package/dist/utils/draw-tooltip/spoken-reading.js +10 -0
  79. package/dist/utils/follow-link/follow-link.d.ts +46 -0
  80. package/dist/utils/follow-link/follow-link.js +37 -0
  81. package/dist/utils/heading-in/heading-in.d.ts +19 -0
  82. package/dist/utils/heading-in/heading-in.js +14 -0
  83. package/dist/utils/number-format/number-format.d.ts +23 -0
  84. package/dist/utils/number-format/number-format.js +55 -0
  85. package/dist/utils/platform/platform.d.ts +23 -0
  86. package/dist/utils/platform/platform.js +11 -0
  87. package/dist/utils/read-color/read-color.d.ts +14 -0
  88. package/dist/utils/read-color/read-color.js +13 -0
  89. package/dist/utils/read-series/read-series.d.ts +34 -0
  90. package/dist/utils/read-series/read-series.js +32 -4
  91. package/dist/utils/reflect-elements/reflect-elements.d.ts +37 -0
  92. package/dist/utils/reflect-elements/reflect-elements.js +40 -0
  93. package/dist/utils/relative-time/relative-time.d.ts +13 -1
  94. package/dist/utils/relative-time/relative-time.js +7 -4
  95. package/dist/utils/report-runtime/report-runtime.d.ts +12 -0
  96. package/dist/utils/roving-stop/roving-stop.d.ts +20 -0
  97. package/dist/utils/roving-stop/roving-stop.js +3 -0
  98. package/dist/utils/scroll-lock/scroll-lock.d.ts +16 -0
  99. package/dist/utils/scroll-lock/scroll-lock.js +30 -0
  100. package/dist/utils/swipe/swipe.d.ts +63 -0
  101. package/dist/utils/swipe/swipe.js +48 -0
  102. package/dist/utils/tab-stops/tab-stops.d.ts +16 -0
  103. package/dist/utils/tab-stops/tab-stops.js +22 -0
  104. package/dist/utils/value-as/value-as.d.ts +20 -0
  105. package/dist/utils/value-as/value-as.js +16 -0
  106. package/dist/utils/write-time/write-time.d.ts +11 -1
  107. package/dist/utils/write-time/write-time.js +7 -7
  108. package/dist/utils/written-list/written-list.d.ts +29 -2
  109. package/dist/utils/written-list/written-list.js +11 -1
  110. package/dist/version.d.ts +1 -1
  111. package/dist/version.js +1 -1
  112. package/package.json +4 -4
@@ -0,0 +1,45 @@
1
+ const vertical = (key) => (key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : 0);
2
+ const inline = (key, rtl) => key === 'ArrowRight' ? (rtl ? -1 : 1) : key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
3
+ export const alongReadings = (key, layout) => {
4
+ const single = layout.grid > 1 || layout.series < 2;
5
+ if (layout.axis === 'block') {
6
+ const down = vertical(key);
7
+ return down !== 0 || !single ? down : inline(key, layout.rtl);
8
+ }
9
+ const across = inline(key, layout.rtl);
10
+ if (across !== 0) {
11
+ return across * Math.max(1, layout.grid);
12
+ }
13
+ return single ? vertical(key) : 0;
14
+ };
15
+ export const amongSeries = (key, layout) => {
16
+ if (layout.grid > 1 || layout.series < 2) {
17
+ return 0;
18
+ }
19
+ return layout.axis === 'block' ? inline(key, layout.rtl) : vertical(key);
20
+ };
21
+ export const stepReading = (key, from, layout) => {
22
+ const last = layout.count - 1;
23
+ if (last < 0) {
24
+ return undefined;
25
+ }
26
+ if (key === 'Home') {
27
+ return { at: 0, series: from.series };
28
+ }
29
+ if (key === 'End') {
30
+ return { at: last, series: from.series };
31
+ }
32
+ const along = alongReadings(key, layout);
33
+ if (along !== 0) {
34
+ const at = from.at < 0 ? (along > 0 ? 0 : last) : Math.min(Math.max(from.at + along, 0), last);
35
+ return { at, series: from.series };
36
+ }
37
+ const among = amongSeries(key, layout);
38
+ if (among !== 0) {
39
+ return {
40
+ at: Math.max(from.at, 0),
41
+ series: Math.min(Math.max(from.series + among, -1), layout.series - 1),
42
+ };
43
+ }
44
+ return undefined;
45
+ };
@@ -24,10 +24,10 @@ export interface CopyOptions {
24
24
  * in more places than it looks and a control that does nothing is
25
25
  * indistinguishable from a broken one. See `write` above.
26
26
  *
27
- * **Where neither route is allowed, the failure is silence.** The confirmation
28
- * does not appear, which is the honest report: a message about a clipboard is
29
- * worse than a chip that did not flash, and there is nothing a reader could do
30
- * about a permission policy anyway.
27
+ * **Where neither route is allowed, the copy has failed**, and `failed` holds
28
+ * for the moment a confirmation would. A component decides whether to say so:
29
+ * `CopyButton` names itself with its `error-label`, where Color's chip does
30
+ * not flash at all.
31
31
  *
32
32
  * **The event is announced after the write, not before it.** It is not
33
33
  * cancelable for the same reason: by the time anything could be said, the text
@@ -42,15 +42,20 @@ export declare class CopyController implements ReactiveController {
42
42
  private readonly options;
43
43
  /** Whether the last write succeeded and is still being confirmed. */
44
44
  copied: boolean;
45
+ /** Whether the last copy put nothing on the clipboard, and is still being said. */
46
+ failed: boolean;
45
47
  private timer;
46
48
  constructor(host: ReactiveControllerHost & EventTarget, options?: CopyOptions);
47
49
  /**
48
50
  * Writes to the clipboard, and confirms it if the platform allowed it.
51
+ * Resolves to whether the text is on the clipboard.
49
52
  *
50
53
  * Empty text is not copied. An empty clipboard is not a thing a reader ever
51
- * asks for, and confirming one is worse than doing nothing.
54
+ * asks for, and confirming one is worse than doing nothing. Empty text and a
55
+ * refused write each hold `failed` for the same moment a copy holds
56
+ * `copied`, for a component that says so.
52
57
  */
53
- copy(text: string): Promise<void>;
58
+ copy(text: string): Promise<boolean>;
54
59
  hostDisconnected(): void;
55
60
  private reset;
56
61
  }
@@ -1,4 +1,5 @@
1
1
  import { HelixCopyEvent } from '../../events/events.js';
2
+ import { deepActiveElement } from '../../utils/focusable-elements/focusable-elements.js';
2
3
  const write = async (text) => {
3
4
  try {
4
5
  await navigator.clipboard.writeText(text);
@@ -6,6 +7,7 @@ const write = async (text) => {
6
7
  }
7
8
  catch {
8
9
  }
10
+ const had = deepActiveElement();
9
11
  const carrier = document.createElement('textarea');
10
12
  carrier.value = text;
11
13
  carrier.setAttribute('readonly', '');
@@ -22,6 +24,9 @@ const write = async (text) => {
22
24
  wrote = false;
23
25
  }
24
26
  carrier.remove();
27
+ if (had instanceof HTMLElement || had instanceof SVGElement) {
28
+ had.focus({ preventScroll: true });
29
+ }
25
30
  return wrote;
26
31
  };
27
32
  export class CopyController {
@@ -29,30 +34,32 @@ export class CopyController {
29
34
  this.host = host;
30
35
  this.options = options;
31
36
  this.copied = false;
37
+ this.failed = false;
32
38
  host.addController(this);
33
39
  }
34
40
  async copy(text) {
35
- if (text === '') {
36
- return;
37
- }
38
- if (!(await write(text))) {
39
- return;
40
- }
41
+ const wrote = text !== '' && (await write(text));
41
42
  if (this.host instanceof HTMLElement && !this.host.isConnected) {
42
- return;
43
+ return wrote;
43
44
  }
44
- this.copied = true;
45
+ this.copied = wrote;
46
+ this.failed = !wrote;
45
47
  this.host.requestUpdate();
46
- this.host.dispatchEvent(new HelixCopyEvent({ value: text }));
48
+ if (wrote) {
49
+ this.host.dispatchEvent(new HelixCopyEvent({ value: text }));
50
+ }
47
51
  this.reset();
48
52
  this.timer = setTimeout(() => {
49
53
  this.copied = false;
54
+ this.failed = false;
50
55
  this.host.requestUpdate();
51
56
  }, this.options.after ?? 1200);
57
+ return wrote;
52
58
  }
53
59
  hostDisconnected() {
54
60
  this.reset();
55
61
  this.copied = false;
62
+ this.failed = false;
56
63
  }
57
64
  reset() {
58
65
  if (this.timer !== undefined) {
@@ -0,0 +1,50 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /** What a `DelayController` is for. */
3
+ export interface DelayOptions {
4
+ /**
5
+ * Whether the host renders `elapsed`, and so updates when it moves.
6
+ * Defaults to `true`; a wait that only runs `done`, a debounce, leaves its
7
+ * host alone.
8
+ */
9
+ renders?: boolean;
10
+ }
11
+ /**
12
+ * A wait an element starts and restarts: whether it has run out, and what runs
13
+ * when it does.
14
+ *
15
+ * Avatar's `fallback-delay`, Image's `placeholder-delay` and Loader's `delay`
16
+ * each hold a region back until a wait is over, and Listbox's `filter-delay`
17
+ * holds a filter back until typing pauses. Each kept a timer, a flag and the
18
+ * clean-up by hand; this keeps the three together, and clears the timer when
19
+ * the element leaves the page.
20
+ *
21
+ * `elapsed` is what a template reads, and the host updates when it moves,
22
+ * unless `renders` is false.
23
+ * Leaving the page stops the wait without forgetting that it ran out, so a
24
+ * loader moved to another place in the page does not wait again.
25
+ */
26
+ export declare class DelayController implements ReactiveController {
27
+ private readonly host;
28
+ private readonly options;
29
+ /** Whether the last wait started has run out. */
30
+ elapsed: boolean;
31
+ private timer;
32
+ private done;
33
+ constructor(host: ReactiveControllerHost, options?: DelayOptions);
34
+ /** Whether a wait is running. */
35
+ get pending(): boolean;
36
+ hostConnected(): void;
37
+ hostDisconnected(): void;
38
+ /**
39
+ * Starts the wait over: `elapsed` goes back to false, and after `ms`
40
+ * milliseconds it turns true and `done` runs. No `ms` starts no wait, which
41
+ * leaves `elapsed` false.
42
+ */
43
+ start(ms?: number, done?: () => void): void;
44
+ /** Stops a running wait, leaving `elapsed` as it is. */
45
+ cancel(): void;
46
+ /** Ends a running wait now, as though it had run out. Nothing happens where none is running. */
47
+ flush(): void;
48
+ private finish;
49
+ private mark;
50
+ }
@@ -0,0 +1,54 @@
1
+ export class DelayController {
2
+ constructor(host, options = {}) {
3
+ this.host = host;
4
+ this.options = options;
5
+ this.elapsed = false;
6
+ this.timer = undefined;
7
+ this.done = undefined;
8
+ host.addController(this);
9
+ }
10
+ get pending() {
11
+ return this.timer !== undefined;
12
+ }
13
+ hostConnected() { }
14
+ hostDisconnected() {
15
+ this.cancel();
16
+ }
17
+ start(ms, done) {
18
+ this.cancel();
19
+ this.mark(false);
20
+ if (ms === undefined) {
21
+ return;
22
+ }
23
+ this.done = done;
24
+ this.timer = setTimeout(() => this.finish(), ms);
25
+ }
26
+ cancel() {
27
+ clearTimeout(this.timer);
28
+ this.timer = undefined;
29
+ this.done = undefined;
30
+ }
31
+ flush() {
32
+ if (this.timer === undefined) {
33
+ return;
34
+ }
35
+ clearTimeout(this.timer);
36
+ this.finish();
37
+ }
38
+ finish() {
39
+ const done = this.done;
40
+ this.timer = undefined;
41
+ this.done = undefined;
42
+ this.mark(true);
43
+ done?.();
44
+ }
45
+ mark(elapsed) {
46
+ if (elapsed === this.elapsed) {
47
+ return;
48
+ }
49
+ this.elapsed = elapsed;
50
+ if (this.options.renders !== false) {
51
+ this.host.requestUpdate();
52
+ }
53
+ }
54
+ }
@@ -8,6 +8,35 @@ const deepDepth = (node) => {
8
8
  }
9
9
  return depth;
10
10
  };
11
+ const modalAround = (event) => {
12
+ for (const node of event.composedPath()) {
13
+ if (node instanceof HTMLDialogElement && node.open) {
14
+ try {
15
+ if (node.matches(':modal')) {
16
+ return node;
17
+ }
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ }
24
+ return undefined;
25
+ };
26
+ const flatContains = (container, node) => {
27
+ let at = node;
28
+ while (at) {
29
+ if (at === container) {
30
+ return true;
31
+ }
32
+ at =
33
+ at.assignedSlot ??
34
+ at.parentNode ??
35
+ at.host ??
36
+ null;
37
+ }
38
+ return false;
39
+ };
11
40
  const REGISTRY = Symbol.for('fusion.helix.dismiss.listening');
12
41
  const globals = globalThis;
13
42
  if (globals[REGISTRY] === undefined) {
@@ -26,15 +55,23 @@ export class DismissController {
26
55
  return;
27
56
  }
28
57
  taken.helixDismissTaken = true;
29
- let deepest = this;
30
- let found = deepDepth(this.host);
31
- for (const other of listening) {
58
+ const modal = modalAround(event);
59
+ let deepest;
60
+ let found = -1;
61
+ for (const other of [this, ...listening]) {
62
+ if (modal && !flatContains(modal, other.host)) {
63
+ continue;
64
+ }
32
65
  const depth = deepDepth(other.host);
33
66
  if (depth > found) {
34
67
  found = depth;
35
68
  deepest = other;
36
69
  }
37
70
  }
71
+ if (!deepest) {
72
+ return;
73
+ }
74
+ event.preventDefault();
38
75
  deepest.onDismiss();
39
76
  };
40
77
  this.handleWindowBlur = () => {
@@ -0,0 +1,56 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ export interface FocusScopeOptions {
3
+ /**
4
+ * The elements whose controls are inside the scope, in order. For a surface
5
+ * that holds a slot, these are the elements assigned to it: a query on the
6
+ * surface stops at the slot and never reaches them.
7
+ */
8
+ contents: () => readonly Element[];
9
+ /** Whether `Tab` is kept inside the scope, wrapping at either end. */
10
+ contain?: () => boolean;
11
+ /**
12
+ * Where focus goes on the way in: the first control inside, or the surface
13
+ * itself. The first control unless said, falling back to the surface where
14
+ * there is none.
15
+ */
16
+ start?: 'first' | 'surface';
17
+ /** The box focus is kept in, which takes focus itself where nothing inside can. */
18
+ surface: () => HTMLElement | null | undefined;
19
+ }
20
+ /** The controls among some elements and inside them, in order. */
21
+ export declare const controlsIn: (elements: readonly Element[]) => HTMLElement[];
22
+ /**
23
+ * Moves focus into a surface and back out of it, and can keep `Tab` inside
24
+ * while it is open.
25
+ *
26
+ * The part of an overlay that is about focus and nothing else: a popover that
27
+ * moves focus in as it opens, a modal popover that keeps it there, a toast
28
+ * region a hotkey takes focus to. Each of them decides *when*; this does the
29
+ * moving, and remembers where focus came from so `leave` can give it back.
30
+ *
31
+ * **Back only from inside.** A surface closed by a press somewhere else has not
32
+ * taken focus with it, and pulling focus back would move a reader who had
33
+ * already gone where they meant to.
34
+ */
35
+ export declare class FocusScopeController implements ReactiveController {
36
+ private readonly host;
37
+ private readonly options;
38
+ /** Where focus was when it was moved in. */
39
+ private origin;
40
+ constructor(host: ReactiveControllerHost & HTMLElement, options: FocusScopeOptions);
41
+ hostConnected(): void;
42
+ hostDisconnected(): void;
43
+ /** Whether focus is in the surface or anything assigned into it. */
44
+ get holdsFocus(): boolean;
45
+ /** The controls inside the scope, in order. */
46
+ get controls(): HTMLElement[];
47
+ /** Moves focus into the scope, and remembers where it was. */
48
+ enter(): void;
49
+ /**
50
+ * Gives focus back to where it was before `enter`, if focus is still inside
51
+ * the scope. Returns whether it did.
52
+ */
53
+ leave(): boolean;
54
+ /** `Tab` at either end wraps to the other, while the scope is containing. */
55
+ private readonly handleKeydown;
56
+ }
@@ -0,0 +1,71 @@
1
+ import { deepActiveElement, deepContains, focusableElements, } from '../../utils/focusable-elements/focusable-elements.js';
2
+ const takesFocus = (element) => element instanceof HTMLElement &&
3
+ (element.matches('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])') ||
4
+ element.shadowRoot?.delegatesFocus === true);
5
+ export const controlsIn = (elements) => elements.flatMap((one) => [
6
+ ...(takesFocus(one) ? [one] : []),
7
+ ...(one instanceof HTMLElement ? focusableElements(one) : []),
8
+ ]);
9
+ export class FocusScopeController {
10
+ constructor(host, options) {
11
+ this.handleKeydown = (event) => {
12
+ if (event.key !== 'Tab' || event.defaultPrevented || !this.options.contain?.()) {
13
+ return;
14
+ }
15
+ const controls = this.controls;
16
+ const active = deepActiveElement();
17
+ const first = controls[0];
18
+ const last = controls[controls.length - 1];
19
+ if (!first || !last) {
20
+ event.preventDefault();
21
+ this.options.surface()?.focus({ preventScroll: true });
22
+ return;
23
+ }
24
+ const at = (control) => active !== null && deepContains(control, active);
25
+ if (event.shiftKey && (at(first) || active === this.options.surface())) {
26
+ event.preventDefault();
27
+ last.focus();
28
+ }
29
+ else if (!event.shiftKey && at(last)) {
30
+ event.preventDefault();
31
+ first.focus();
32
+ }
33
+ };
34
+ this.host = host;
35
+ this.options = options;
36
+ host.addController(this);
37
+ }
38
+ hostConnected() {
39
+ this.host.addEventListener('keydown', this.handleKeydown);
40
+ }
41
+ hostDisconnected() {
42
+ this.host.removeEventListener('keydown', this.handleKeydown);
43
+ }
44
+ get holdsFocus() {
45
+ const active = deepActiveElement();
46
+ const surface = this.options.surface();
47
+ return (active !== null &&
48
+ ((surface !== null && surface !== undefined && deepContains(surface, active)) ||
49
+ this.options.contents().some((one) => deepContains(one, active))));
50
+ }
51
+ get controls() {
52
+ return controlsIn(this.options.contents());
53
+ }
54
+ enter() {
55
+ const active = deepActiveElement();
56
+ if (active instanceof HTMLElement && !this.holdsFocus) {
57
+ this.origin = active;
58
+ }
59
+ const first = this.options.start === 'surface' ? undefined : this.controls[0];
60
+ (first ?? this.options.surface())?.focus({ preventScroll: true });
61
+ }
62
+ leave() {
63
+ const origin = this.origin;
64
+ this.origin = undefined;
65
+ if (!origin?.isConnected || !this.holdsFocus) {
66
+ return false;
67
+ }
68
+ origin.focus({ preventScroll: true });
69
+ return true;
70
+ }
71
+ }
@@ -62,6 +62,15 @@ export interface KeyboardNavigationOptions {
62
62
  * could not be opened from the keyboard at all.
63
63
  */
64
64
  reserved?: () => string[];
65
+ /**
66
+ * The words of an item, which turns on typeahead: a printable key moves to
67
+ * the next item whose words start with what was typed.
68
+ *
69
+ * Off unless given. A set whose items have no words a reader would type, or
70
+ * that holds a text field of its own, leaves it out. See `Typeahead` for how
71
+ * the letters are matched.
72
+ */
73
+ typeahead?: (item: NavigableItem) => string;
65
74
  /**
66
75
  * Whether the ends join up, which they do unless a set says otherwise.
67
76
  *
@@ -82,6 +91,54 @@ export interface KeyboardNavigationOptions {
82
91
  */
83
92
  wrap?: boolean;
84
93
  }
94
+ /**
95
+ * Whether a press types a character, which is what typeahead reads.
96
+ *
97
+ * One character with no Control, Command or Alt held, so a shortcut such as
98
+ * Control+A is never taken for the letter A. Shift is allowed, because a
99
+ * capital is still a letter.
100
+ */
101
+ export declare const typesCharacter: (event: KeyboardEvent) => boolean;
102
+ /**
103
+ * Finds an item by the letters a reader types at a set, the way a native
104
+ * `<select>` does.
105
+ *
106
+ * Letters typed within `timeout` milliseconds of each other build one search,
107
+ * so typing "ca" moves to "Canada" past "Cambodia". The same letter typed again
108
+ * moves to the next item that starts with it, so pressing "c" three times visits
109
+ * the first three. A pause starts a new search.
110
+ *
111
+ * Matching ignores case and leading spaces, and searches from the reader's
112
+ * position, wrapping at the end. A search that matches nothing moves nothing.
113
+ *
114
+ * Holds only the letters, so one instance serves any set: the caller passes the
115
+ * words of the items and where the reader is on every key.
116
+ */
117
+ export declare class Typeahead {
118
+ private letters;
119
+ private last;
120
+ private readonly timeout;
121
+ constructor({ timeout }?: {
122
+ timeout?: number;
123
+ });
124
+ /**
125
+ * Whether a search is under way.
126
+ *
127
+ * A set that chooses on Space asks this first, because a Space typed in the
128
+ * middle of a search is part of the words, as in "New York".
129
+ */
130
+ typing(now?: number): boolean;
131
+ /**
132
+ * Adds a character to the search, and returns the index of the item it
133
+ * matches, or `-1` where none does.
134
+ *
135
+ * `texts` are the words of the items a reader can reach, in order, and
136
+ * `from` is the index of the item the reader is on, or `-1`.
137
+ */
138
+ match(key: string, texts: readonly string[], from: number, now?: number): number;
139
+ /** Ends the search, so the next letter starts a new one. */
140
+ reset(): void;
141
+ }
85
142
  /**
86
143
  * Moves through a set of items with the arrow keys, and chooses as it goes.
87
144
  *
@@ -107,6 +164,11 @@ export interface KeyboardNavigationOptions {
107
164
  * Disabled items are skipped rather than stopped at, so a set with a gap in it
108
165
  * still moves in one press.
109
166
  *
167
+ * **Typeahead is opt-in.** Pass `typeahead` with a function that returns an
168
+ * item's words, and a printable key moves to the next item whose words start
169
+ * with the letters typed, choosing it where arriving chooses. A key that a text
170
+ * field inside the set is typing into is left to the field.
171
+ *
110
172
  * A set that starts from focus rather than from the selection, or that moves
111
173
  * without choosing, says so through `origin` and `selects`. Neither is a reason
112
174
  * to keep a hand-written copy, and neither exempts a set from both axes.
@@ -118,7 +180,23 @@ export declare class KeyboardNavigationController implements ReactiveController
118
180
  private readonly selects;
119
181
  private readonly reserved;
120
182
  private readonly wrap;
121
- constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, wrap, }: KeyboardNavigationOptions);
183
+ private readonly words;
184
+ private readonly typeahead;
185
+ constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, typeahead, wrap, }: KeyboardNavigationOptions);
186
+ /** Moves to an item, and chooses it where arriving is choosing. */
187
+ private arrive;
188
+ /**
189
+ * Where the reader is: the item the press came from, or the selection.
190
+ *
191
+ * **Focus first, where nothing is chosen.** A reader who tabs into an
192
+ * unanswered set lands on its first item, and the selection then says `-1`.
193
+ * Starting from `-1`, a forward step lands on that same first item, so the
194
+ * first press appeared to do nothing and the second one moved. A native radio
195
+ * group moves to the second radio on that press, and so does this.
196
+ */
197
+ private current;
198
+ /** Moves to the item whose words start with what was typed, if any. */
199
+ private handleTypeahead;
122
200
  private readonly handleKeydown;
123
201
  hostConnected(): void;
124
202
  hostDisconnected(): void;
@@ -1,10 +1,47 @@
1
1
  import { inlineStep } from '../../utils/inline-step/inline-step.js';
2
2
  import { keyClaimed } from '../../utils/key-claimed/key-claimed.js';
3
3
  const KEYS = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);
4
+ export const typesCharacter = (event) => event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
5
+ export class Typeahead {
6
+ constructor({ timeout = 500 } = {}) {
7
+ this.letters = '';
8
+ this.last = 0;
9
+ this.timeout = timeout;
10
+ }
11
+ typing(now = Date.now()) {
12
+ return this.letters !== '' && now - this.last <= this.timeout;
13
+ }
14
+ match(key, texts, from, now = Date.now()) {
15
+ if (!this.typing(now)) {
16
+ this.letters = '';
17
+ }
18
+ this.last = now;
19
+ this.letters += key.toLowerCase();
20
+ const words = texts.map((text) => text.trim().toLowerCase());
21
+ const repeated = [...this.letters].every((letter) => letter === this.letters[0]);
22
+ const needle = repeated ? (this.letters[0] ?? '') : this.letters;
23
+ const start = repeated ? from + 1 : Math.max(from, 0);
24
+ for (let step = 0; step < words.length; step += 1) {
25
+ const at = (start + step) % words.length;
26
+ if (words[at]?.startsWith(needle)) {
27
+ return at;
28
+ }
29
+ }
30
+ return -1;
31
+ }
32
+ reset() {
33
+ this.letters = '';
34
+ }
35
+ }
4
36
  export class KeyboardNavigationController {
5
- constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, wrap = true, }) {
37
+ constructor(host, { items, origin = 'selection', reserved = () => [], selects = () => true, typeahead, wrap = true, }) {
38
+ this.typeahead = new Typeahead();
6
39
  this.handleKeydown = (event) => {
7
- if (!KEYS.has(event.key) || this.reserved().includes(event.key)) {
40
+ if (!KEYS.has(event.key)) {
41
+ this.handleTypeahead(event);
42
+ return;
43
+ }
44
+ if (this.reserved().includes(event.key)) {
8
45
  return;
9
46
  }
10
47
  if (keyClaimed(event)) {
@@ -14,9 +51,7 @@ export class KeyboardNavigationController {
14
51
  if (items.length === 0) {
15
52
  return;
16
53
  }
17
- const current = this.origin === 'focus'
18
- ? items.findIndex((item) => item.contains(event.target))
19
- : items.findIndex((item) => item.selected);
54
+ const current = this.current(items, event);
20
55
  if (this.origin === 'focus' && current === -1) {
21
56
  return;
22
57
  }
@@ -41,20 +76,55 @@ export class KeyboardNavigationController {
41
76
  next = last;
42
77
  }
43
78
  event.preventDefault();
44
- const target = items[next];
45
- target?.focusItem();
46
- if (this.selects()) {
47
- target?.select();
48
- }
79
+ this.typeahead.reset();
80
+ this.arrive(items[next]);
49
81
  };
50
82
  this.host = host;
51
83
  this.items = items;
52
84
  this.origin = origin;
53
85
  this.reserved = reserved;
54
86
  this.selects = selects;
87
+ this.words = typeahead;
55
88
  this.wrap = wrap;
56
89
  host.addController(this);
57
90
  }
91
+ arrive(target) {
92
+ target?.focusItem();
93
+ if (this.selects()) {
94
+ target?.select();
95
+ }
96
+ }
97
+ current(items, event) {
98
+ const path = event.composedPath().filter((node) => node instanceof Node);
99
+ const focused = items.findIndex((item) => path.some((node) => item.contains(node)));
100
+ if (this.origin === 'focus') {
101
+ return focused;
102
+ }
103
+ const selected = items.findIndex((item) => item.selected);
104
+ return selected < 0 ? focused : selected;
105
+ }
106
+ handleTypeahead(event) {
107
+ const words = this.words;
108
+ if (!words || !typesCharacter(event) || keyClaimed(event)) {
109
+ return;
110
+ }
111
+ if (event.key === ' ' && !this.typeahead.typing()) {
112
+ return;
113
+ }
114
+ const items = this.items().filter((item) => !item.disabled);
115
+ if (items.length === 0) {
116
+ return;
117
+ }
118
+ const from = this.current(items, event);
119
+ if (this.origin === 'focus' && from === -1) {
120
+ return;
121
+ }
122
+ const at = this.typeahead.match(event.key, items.map(words), from);
123
+ event.preventDefault();
124
+ if (at >= 0 && at !== from) {
125
+ this.arrive(items[at]);
126
+ }
127
+ }
58
128
  hostConnected() {
59
129
  this.host.addEventListener('keydown', this.handleKeydown);
60
130
  }