@fusion.dev/helix-headless 0.208.1 → 0.210.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.
@@ -1,4 +1,4 @@
1
- import { AriaForwardController, HelixElement } from '@fusion.dev/helix-core';
1
+ import { AriaForwardController, HelixElement, ToolbarController } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  /**
4
4
  * @summary A group of controls that share one tab stop.
@@ -55,6 +55,10 @@ import { type PropertyValues, type TemplateResult } from 'lit';
55
55
  * tab stop while the toolbar's is elsewhere, so Tab stops once in the
56
56
  * toolbar. A composite without one keeps its own tab stop.
57
57
  *
58
+ * A `<slot>` in the toolbar counts as the controls assigned to it, for a
59
+ * toolbar a component draws in its own shadow root with your controls passed
60
+ * into it: they share its tab stop and its arrows with the component's own.
61
+ *
58
62
  * A toolbar inside another toolbar, a Helix one or the page's own
59
63
  * `role="toolbar"`, is a `group` of the outer one, as React Aria's is: the
60
64
  * outer toolbar's arrows walk through its controls and on past them, and it
@@ -90,13 +94,6 @@ export declare class HelixHeadlessToolbar extends HelixElement {
90
94
  label: string;
91
95
  /** Carries a host `aria-label` and `aria-labelledby` to the toolbar. */
92
96
  protected readonly aria: AriaForwardController;
93
- /**
94
- * Whether the stop has been written onto controls this package does not
95
- * know, which happens on the first focus. From then on, every move of the
96
- * stop has to write them too, or the one at `0` can be removed or disabled
97
- * and leave every other one at `-1`.
98
- */
99
- private wroteForeign;
100
97
  /**
101
98
  * The direction the arrow keys move focus in.
102
99
  */
@@ -106,67 +103,12 @@ export declare class HelixHeadlessToolbar extends HelixElement {
106
103
  * wrap around from one end to the other.
107
104
  */
108
105
  stopAtEnds: boolean;
109
- private watcher;
110
- /**
111
- * The children that take part in the toolbar, in document order, each with
112
- * the elements in it that take focus.
113
- *
114
- * A test on the element rather than a list of tag names: a toolbar holds
115
- * anything, including elements this package has never heard of. A child
116
- * that takes no focus and holds nothing that does is skipped. Client only,
117
- * there being no tree to walk on the server.
118
- */
119
- private get controls();
120
- /** The elements in a child's light DOM that take focus. */
121
- private within;
122
- /**
123
- * The stops of a child that takes focus itself: the child, or the members of
124
- * a composite that owns its tab stop, such as a toggle group, which the
125
- * toolbar enters at a member and moves the stop of through `focusable`.
126
- */
127
- private inside;
128
- /**
129
- * Where focus lands on arriving at a control. A nested composite is entered
130
- * at the stop it holds, which for a toggle group is the chosen toggle.
131
- */
132
- private entry;
133
- /** The control a press or a focus came from, by its path. */
134
- private origin;
135
- /**
136
- * Moves the single tab stop to one control.
137
- *
138
- * Prefers the control's own `focusable` property to writing `tabindex` on it.
139
- * The attribute is a change to markup the consumer rendered, which a React
140
- * consumer's hydration reports and does not patch up; a control that offers
141
- * the property moves the stop inside its own shadow root instead.
142
- *
143
- * `tabindex` stays as the fallback, because a toolbar has to hold controls
144
- * this package has never heard of.
145
- */
146
- private setTabStop;
147
106
  /**
148
- * Sets the first tab stop once every custom control has upgraded.
149
- *
150
- * A custom element has no properties at all before it upgrades, so a pass
151
- * that ran first would find no `focusable`, take the `tabindex` fallback, and
152
- * write the very attribute this exists to avoid.
107
+ * The one tab stop and the arrow keys, shared with every toolbar a
108
+ * component draws: the controls are the children, a slot among them read as
109
+ * the elements assigned to it.
153
110
  */
154
- private claimTabStop;
155
- private readonly handleFocusIn;
156
- /**
157
- * Gives the tab stop back to a control that still exists.
158
- *
159
- * `controls` excludes anything disabled or hidden, so disabling the control
160
- * holding the stop drops it out of the list while every other control is
161
- * already at `-1`, leaving a toolbar the keyboard cannot reach at all. A Save
162
- * button disabling itself once the form is clean is enough to do it.
163
- *
164
- * `foreign` stays false, the same as the first claim, until a focus has
165
- * written `tabindex="-1"` onto foreign controls. From then on `setTabStop`
166
- * writes them as well, so removing or disabling the native button that held
167
- * the stop hands it to the first one left.
168
- */
169
- private readonly restoreTabStop;
111
+ protected readonly tabStop: ToolbarController;
170
112
  /**
171
113
  * Whether this toolbar sits inside another, which then owns the keys and the
172
114
  * tab stop: a toolbar inside a toolbar held the reader's arrows inside it.
@@ -181,18 +123,7 @@ export declare class HelixHeadlessToolbar extends HelixElement {
181
123
  * so a reflected default cannot appear on the host after hydration.
182
124
  */
183
125
  private get axis();
184
- /**
185
- * The toolbar's own keys, from inside a nested composite.
186
- *
187
- * In capture, ahead of the composite, which answers every arrow itself and
188
- * would otherwise hold the reader inside it. Only the keys along the
189
- * toolbar's axis and Home and End are taken, so the arrows across it still
190
- * move inside the composite.
191
- */
192
- private readonly handleCompositeKeydown;
193
- private readonly handleKeydown;
194
126
  connectedCallback(): void;
195
- disconnectedCallback(): void;
196
127
  protected firstUpdated(changed: PropertyValues): void;
197
128
  protected willUpdate(changed: PropertyValues): void;
198
129
  protected render(): TemplateResult;
@@ -1,44 +1,8 @@
1
1
  var HelixHeadlessToolbar_1;
2
2
  import { __decorate } from "tslib";
3
- import { AriaForwardController, closestInstance, customElement, HelixElement, inlineStep, keyClaimed, toolbarAround, words, } from '@fusion.dev/helix-core';
3
+ import { AriaForwardController, closestInstance, customElement, HelixElement, ToolbarController, toolbarAround, words, } from '@fusion.dev/helix-core';
4
4
  import { html, nothing } from 'lit';
5
5
  import { property } from 'lit/decorators.js';
6
- const KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);
7
- const ownsTabStop = (node) => 'focusable' in node;
8
- const NATIVE = 'a[href], button, input, select, textarea, [tabindex], [contenteditable=""], [contenteditable="true"]';
9
- const TABBABLE = NATIVE.replace('[tabindex]', '[tabindex]:not([tabindex="-1"])');
10
- const innerStops = new WeakMap();
11
- const takesFocus = (node) => {
12
- if (!(node instanceof HTMLElement) ||
13
- node.hasAttribute('disabled') ||
14
- node.getAttribute('aria-hidden') === 'true') {
15
- return false;
16
- }
17
- return (node.matches(NATIVE) ||
18
- ownsTabStop(node) ||
19
- node.shadowRoot?.delegatesFocus === true ||
20
- node.shadowRoot?.querySelector(TABBABLE) != null ||
21
- innerStops.get(node)?.isConnected === true);
22
- };
23
- const stopOf = (node) => {
24
- if (node.hasAttribute('tabindex')) {
25
- return node;
26
- }
27
- const held = innerStops.get(node);
28
- if (held?.isConnected && held.getRootNode() === node.shadowRoot) {
29
- return held;
30
- }
31
- const inner = node.localName.includes('-')
32
- ? node.shadowRoot?.querySelector(TABBABLE)
33
- : null;
34
- if (!inner) {
35
- return node;
36
- }
37
- innerStops.set(node, inner);
38
- return inner;
39
- };
40
- const flattens = (node) => ownsTabStop(node) &&
41
- node.selectionMode === 'multiple';
42
6
  let HelixHeadlessToolbar = class HelixHeadlessToolbar extends HelixElement {
43
7
  constructor() {
44
8
  super(...arguments);
@@ -47,177 +11,16 @@ let HelixHeadlessToolbar = class HelixHeadlessToolbar extends HelixElement {
47
11
  label: true,
48
12
  target: () => this.partNamed('toolbar'),
49
13
  });
50
- this.wroteForeign = false;
51
14
  this.stopAtEnds = false;
52
- this.handleFocusIn = (event) => {
53
- if (this.nested) {
54
- return;
55
- }
56
- const control = this.origin(event);
57
- if (control) {
58
- this.setTabStop(control);
59
- }
60
- };
61
- this.restoreTabStop = () => {
62
- if (this.nested) {
63
- return;
64
- }
65
- const controls = this.controls;
66
- const held = controls.some((control) => {
67
- if (control.group) {
68
- return control.group.focusable;
69
- }
70
- const composite = control.stops.length !== 1;
71
- const element = composite ? control.child : control.stops[0];
72
- if (ownsTabStop(element)) {
73
- return element.focusable;
74
- }
75
- return composite || stopOf(element).tabIndex === 0;
76
- });
77
- if (controls.length > 0 && !held) {
78
- this.setTabStop(controls[0], false);
79
- }
80
- };
81
- this.handleCompositeKeydown = (event) => {
82
- if (this.nested) {
83
- return;
84
- }
85
- const control = this.origin(event);
86
- if (!control || (control.stops.length === 1 && !control.group)) {
87
- return;
88
- }
89
- this.handleKeydown(event);
90
- if (event.defaultPrevented) {
91
- event.stopPropagation();
92
- }
93
- };
94
- this.handleKeydown = (event) => {
95
- if (this.nested || !KEYS.has(event.key) || keyClaimed(event)) {
96
- return;
97
- }
98
- const along = inlineStep(this, event.key);
99
- const forward = this.axis === 'horizontal' ? along === 1 : event.key === 'ArrowDown';
100
- const back = this.axis === 'horizontal' ? along === -1 : event.key === 'ArrowUp';
101
- const controls = this.controls;
102
- const origin = this.origin(event);
103
- const current = origin ? controls.findIndex((control) => control.child === origin.child) : -1;
104
- if (current === -1) {
105
- return;
106
- }
107
- let next = current;
108
- const last = controls.length - 1;
109
- if (forward) {
110
- next = this.stopAtEnds ? Math.min(current + 1, last) : (current + 1) % controls.length;
111
- }
112
- else if (back) {
113
- next = this.stopAtEnds
114
- ? Math.max(current - 1, 0)
115
- : (current - 1 + controls.length) % controls.length;
116
- }
117
- else if (event.key === 'Home') {
118
- next = 0;
119
- }
120
- else if (event.key === 'End') {
121
- next = controls.length - 1;
122
- }
123
- else {
124
- return;
125
- }
126
- event.preventDefault();
127
- const target = controls[next];
128
- this.setTabStop(target);
129
- const element = target ? this.entry(target) : undefined;
130
- if (element && 'focusItem' in element && typeof element.focusItem === 'function') {
131
- element.focusItem();
132
- }
133
- else if (element) {
134
- ;
135
- (ownsTabStop(element) ? element : stopOf(element)).focus();
136
- }
137
- };
15
+ this.tabStop = new ToolbarController(this, {
16
+ arrows: () => this.axis,
17
+ members: () => [...this.children],
18
+ nested: () => this.nested,
19
+ stopAtEnds: () => this.stopAtEnds,
20
+ });
138
21
  }
139
22
  static { HelixHeadlessToolbar_1 = this; }
140
23
  static { this.words = 'helix-toolbar'; }
141
- get controls() {
142
- const controls = [];
143
- for (const child of this.children) {
144
- if (!(child instanceof HTMLElement) ||
145
- child.hasAttribute('disabled') ||
146
- child.getAttribute('aria-hidden') === 'true') {
147
- continue;
148
- }
149
- if (takesFocus(child)) {
150
- const stops = this.inside(child);
151
- if (stops.length > 1 && flattens(child)) {
152
- for (const stop of stops) {
153
- controls.push({ child: stop, group: child, stops: [stop] });
154
- }
155
- continue;
156
- }
157
- controls.push({ child, stops });
158
- continue;
159
- }
160
- for (const stop of this.within(child)) {
161
- controls.push({ child: stop, stops: [stop] });
162
- }
163
- }
164
- return controls;
165
- }
166
- within(child) {
167
- return [...child.querySelectorAll('*')].filter(takesFocus);
168
- }
169
- inside(child) {
170
- if (!ownsTabStop(child)) {
171
- return [child];
172
- }
173
- const members = this.within(child);
174
- return members.length > 1 ? members : [child];
175
- }
176
- entry(control) {
177
- const { stops } = control;
178
- if (stops.length === 1) {
179
- return stops[0];
180
- }
181
- return (stops.find((stop) => (ownsTabStop(stop) && stop.focusable) ||
182
- stop.selected === true) ?? stops[0]);
183
- }
184
- origin(event) {
185
- const path = event.composedPath();
186
- return this.controls.find((control) => control.stops.some((stop) => path.includes(stop)));
187
- }
188
- setTabStop(active, foreign = true) {
189
- if (foreign) {
190
- this.wroteForeign = true;
191
- }
192
- for (const control of this.controls) {
193
- if (control.group) {
194
- control.group.focusable = control.group === active?.group;
195
- continue;
196
- }
197
- if (control.stops.length !== 1) {
198
- if (ownsTabStop(control.child)) {
199
- control.child.focusable = control.child === active?.child;
200
- }
201
- continue;
202
- }
203
- const element = control.stops[0];
204
- const stop = control.child === active?.child;
205
- if (ownsTabStop(element)) {
206
- element.focusable = stop;
207
- }
208
- else if (foreign || this.wroteForeign) {
209
- stopOf(element).tabIndex = stop ? 0 : -1;
210
- }
211
- }
212
- }
213
- async claimTabStop() {
214
- if (this.nested) {
215
- return;
216
- }
217
- const pending = [...new Set([...this.children].map((child) => child.localName))].filter((name) => name.includes('-'));
218
- await Promise.all(pending.map((name) => customElements.whenDefined(name)));
219
- this.setTabStop(this.controls[0], false);
220
- }
221
24
  get nested() {
222
25
  const parent = this.parentElement;
223
26
  if (!parent) {
@@ -231,27 +34,10 @@ let HelixHeadlessToolbar = class HelixHeadlessToolbar extends HelixElement {
231
34
  connectedCallback() {
232
35
  super.connectedCallback();
233
36
  this.requestUpdate();
234
- this.addEventListener('keydown', this.handleCompositeKeydown, true);
235
- this.addEventListener('keydown', this.handleKeydown);
236
- this.addEventListener('focusin', this.handleFocusIn);
237
- this.watcher = new MutationObserver(this.restoreTabStop);
238
- this.watcher.observe(this, {
239
- attributeFilter: ['disabled', 'aria-hidden'],
240
- attributes: true,
241
- subtree: true,
242
- });
243
- }
244
- disconnectedCallback() {
245
- super.disconnectedCallback();
246
- this.removeEventListener('keydown', this.handleCompositeKeydown, true);
247
- this.removeEventListener('keydown', this.handleKeydown);
248
- this.removeEventListener('focusin', this.handleFocusIn);
249
- this.watcher?.disconnect();
250
- this.watcher = undefined;
251
37
  }
252
38
  firstUpdated(changed) {
253
39
  super.firstUpdated(changed);
254
- void this.claimTabStop();
40
+ void this.tabStop.claim();
255
41
  }
256
42
  willUpdate(changed) {
257
43
  super.willUpdate(changed);
@@ -259,7 +45,7 @@ let HelixHeadlessToolbar = class HelixHeadlessToolbar extends HelixElement {
259
45
  this.toggleState('horizontal', this.axis === 'horizontal');
260
46
  this.toggleState('vertical', this.axis === 'vertical');
261
47
  if (this.hasUpdated && changed.has('orientation')) {
262
- for (const child of this.children) {
48
+ for (const child of this.tabStop.members) {
263
49
  ;
264
50
  child.requestUpdate?.();
265
51
  }
@@ -273,7 +59,7 @@ let HelixHeadlessToolbar = class HelixHeadlessToolbar extends HelixElement {
273
59
  aria-label=${this.label}
274
60
  aria-orientation=${nested ? nothing : this.axis}
275
61
  >
276
- <slot @slotchange=${() => this.claimTabStop()}></slot>
62
+ <slot @slotchange=${() => this.tabStop.claim()}></slot>
277
63
  </div>`;
278
64
  }
279
65
  };