@fusion.dev/helix-core 0.142.0 → 0.143.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 (47) hide show
  1. package/custom-elements.json +1475 -54
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +55 -0
  3. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +72 -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 +10 -0
  7. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +5 -1
  8. package/dist/controllers/copy-controller/copy-controller.js +5 -0
  9. package/dist/controllers/dismiss-controller/dismiss-controller.js +40 -3
  10. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.d.ts +79 -1
  11. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +80 -10
  12. package/dist/controllers/popover-controller/popover-controller.d.ts +11 -0
  13. package/dist/controllers/popover-controller/popover-controller.js +6 -0
  14. package/dist/controllers/source-controller/source-controller.d.ts +22 -0
  15. package/dist/controllers/source-controller/source-controller.js +27 -1
  16. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +16 -0
  17. package/dist/controllers/virtualize-controller/virtualize-controller.js +24 -2
  18. package/dist/events/events.d.ts +102 -4
  19. package/dist/events/events.js +84 -0
  20. package/dist/helix-element/react-bridge.js +6 -4
  21. package/dist/helix-form-element/helix-form-element.d.ts +131 -2
  22. package/dist/helix-form-element/helix-form-element.js +216 -6
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +4 -3
  25. package/dist/mixins/mixin-chart-source/mixin-chart-source.d.ts +8 -0
  26. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +4 -0
  27. package/dist/mixins/mixin-collection/mixin-collection.d.ts +4 -2
  28. package/dist/mixins/mixin-collection/mixin-collection.js +8 -6
  29. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +19 -3
  30. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +39 -0
  31. package/dist/mixins/mixin-field/mixin-field.js +38 -8
  32. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.d.ts +8 -3
  33. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +6 -0
  34. package/dist/mixins/mixin-picker-field/mixin-picker-field.d.ts +27 -4
  35. package/dist/mixins/mixin-picker-field/mixin-picker-field.js +83 -1
  36. package/dist/utils/draw-tooltip/draw-tooltip.d.ts +3 -0
  37. package/dist/utils/draw-tooltip/draw-tooltip.js +5 -0
  38. package/dist/utils/read-series/read-series.d.ts +28 -0
  39. package/dist/utils/read-series/read-series.js +31 -4
  40. package/dist/utils/report-runtime/report-runtime.d.ts +10 -0
  41. package/dist/utils/scroll-lock/scroll-lock.d.ts +16 -0
  42. package/dist/utils/scroll-lock/scroll-lock.js +30 -0
  43. package/dist/utils/written-list/written-list.d.ts +11 -2
  44. package/dist/utils/written-list/written-list.js +3 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +4 -4
@@ -1,6 +1,34 @@
1
1
  import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
2
  /** What the controller needs to know: which element inside is the control. */
3
3
  export interface AriaForwardOptions {
4
+ /**
5
+ * Whether a host `aria-label` names the control too.
6
+ *
7
+ * For a control named by its content, a button or a switch, which has no
8
+ * `label` to render it into. A field renders its `aria-label` as a clipped
9
+ * label already, so it leaves this off. A function is asked on every
10
+ * forward, for a subclass that names the control itself.
11
+ */
12
+ label?: boolean | (() => boolean);
13
+ /**
14
+ * Elements read after whatever names the control from outside, the host's
15
+ * `aria-labelledby` or its `<label>`.
16
+ *
17
+ * For a control whose name is the label and then what it shows, as a
18
+ * native `<select>` is named. Added only where the host gives a name, so a
19
+ * control named by its own template keeps its own.
20
+ */
21
+ labelledByAfter?: () => Element[];
22
+ /**
23
+ * Whether the control has a name of its own, where its own labels do not
24
+ * say.
25
+ *
26
+ * A file input is labeled by the trigger that opens its picker, "Choose
27
+ * files", which is an action rather than a name, so a page's `<label>`
28
+ * should still name it. Absent, the control's own `aria-label`,
29
+ * `aria-labelledby` and labels with words in them decide.
30
+ */
31
+ named?: () => boolean;
4
32
  /** The focusable thing in the shadow root, or null before it renders. */
5
33
  target: () => HTMLElement | null;
6
34
  }
@@ -42,6 +70,13 @@ export interface AriaForwardOptions {
42
70
  * a framework rendering a tooltip after this element upgrades, or a consumer
43
71
  * pointing `aria-describedby` at a different id, changes an attribute and
44
72
  * nothing schedules a Lit update.
73
+ *
74
+ * **A `<label>` that names the host names the control too**, when the control
75
+ * has no name of its own. The platform gives a form-associated host its labels
76
+ * and the host is a generic the reader never lands on, so a `<label for>` or a
77
+ * wrapping `<label>` left the textbox unnamed. The labels travel the same way a
78
+ * forwarded `aria-labelledby` does, as element references, and are read again
79
+ * when focus arrives, since a label can be added without the host hearing.
45
80
  */
46
81
  export declare class AriaForwardController implements ReactiveController {
47
82
  private readonly host;
@@ -51,7 +86,25 @@ export declare class AriaForwardController implements ReactiveController {
51
86
  private readonly forwarded;
52
87
  /** The control the record above is about, so a replacement resets it. */
53
88
  private recorded;
89
+ /** Whether a host `aria-label` is carried down. See the option. */
90
+ private readonly asked;
91
+ private get label();
92
+ /** The component's own answer to whether the control is named. See the option. */
93
+ private readonly named;
94
+ /** What is read after the host's names. See the option. */
95
+ private readonly after;
54
96
  constructor(host: ReactiveControllerHost & HTMLElement, options: AriaForwardOptions);
97
+ /**
98
+ * Whether the control has a name that did not come from this controller: an
99
+ * `aria-label`, an `aria-labelledby` of its own, or a label of its own with
100
+ * words in it.
101
+ */
102
+ private namedByItself;
103
+ /**
104
+ * The labels the platform associated with the host, for a control with no
105
+ * name of its own, and none otherwise.
106
+ */
107
+ private hostLabels;
55
108
  /**
56
109
  * Resolves the host's references and hands them to the control.
57
110
  *
@@ -65,5 +118,7 @@ export declare class AriaForwardController implements ReactiveController {
65
118
  forward(): void;
66
119
  hostConnected(): void;
67
120
  hostDisconnected(): void;
121
+ /** Forwards again as focus arrives, for a label added since the last update. */
122
+ private readonly refresh;
68
123
  hostUpdated(): void;
69
124
  }
@@ -1,16 +1,61 @@
1
1
  import { elementById } from '../../utils/element-by-id/element-by-id.js';
2
+ const shownText = (node) => {
3
+ if (node instanceof HTMLSlotElement) {
4
+ return node.assignedNodes({ flatten: true }).map(shownText).join('');
5
+ }
6
+ if (node.nodeType === Node.TEXT_NODE) {
7
+ return node.textContent ?? '';
8
+ }
9
+ if (node instanceof HTMLElement && node.hidden) {
10
+ return '';
11
+ }
12
+ return [...node.childNodes].map(shownText).join('');
13
+ };
2
14
  const RELATIONSHIPS = [
3
15
  ['aria-describedby', 'ariaDescribedByElements'],
4
16
  ['aria-labelledby', 'ariaLabelledByElements'],
5
17
  ];
6
- const STATES = ['aria-expanded', 'aria-haspopup', 'aria-controls', 'aria-invalid'];
18
+ const STATES = [
19
+ 'aria-expanded',
20
+ 'aria-haspopup',
21
+ 'aria-controls',
22
+ 'aria-invalid',
23
+ 'aria-pressed',
24
+ ];
7
25
  export class AriaForwardController {
26
+ get label() {
27
+ return typeof this.asked === 'function' ? this.asked() : this.asked;
28
+ }
8
29
  constructor(host, options) {
9
30
  this.forwarded = new Set();
31
+ this.refresh = () => {
32
+ this.forward();
33
+ };
10
34
  this.host = host;
11
35
  this.target = options.target;
36
+ this.asked = options.label ?? false;
37
+ this.named = options.named;
38
+ this.after = options.labelledByAfter;
12
39
  host.addController(this);
13
40
  }
41
+ namedByItself(target) {
42
+ if (target.getAttribute('aria-label')) {
43
+ return true;
44
+ }
45
+ if (!this.forwarded.has('aria-labelledby') && target.hasAttribute('aria-labelledby')) {
46
+ return true;
47
+ }
48
+ const own = target.labels;
49
+ return [...(own ?? [])].some((label) => shownText(label).trim() !== '');
50
+ }
51
+ hostLabels(target) {
52
+ const labels = this.host.labels;
53
+ const named = this.named ? this.named() : this.namedByItself(target);
54
+ if (!labels || labels.length === 0 || named) {
55
+ return [];
56
+ }
57
+ return [...labels].filter((label) => label instanceof HTMLElement);
58
+ }
14
59
  forward() {
15
60
  const target = this.target();
16
61
  if (!target) {
@@ -20,17 +65,34 @@ export class AriaForwardController {
20
65
  this.forwarded.clear();
21
66
  this.recorded = target;
22
67
  }
68
+ for (const attribute of this.label ? [...STATES, 'aria-label'] : STATES) {
69
+ const value = this.host.getAttribute(attribute);
70
+ if (value === null) {
71
+ if (this.forwarded.has(attribute)) {
72
+ target.removeAttribute(attribute);
73
+ this.forwarded.delete(attribute);
74
+ }
75
+ continue;
76
+ }
77
+ target.setAttribute(attribute, value);
78
+ this.forwarded.add(attribute);
79
+ }
23
80
  for (const [attribute, property] of RELATIONSHIPS) {
24
81
  if (!(property in target)) {
25
82
  continue;
26
83
  }
27
84
  const ids = (this.host.getAttribute(attribute) ?? '').split(/\s+/).filter(Boolean);
28
- const elements = ids
85
+ const referenced = ids
29
86
  .map((id) => elementById(this.host, id))
30
87
  .filter((element) => element !== null);
88
+ const elements = referenced.length === 0 && attribute === 'aria-labelledby'
89
+ ? this.hostLabels(target)
90
+ : referenced;
31
91
  const reflect = target;
32
92
  if (elements.length > 0) {
33
- reflect[property] = elements;
93
+ const after = attribute === 'aria-labelledby' ? (this.after?.() ?? []) : [];
94
+ const named = elements;
95
+ reflect[property] = [...named, ...after.filter((one) => !named.includes(one))];
34
96
  this.forwarded.add(attribute);
35
97
  continue;
36
98
  }
@@ -39,28 +101,22 @@ export class AriaForwardController {
39
101
  this.forwarded.delete(attribute);
40
102
  }
41
103
  }
42
- for (const attribute of STATES) {
43
- const value = this.host.getAttribute(attribute);
44
- if (value === null) {
45
- if (this.forwarded.has(attribute)) {
46
- target.removeAttribute(attribute);
47
- this.forwarded.delete(attribute);
48
- }
49
- continue;
50
- }
51
- target.setAttribute(attribute, value);
52
- this.forwarded.add(attribute);
53
- }
54
104
  }
55
105
  hostConnected() {
56
106
  this.observer = new MutationObserver(() => this.forward());
57
107
  this.observer.observe(this.host, {
58
- attributeFilter: [...RELATIONSHIPS.map(([attribute]) => attribute), ...STATES],
108
+ attributeFilter: [
109
+ ...RELATIONSHIPS.map(([attribute]) => attribute),
110
+ ...STATES,
111
+ ...(this.asked === false ? [] : ['aria-label']),
112
+ ],
59
113
  });
114
+ this.host.addEventListener('focusin', this.refresh);
60
115
  }
61
116
  hostDisconnected() {
62
117
  this.observer?.disconnect();
63
118
  this.observer = undefined;
119
+ this.host.removeEventListener('focusin', this.refresh);
64
120
  }
65
121
  hostUpdated() {
66
122
  this.forward();
@@ -5,6 +5,11 @@ export interface ArrivalOptions {
5
5
  text: () => string;
6
6
  /** Whether the element is arriving now, rather than with the page. */
7
7
  arriving: () => boolean;
8
+ /**
9
+ * How long the region keeps the words once it has said them, in
10
+ * milliseconds. Defaults to seven seconds.
11
+ */
12
+ hold?: number;
8
13
  }
9
14
  /**
10
15
  * The words of an element that is announced when it appears, for a region that
@@ -24,6 +29,12 @@ export interface ArrivalOptions {
24
29
  * second time on the headless tier, which has no sheet to clip the region, and
25
30
  * a served page changed its picture on hydrating.
26
31
  *
32
+ * **And emptied again once it has been heard.** The words stay in the element
33
+ * itself, so a region that kept its copy put the message in the accessibility
34
+ * tree twice, and a reader moving through the page met it twice. The region
35
+ * keeps the copy for `hold` and then lets it go, which a polite region does not
36
+ * read out, removals being no news.
37
+ *
27
38
  * The component renders `said` into its region and calls `say` when what it
28
39
  * holds changes, from a `slotchange`.
29
40
  */
@@ -35,6 +46,7 @@ export declare class ArrivalController implements ReactiveController {
35
46
  /** The words last accounted for, said or not. */
36
47
  private heard;
37
48
  private frame;
49
+ private timer;
38
50
  constructor(host: ReactiveControllerHost, options: ArrivalOptions);
39
51
  hostConnected(): void;
40
52
  hostDisconnected(): void;
@@ -1,3 +1,4 @@
1
+ const HOLD = 7000;
1
2
  export class ArrivalController {
2
3
  constructor(host, options) {
3
4
  this.host = host;
@@ -22,6 +23,9 @@ export class ArrivalController {
22
23
  hostDisconnected() {
23
24
  cancelAnimationFrame(this.frame);
24
25
  this.frame = 0;
26
+ clearTimeout(this.timer);
27
+ this.timer = undefined;
28
+ this.said = '';
25
29
  }
26
30
  say() {
27
31
  const next = this.options.text();
@@ -31,5 +35,11 @@ export class ArrivalController {
31
35
  this.heard = next;
32
36
  this.said = next;
33
37
  this.host.requestUpdate();
38
+ clearTimeout(this.timer);
39
+ this.timer = setTimeout(() => {
40
+ this.timer = undefined;
41
+ this.said = '';
42
+ this.host.requestUpdate();
43
+ }, this.options.hold ?? HOLD);
34
44
  }
35
45
  }
@@ -110,6 +110,16 @@ export declare class ChartPointerController implements ReactiveController {
110
110
  * can change its direction without the chart updating.
111
111
  */
112
112
  get mirrored(): boolean;
113
+ /**
114
+ * Whether a pointer event may move the reading, which only a mouse or a pen
115
+ * may.
116
+ *
117
+ * Public for a chart that hears the pointer itself, a donut's slices and a
118
+ * heatmap's cells, so the rule a coarse pointer gets is this one everywhere.
119
+ * Heard unfiltered, a touch opened the bubble under the finger and left it
120
+ * there after the finger lifted.
121
+ */
122
+ accepts(event: Event): boolean;
113
123
  /** Puts the pointer on a reading, for a chart that resolves it some other way. */
114
124
  set(at: number): void;
115
125
  private readonly move;
@@ -5,7 +5,7 @@ export class ChartPointerController {
5
5
  this.at = -1;
6
6
  this.axis = 'inline';
7
7
  this.move = (event) => {
8
- if (this.axis === 'none' || (event.pointerType !== 'mouse' && event.pointerType !== 'pen')) {
8
+ if (this.axis === 'none' || !this.accepts(event)) {
9
9
  return;
10
10
  }
11
11
  const box = this.box();
@@ -63,6 +63,10 @@ export class ChartPointerController {
63
63
  get mirrored() {
64
64
  return isRtl(this.host);
65
65
  }
66
+ accepts(event) {
67
+ const type = event.pointerType;
68
+ return type === 'mouse' || type === 'pen';
69
+ }
66
70
  set(at) {
67
71
  if (at !== this.at) {
68
72
  this.at = at;
@@ -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 {
@@ -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 = () => {
@@ -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
  }
@@ -122,6 +122,7 @@ export declare class PopoverController implements ReactiveController {
122
122
  private readonly dismisser;
123
123
  /** Whether the surface is currently shown, so the calls can be idempotent. */
124
124
  private shown;
125
+ private readonly host;
125
126
  constructor(host: ReactiveControllerHost & HTMLElement, options: PopoverOptions);
126
127
  /** Whether the surface is showing. */
127
128
  get open(): boolean;
@@ -152,4 +153,14 @@ export declare class PopoverController implements ReactiveController {
152
153
  */
153
154
  reposition(): void;
154
155
  hostDisconnected(): void;
156
+ /**
157
+ * Asks a reconnected host that is still open to update, so the panel is shown
158
+ * again.
159
+ *
160
+ * The flag above lets it show, and nothing asked it to: a host moved in the
161
+ * DOM while open kept `open` and `aria-expanded="true"` with the panel
162
+ * hidden until something else updated it, and Escape did not close it,
163
+ * because the dismissal is started by showing.
164
+ */
165
+ hostConnected(): void;
155
166
  }
@@ -4,6 +4,7 @@ export class PopoverController {
4
4
  constructor(host, options) {
5
5
  this.shown = false;
6
6
  this.options = options;
7
+ this.host = host;
7
8
  this.floating = new FloatingController(host, {
8
9
  anchor: options.anchor,
9
10
  ...(options.arrow === undefined ? {} : { arrow: options.arrow }),
@@ -85,4 +86,9 @@ export class PopoverController {
85
86
  hostDisconnected() {
86
87
  this.shown = false;
87
88
  }
89
+ hostConnected() {
90
+ if (this.options.open?.()) {
91
+ this.host.requestUpdate();
92
+ }
93
+ }
88
94
  }