@fusion.dev/helix-headless 0.149.0 → 0.150.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 (35) hide show
  1. package/custom-elements.json +471 -45
  2. package/dist/button/root/button-root.d.ts +5 -0
  3. package/dist/button/root/button-root.js +3 -0
  4. package/dist/color-input/root/color-input-root.d.ts +1 -0
  5. package/dist/color-picker/root/color-picker-root.js +1 -0
  6. package/dist/combobox/root/combobox-root.d.ts +12 -1
  7. package/dist/combobox/root/combobox-root.js +8 -0
  8. package/dist/copy-button/root/copy-button-root.d.ts +4 -3
  9. package/dist/copy-button/root/copy-button-root.js +5 -1
  10. package/dist/date-input/root/date-input-root.d.ts +1 -0
  11. package/dist/element-events.generated.js +1 -1
  12. package/dist/filter-input/chip/filter-chip.d.ts +2 -1
  13. package/dist/filter-input/root/filter-input-root.d.ts +5 -0
  14. package/dist/filter-input/root/filter-input-root.js +7 -0
  15. package/dist/number-input/root/number-input-root.d.ts +6 -0
  16. package/dist/number-input/root/number-input-root.js +14 -3
  17. package/dist/option/root/option-root.d.ts +17 -8
  18. package/dist/option/root/option-root.js +18 -4
  19. package/dist/radio/group/radio-group.d.ts +8 -1
  20. package/dist/radio/group/radio-group.js +15 -1
  21. package/dist/slider/root/slider-root.d.ts +8 -0
  22. package/dist/slider/root/slider-root.js +6 -3
  23. package/dist/tag/group/tag-group.d.ts +22 -1
  24. package/dist/tag/group/tag-group.js +63 -3
  25. package/dist/tag/root/tag-root.d.ts +9 -0
  26. package/dist/tag/root/tag-root.js +5 -1
  27. package/dist/time-input/root/time-input-root.d.ts +1 -0
  28. package/dist/toggle/group/toggle-group.d.ts +6 -0
  29. package/dist/toggle/group/toggle-group.js +16 -1
  30. package/dist/toggle/root/toggle-root.d.ts +21 -3
  31. package/dist/toggle/root/toggle-root.js +22 -5
  32. package/lint.json +1 -1
  33. package/package.json +7 -7
  34. package/types/jsx.d.ts +160 -58
  35. package/types/vue.d.ts +28 -16
@@ -222,6 +222,11 @@ export declare class HelixHeadlessButton extends HelixFormElement {
222
222
  * inconsistently.
223
223
  */
224
224
  private sayPending;
225
+ /**
226
+ * @internal Says `words` in the button's live region, for a subclass with
227
+ * news of its own, as `CopyButton` says the result of a copy.
228
+ */
229
+ protected sayStatus(words: string): void;
225
230
  /**
226
231
  * @internal The live region a pending button speaks in, for a subclass that
227
232
  * replaces the template, as `CopyButton` does.
@@ -74,6 +74,9 @@ let HelixHeadlessButton = class HelixHeadlessButton extends HelixFormElement {
74
74
  }
75
75
  this.status.say(this.pending && focused ? this.pendingLabel : '', { clear: false });
76
76
  }
77
+ sayStatus(words) {
78
+ this.status.say(words);
79
+ }
77
80
  renderLive() {
78
81
  return drawStatus(this.status);
79
82
  }
@@ -45,6 +45,7 @@ declare const HelixHeadlessColorInput_base: (new (...args: any[]) => import("@fu
45
45
  * @slot end - Content at the trailing end, before the trigger.
46
46
  * @slot feedback - The message that says what is wrong with the value.
47
47
  * @slot hint - Help text under the field.
48
+ * @slot label - The label, for content richer than the `label` text.
48
49
  * @slot panel-footer - Content in the panel under the picker, such as a note or an action.
49
50
  * @slot panel-header - Content in the panel above the picker, such as a heading or a shortcut.
50
51
  * @slot start - Content at the leading end of the field, inside its border.
@@ -503,6 +503,7 @@ let HelixHeadlessColorPicker = class HelixHeadlessColorPicker extends MixinPicke
503
503
  min="0"
504
504
  max="100"
505
505
  tabindex="-1"
506
+ style="writing-mode: vertical-lr; direction: rtl"
506
507
  aria-label=${axisName(this.brightnessLabel)}
507
508
  aria-describedby=${this.feedback === '' ? nothing : 'feedback'}
508
509
  aria-valuetext=${reversed}
@@ -31,6 +31,9 @@ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusi
31
31
  * | Escape | Closes the list and puts the chosen words back. On a closed field, a second Escape clears a single choice. |
32
32
  * | Tab | Closes the list and moves on. |
33
33
  * | Backspace | With `multiple`, on an empty field, removes the last tag. |
34
+ * | Left | With `multiple`, with the caret at the start of the text, moves to the last tag's remove button. Right on a right-to-left page. |
35
+ * | Left, Right on a tag | Move between the tags, and past the last back to the text. |
36
+ * | Delete, Backspace on a tag | Removes the tag. |
34
37
  * | Space, Home, End, letters | Edit the text. |
35
38
  *
36
39
  * **ARIA.** The input is the `combobox`, named by the label, with
@@ -46,7 +49,8 @@ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusi
46
49
  * Set `autocomplete` to let a browser's autofill choose an option.
47
50
  *
48
51
  * With `multiple`, each chosen option is a tag in the field with its own remove
49
- * control. The panel stays open and the filter clears after each choice, so the
52
+ * control. The text is the field's one tab stop, and the arrow keys reach the
53
+ * tags from it. The panel stays open and the filter clears after each choice, so the
50
54
  * next choice starts from the whole list.
51
55
  *
52
56
  * With `creatable`, the combobox offers a create row and fires `helix-create`.
@@ -584,6 +588,13 @@ export declare class HelixHeadlessCombobox extends HelixHeadlessCombobox_base {
584
588
  * tag does.
585
589
  */
586
590
  protected removeLastToken(): void;
591
+ /** The tags, which the arrow keys reach from the text. */
592
+ protected tokenElements(): HTMLElement[];
593
+ /**
594
+ * Focus goes to the text field, the one tab stop, and never to a tag's remove
595
+ * button ahead of it, where Enter or Space would take a choice away.
596
+ */
597
+ protected get focusTarget(): HTMLElement | null;
587
598
  /** One tag asking to be taken out of the set. */
588
599
  private readonly handleDismiss;
589
600
  /**
@@ -446,6 +446,12 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
446
446
  this.drop(last);
447
447
  }
448
448
  }
449
+ tokenElements() {
450
+ return [...this.renderRoot.querySelectorAll('[part="tag"]')];
451
+ }
452
+ get focusTarget() {
453
+ return this.partNamed('control') ?? super.focusTarget;
454
+ }
449
455
  sync() {
450
456
  if (this.multiple) {
451
457
  reportValues(this.internals, this.name, this.value === '' ? [] : this.value.split(','));
@@ -574,6 +580,7 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
574
580
  data-value=${option.value}
575
581
  dismiss-label=${formatWords(this, 'removeLabel', { name: option.text })}
576
582
  ?dismissible=${!this.isDisabled && !this.readOnly}
583
+ .dismissTabStop=${false}
577
584
  @helix-dismiss=${this.handleDismiss}
578
585
  >${option.text}</${this.tag('tag')}
579
586
  >`;
@@ -619,6 +626,7 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
619
626
  aria-expanded=${this.open ? 'true' : 'false'}
620
627
  aria-describedby=${this.describedBy ?? nothing}
621
628
  aria-invalid=${this.invalidity ?? nothing}
629
+ aria-required=${this.required ? 'true' : nothing}
622
630
  placeholder=${(this.chosen.length > 0 || this.unresolved ? '' : this.placeholder) || nothing}
623
631
  ?disabled=${this.isDisabled}
624
632
  ?readonly=${this.readOnly}
@@ -29,8 +29,9 @@ import '../../tooltip/index.js';
29
29
  * **Keyboard.** Enter and Space copy, as they press a native button.
30
30
  *
31
31
  * The button is named `label`, or the host's `aria-label`, and its name
32
- * changes to `copied-label` or `error-label` for the moment after a press,
33
- * which is what a screen reader on it hears. The tooltip shows the same words.
32
+ * changes to `copied-label` or `error-label` for the moment after a press. The
33
+ * same words are said in a live region, so a screen reader hears the result,
34
+ * and the tooltip shows them.
34
35
  *
35
36
  * @since 0.1.0
36
37
  * @status stable
@@ -46,7 +47,7 @@ import '../../tooltip/index.js';
46
47
  * @event {HelixCopyErrorEvent} helix-copy-error - Fired when a press put nothing on the clipboard: the browser did not allow the write, or there was nothing to copy.
47
48
  *
48
49
  * @csspart button - The inner button.
49
- * @csspart live - The live region that says `pending-label` when the button becomes pending under focus. It is rendered from the first time the button is pending.
50
+ * @csspart live - The live region that says `copied-label` or `error-label` after a press, and `pending-label` when the button becomes pending under focus. It is rendered from the first time it says anything.
50
51
  * @csspart mark - The clipboard icon, and the confirmation icon that replaces it.
51
52
  * @csspart spinner - The ring a pending button turns, an empty element the styled button draws over the icon. It is rendered only while the button is pending.
52
53
  * @csspart tooltip - The tooltip bubble, forwarded from the tooltip inside the button.
@@ -37,7 +37,11 @@ let HelixHeadlessCopyButton = class HelixHeadlessCopyButton extends HelixHeadles
37
37
  const copying = this.clipboard.copy(this.text);
38
38
  this.announcePress(event);
39
39
  const wrote = await copying;
40
- if (!wrote && this.isConnected) {
40
+ if (!this.isConnected) {
41
+ return;
42
+ }
43
+ this.sayStatus(wrote ? this.copiedLabel : this.errorLabel);
44
+ if (!wrote) {
41
45
  this.dispatchEvent(new HelixCopyErrorEvent());
42
46
  }
43
47
  };
@@ -35,6 +35,7 @@ declare const HelixHeadlessDateInput_base: (new (...args: any[]) => import("@fus
35
35
  *
36
36
  * @slot feedback - The error message under the field.
37
37
  * @slot hint - The help text under the field.
38
+ * @slot label - The label, for content richer than the `label` text.
38
39
  * @slot next-icon - The icon on the calendar's next month control.
39
40
  * @slot panel-footer - Content in the panel under the calendar, such as a note or an action.
40
41
  * @slot panel-header - Content in the panel above the calendar, such as a heading or a shortcut.
@@ -262,7 +262,7 @@ registerElementEvents({
262
262
  'helix-headless-time-picker': ['change', 'helix-before-change', 'input'],
263
263
  'helix-headless-toast': ['helix-dismiss'],
264
264
  'helix-headless-toast-item': ['helix-dismiss'],
265
- 'helix-headless-toggle': ['helix-select'],
265
+ 'helix-headless-toggle': ['change', 'helix-select'],
266
266
  'helix-headless-toggle-group': ['change', 'helix-before-change', 'helix-select', 'input'],
267
267
  'helix-headless-tooltip': ['helix-before-close', 'helix-before-open', 'toggle'],
268
268
  'helix-headless-tour': [
@@ -29,7 +29,8 @@ declare const HelixHeadlessFilterChip_base: (new (...args: any[]) => import("@fu
29
29
  *
30
30
  * **Keyboard.** The remove button is a tab stop of its own, and Enter or Space
31
31
  * on it asks to remove the filter. In a `FilterInput`, Backspace in the empty
32
- * field asks to remove the last chip.
32
+ * field asks to remove the last chip, the arrow keys move between the field
33
+ * and the chips, and Delete or Backspace on a chip asks to remove it.
33
34
  *
34
35
  * The remove button is named `dismiss-label` followed by the chip's words,
35
36
  * such as "Remove filter Status is not open", so a row of chips says which
@@ -43,6 +43,9 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
43
43
  * | Home, End | Highlights the first or the last suggestion while the panel is open. |
44
44
  * | Escape | Closes the panel and stops there, so a dialog around the field stays open. |
45
45
  * | Backspace | In an empty field, asks the last token to go. |
46
+ * | Left | With the caret at the start of the text, moves to the last token's remove button. Right on a right-to-left page. |
47
+ * | Left, Right on a token | Move between the tokens, and past the last back to the text. |
48
+ * | Delete, Backspace on a token | Asks the token to go. |
46
49
  *
47
50
  * **Typed filters.** The field parses only the fields you declare. Write
48
51
  * `FilterField` elements, usually inside the `FilterMenu` slotted into `start`,
@@ -425,6 +428,8 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
425
428
  private readonly handleLeading;
426
429
  /** The row again, whenever what is in it changes. */
427
430
  private readonly handleTokens;
431
+ /** The tokens slotted in `tags`, which the arrow keys reach from the text. */
432
+ protected tokenElements(): HTMLElement[];
428
433
  /**
429
434
  * Asks the last token to go, rather than taking it.
430
435
  *
@@ -84,6 +84,9 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
84
84
  }
85
85
  };
86
86
  this.handleKeydown = (event) => {
87
+ if (event.isComposing) {
88
+ return;
89
+ }
87
90
  if (this.tokenKeydown(event, this.value)) {
88
91
  return;
89
92
  }
@@ -310,6 +313,9 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
310
313
  this.suggesting = options.length > 0;
311
314
  this.activeAt = this.activeAt < options.length ? this.activeAt : -1;
312
315
  }
316
+ tokenElements() {
317
+ return (this.tokens?.assignedElements({ flatten: true }) ?? []);
318
+ }
313
319
  removeLastToken() {
314
320
  const last = this.tokens
315
321
  ?.assignedElements({ flatten: true })
@@ -368,6 +374,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
368
374
  const active = this.open ? options[this.activeAt] : undefined;
369
375
  for (const [at, option] of options.entries()) {
370
376
  option.active = this.open && at === this.activeAt;
377
+ option.selectionFollowsActive = true;
371
378
  }
372
379
  const control = this.control;
373
380
  if (control && 'ariaActiveDescendantElement' in control) {
@@ -381,6 +381,8 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
381
381
  * saving on `change` kept 150 while the form submitted 100.
382
382
  */
383
383
  private readonly handleChange;
384
+ /** The value Enter settled and announced, until the field's `change` or the next keystroke. */
385
+ private enterCommit;
384
386
  /**
385
387
  * The four keys a spinner answers, which `Input` does not bind.
386
388
  *
@@ -423,6 +425,10 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
423
425
  *
424
426
  * On `keypress`, where the platform runs its own implicit submission, so a
425
427
  * consumer who prevents the `keydown` for Enter stops this too.
428
+ *
429
+ * **Settled first**, as blur settles it. The typed text is read as a number,
430
+ * `change` announces it, and the form value is written before the form is
431
+ * submitted, so a field showing `$2,500.50` submits 2500.5.
426
432
  */
427
433
  private readonly handleKeypress;
428
434
  /** @internal A single line of text counts against implicit submission. */
@@ -96,6 +96,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
96
96
  };
97
97
  this.handleInput = (event) => {
98
98
  event.stopPropagation();
99
+ this.enterCommit = undefined;
99
100
  const control = event.target;
100
101
  this.requestValue(control.value);
101
102
  restoreValue(control, this.shown);
@@ -109,7 +110,11 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
109
110
  this.handleChange = (event) => {
110
111
  event.stopPropagation();
111
112
  this.settle();
112
- this.announceChange();
113
+ const announced = this.enterCommit === this.value;
114
+ this.enterCommit = undefined;
115
+ if (!announced) {
116
+ this.announceChange();
117
+ }
113
118
  };
114
119
  this.handleKeydown = (event) => {
115
120
  if (event.key === 'ArrowUp') {
@@ -142,9 +147,15 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
142
147
  event.preventDefault();
143
148
  };
144
149
  this.handleKeypress = (event) => {
145
- if (event.key === 'Enter' && !event.defaultPrevented) {
146
- submitImplicitly(this.form);
150
+ if (event.key !== 'Enter' || event.defaultPrevented) {
151
+ return;
152
+ }
153
+ if (this.settle()) {
154
+ this.sync();
155
+ this.enterCommit = this.value;
156
+ this.announceChange();
147
157
  }
158
+ submitImplicitly(this.form);
148
159
  };
149
160
  }
150
161
  static { this.words = 'helix-number-input'; }
@@ -44,7 +44,9 @@ export declare const optionsAmong: (elements: readonly Element[]) => HelixHeadle
44
44
  * @documentation https://fusion.dev/helix/components/option
45
45
  *
46
46
  * @slot - The option's label.
47
+ * @slot description - A second line under the label, for content richer than the `description` text.
47
48
  * @slot end - Content after the label, such as a shortcut or a count.
49
+ * @slot indicator - An icon shown while the option is chosen, in place of the check the styled option draws.
48
50
  * @slot start - Content before the label, such as an icon or an avatar.
49
51
  *
50
52
  * @event {HelixSelectEvent} helix-select - The option was chosen. Cancelable: canceling it leaves the selection as it was, and keeps the browser from following an option's `href`.
@@ -57,6 +59,8 @@ export declare const optionsAmong: (elements: readonly Element[]) => HelixHeadle
57
59
  * @csspart text - The label and the description under it, as one column.
58
60
  *
59
61
  * @cssstate active - While the keyboard is on the option, which is the option `aria-activedescendant` names.
62
+ * @cssstate disabled - While the option cannot be chosen.
63
+ * @cssstate has-indicator - An icon is slotted into `indicator`.
60
64
  * @cssstate selected - While the option is chosen.
61
65
  */
62
66
  export declare class HelixHeadlessOption extends HelixElement {
@@ -136,14 +140,10 @@ export declare class HelixHeadlessOption extends HelixElement {
136
140
  * ends. Matching still reads the label, so what somebody types is matched
137
141
  * against the name rather than against prose about it.
138
142
  *
139
- * **A property and not a slot, which is the departure worth explaining.** The
140
- * area rules say a content property is its slot's fallback, and that holds for
141
- * a region always drawn: `Avatar`'s initials, `Tooltip`'s text. This one is
142
- * usually absent, and a wrapped slot generates a box whether or not it is
143
- * filled, so every option without a caption would carry an empty line. The
144
- * detection that would avoid it does not exist, `:host(:has())` being invalid
145
- * and the server having no children to inspect. A property is known at render
146
- * time, so the component can decline to draw the box at all.
143
+ * For a caption richer than a string, slot it into `description`. The
144
+ * property is that slot's fallback inside the `description` part. With no
145
+ * property the slot is drawn bare, which takes no box while it is empty, so
146
+ * an option without a caption carries no empty line.
147
147
  *
148
148
  * It does not reflect, per the rule that payload state stays off the host: a
149
149
  * string is not a fact a selector needs.
@@ -198,6 +198,15 @@ export declare class HelixHeadlessOption extends HelixElement {
198
198
  * Written by the listbox.
199
199
  */
200
200
  selected: boolean;
201
+ /**
202
+ * Whether the option says it is selected while the keyboard is on it.
203
+ *
204
+ * Written by a control whose list holds suggestions, such as `FilterInput`:
205
+ * nothing there stays chosen, so the row the keyboard is on is the
206
+ * selection, as the APG combobox's list with selection that follows focus
207
+ * says. No attribute: it is coordination between elements.
208
+ */
209
+ selectionFollowsActive: boolean;
201
210
  /**
202
211
  * Where the link opens, such as `_blank`, for an option with an `href`.
203
212
  */
@@ -4,8 +4,8 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { customElement, followLink, HelixElement, HelixSelectEvent, } from '@fusion.dev/helix-core';
8
- import { html, nothing } from 'lit';
7
+ import { customElement, followLink, HelixElement, HelixSelectEvent, slotFilled, } from '@fusion.dev/helix-core';
8
+ import { html } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  export const optionsAmong = (elements) => {
11
11
  const isOption = (element) => 'text' in element && 'value' in element && 'selected' in element;
@@ -23,6 +23,7 @@ let HelixHeadlessOption = class HelixHeadlessOption extends HelixElement {
23
23
  this.label = '';
24
24
  this.rel = '';
25
25
  this.selected = false;
26
+ this.selectionFollowsActive = false;
26
27
  this.target = '';
27
28
  this.textValue = '';
28
29
  this.value = '';
@@ -59,10 +60,14 @@ let HelixHeadlessOption = class HelixHeadlessOption extends HelixElement {
59
60
  super.willUpdate(changed);
60
61
  this.toggleState('selected', this.selected);
61
62
  this.toggleState('active', this.active);
63
+ this.toggleState('disabled', this.disabled);
62
64
  }
63
65
  render() {
64
66
  this.internals.role = 'option';
65
- this.internals.ariaSelected = this.href === '' ? String(this.selected) : null;
67
+ this.internals.ariaSelected =
68
+ this.href === ''
69
+ ? String(this.selected || (this.selectionFollowsActive && this.active))
70
+ : null;
66
71
  this.internals.ariaDisabled = this.disabled ? 'true' : null;
67
72
  this.internals.ariaDescription =
68
73
  this.alternate !== '' && this.alternateLabel !== '' ? this.alternateLabel : null;
@@ -73,9 +78,15 @@ let HelixHeadlessOption = class HelixHeadlessOption extends HelixElement {
73
78
  <slot name="start"></slot>
74
79
  <span part="text">
75
80
  <span part="label"><slot>${this.label}</slot></span>
76
- ${this.description ? html `<span part="description">${this.description}</span>` : nothing}
81
+ ${this.description
82
+ ? html `<span part="description"><slot name="description">${this.description}</slot></span>`
83
+ : html `<slot name="description"></slot>`}
77
84
  </span>
78
85
  <slot name="end"></slot>
86
+ <slot
87
+ name="indicator"
88
+ @slotchange=${(event) => this.toggleState('has-indicator', slotFilled(event.target))}
89
+ ></slot>
79
90
  </div>`;
80
91
  }
81
92
  };
@@ -109,6 +120,9 @@ __decorate([
109
120
  __decorate([
110
121
  property({ type: Boolean })
111
122
  ], HelixHeadlessOption.prototype, "selected", void 0);
123
+ __decorate([
124
+ property({ attribute: false })
125
+ ], HelixHeadlessOption.prototype, "selectionFollowsActive", void 0);
112
126
  __decorate([
113
127
  property()
114
128
  ], HelixHeadlessOption.prototype, "target", void 0);
@@ -114,8 +114,15 @@ export declare class HelixHeadlessRadioGroup extends HelixHeadlessRadioGroup_bas
114
114
  valueMissingMessage: string;
115
115
  protected readonly keys: KeyboardNavigationController;
116
116
  protected readonly cascade: DisabledCascadeController;
117
- /** The radios, in light-DOM order. Client only: the server has no tree. */
117
+ /**
118
+ * The radios, in light-DOM order. Client only: the server has no tree.
119
+ *
120
+ * Descendants, as `Checkbox.Group` reads its boxes, so a radio inside a
121
+ * layout wrapper of yours is one of the group's. A radio held by a group
122
+ * nested inside this one is that group's.
123
+ */
118
124
  get items(): HelixHeadlessRadio[];
125
+ private observer;
119
126
  /**
120
127
  * Announces before it applies, so a listener can refuse the change.
121
128
  *
@@ -42,7 +42,10 @@ let HelixHeadlessRadioGroup = class HelixHeadlessRadioGroup extends MixinControl
42
42
  }
43
43
  static { this.words = 'helix-radio-group'; }
44
44
  get items() {
45
- return [...(this.children ?? [])].filter((child) => child instanceof HelixHeadlessRadio);
45
+ if (this.children === undefined) {
46
+ return [];
47
+ }
48
+ return [...this.querySelectorAll('*')].filter((element) => element instanceof HelixHeadlessRadio && nearestGroup(element) === this);
46
49
  }
47
50
  get answer() {
48
51
  return this.items.find((item) => item.value === this.value);
@@ -91,10 +94,14 @@ let HelixHeadlessRadioGroup = class HelixHeadlessRadioGroup extends MixinControl
91
94
  connectedCallback() {
92
95
  super.connectedCallback();
93
96
  this.addEventListener('helix-select', this.handleSelection);
97
+ this.observer = new MutationObserver(() => this.requestUpdate());
98
+ this.observer.observe(this, { childList: true, subtree: true });
94
99
  }
95
100
  disconnectedCallback() {
96
101
  super.disconnectedCallback();
97
102
  this.removeEventListener('helix-select', this.handleSelection);
103
+ this.observer?.disconnect();
104
+ this.observer = undefined;
98
105
  }
99
106
  willUpdate(changed) {
100
107
  super.willUpdate(changed);
@@ -134,3 +141,10 @@ HelixHeadlessRadioGroup = __decorate([
134
141
  customElement('helix-headless-radio-group')
135
142
  ], HelixHeadlessRadioGroup);
136
143
  export { HelixHeadlessRadioGroup };
144
+ function nearestGroup(radio) {
145
+ let parent = radio.parentElement;
146
+ while (parent && !(parent instanceof HelixHeadlessRadioGroup)) {
147
+ parent = parent.parentElement;
148
+ }
149
+ return parent;
150
+ }
@@ -290,6 +290,14 @@ export declare class HelixHeadlessSlider extends HelixHeadlessSlider_base {
290
290
  * from the origin to the thumb as `--slider-from` and `--slider-to`.
291
291
  */
292
292
  private get fillStyle();
293
+ /**
294
+ * The range's axis, written on it, so a vertical slider is vertical to the
295
+ * accessibility tree with no stylesheet: Chromium reads a range's
296
+ * orientation off its writing mode and ignores `aria-orientation` there.
297
+ * `direction: rtl` puts the maximum at the top, which `vertical-lr` alone
298
+ * puts at the foot.
299
+ */
300
+ private get axis();
293
301
  /**
294
302
  * A value snapped to the step, counted from `min` the way a range counts it.
295
303
  *
@@ -112,10 +112,13 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
112
112
  get fillStyle() {
113
113
  const fraction = this.fraction;
114
114
  if (!this.origin) {
115
- return `--slider-fraction: ${fraction}`;
115
+ return `--slider-fraction: ${fraction}${this.axis}`;
116
116
  }
117
117
  const at = { center: 0.5, end: 1, start: 0 }[this.origin] ?? 0;
118
- return `--slider-fraction: ${fraction}; --slider-from: ${Math.min(at, fraction)}; --slider-to: ${Math.max(at, fraction)}`;
118
+ return `--slider-fraction: ${fraction}; --slider-from: ${Math.min(at, fraction)}; --slider-to: ${Math.max(at, fraction)}${this.axis}`;
119
+ }
120
+ get axis() {
121
+ return this.orientation === 'vertical' ? '; writing-mode: vertical-lr; direction: rtl' : '';
119
122
  }
120
123
  snapped(at) {
121
124
  if (!(this.step > 0)) {
@@ -208,7 +211,7 @@ let HelixHeadlessSlider = class HelixHeadlessSlider extends MixinControllerValue
208
211
  id=${which === 'start' ? 'control' : 'control-end'}
209
212
  data-thumb=${which}
210
213
  type="range"
211
- style="--slider-from: ${this.along(start)}; --slider-to: ${this.along(end)}"
214
+ style="--slider-from: ${this.along(start)}; --slider-to: ${this.along(end)}${this.axis}"
212
215
  min=${this.min}
213
216
  max=${this.max}
214
217
  step=${this.step}
@@ -15,7 +15,10 @@ export type TagSelectionMode = SelectionMode;
15
15
  * focus moves to the next tag, or the previous one after the last.
16
16
  *
17
17
  * `selection-mode` lets a reader select tags, with Space, Enter or a press:
18
- * `single` selects one at a time and `multiple` any number. `value` holds the
18
+ * `single` selects one at a time and `multiple` any number. In `multiple`,
19
+ * Shift with an arrow, Home or End selects the range from the tag last
20
+ * pressed to the tag focus moves to, and Control or Command with A selects
21
+ * every tag that is not disabled. `value` holds the
19
22
  * selected tags' values, separated by commas, and a change asks with
20
23
  * `helix-before-change` and tells with `change`. Enter on a tag with an
21
24
  * `href` follows its link. A `disabled` tag is passed over. A selected tag
@@ -30,6 +33,8 @@ export type TagSelectionMode = SelectionMode;
30
33
  * set is a `group`, and focus moves to its `empty` part,
31
34
  * which holds the `empty` slot: put words there such as "No filters" for a
32
35
  * reader to hear. With nothing slotted it says `empty-label`, "No tags".
36
+ * Where only disabled tags are left, the first of them holds the tab stop and
37
+ * takes focus, so the reader stays in the set.
33
38
  *
34
39
  * @since 0.1.0
35
40
  * @status stable
@@ -77,6 +82,12 @@ export declare class HelixHeadlessTagGroup extends HelixElement {
77
82
  value: string | readonly string[];
78
83
  /** The tag the reader was last on, which keeps the tab stop. */
79
84
  private last;
85
+ /**
86
+ * Where a range chosen with Shift and an arrow starts, and where it reached,
87
+ * in `multiple`: the tag last pressed or moved to without Shift.
88
+ */
89
+ private anchor;
90
+ private reached;
80
91
  /**
81
92
  * The arrow keys, Home, End and typed letters, moving focus and choosing
82
93
  * nothing. From the focused tag rather than the selection, since a reader
@@ -119,6 +130,16 @@ export declare class HelixHeadlessTagGroup extends HelixElement {
119
130
  * has moved.
120
131
  */
121
132
  protected press(tag: HelixHeadlessTag): void;
133
+ /** The reachable tags from one to the other, either way round. */
134
+ private span;
135
+ /**
136
+ * Shift and an arrow, Home or End, in `multiple`: the range from the anchor
137
+ * to the tag focus moved to is selected, in place of the range the last such
138
+ * key selected, as React Aria's selection extends.
139
+ */
140
+ private extend;
141
+ /** Asks for `value` to hold these tags' values, in their order. */
142
+ private choose;
122
143
  private readonly handleKeydown;
123
144
  private readonly handleClick;
124
145
  /**
@@ -23,7 +23,29 @@ let HelixHeadlessTagGroup = class HelixHeadlessTagGroup extends HelixElement {
23
23
  });
24
24
  this.handleKeydown = (event) => {
25
25
  const tag = this.tagOf(event);
26
- if (!tag || tag.disabled || event.altKey || event.ctrlKey || event.metaKey) {
26
+ if (!tag || tag.disabled || event.altKey) {
27
+ return;
28
+ }
29
+ const multiple = this.selectionMode === 'multiple';
30
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
31
+ if (multiple) {
32
+ event.preventDefault();
33
+ this.choose(this.reachable);
34
+ }
35
+ return;
36
+ }
37
+ if (event.ctrlKey || event.metaKey) {
38
+ return;
39
+ }
40
+ if (MOVES.has(event.key)) {
41
+ const to = this.last;
42
+ if (multiple && event.shiftKey && to && to !== tag) {
43
+ this.extend(tag, to);
44
+ }
45
+ else if (!event.shiftKey) {
46
+ this.anchor = to;
47
+ this.reached = to;
48
+ }
27
49
  return;
28
50
  }
29
51
  if (event.key === 'Delete' || event.key === 'Backspace') {
@@ -70,7 +92,15 @@ let HelixHeadlessTagGroup = class HelixHeadlessTagGroup extends HelixElement {
70
92
  this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
71
93
  }
72
94
  const active = document.activeElement;
73
- if (!had || this.tags.length > 0 || (active !== null && active !== document.body)) {
95
+ if (!had || (active !== null && active !== document.body)) {
96
+ return;
97
+ }
98
+ const [first] = this.tags;
99
+ if (first) {
100
+ if (this.reachable.length === 0) {
101
+ this.sync();
102
+ first.focusItem();
103
+ }
74
104
  return;
75
105
  }
76
106
  this.requestUpdate();
@@ -126,7 +156,10 @@ let HelixHeadlessTagGroup = class HelixHeadlessTagGroup extends HelixElement {
126
156
  selected: chosen.has(this.valueFor(tag)),
127
157
  tag,
128
158
  }));
129
- const holder = rovingStop(items, items.find((item) => item.tag === this.last), { disabled: (item) => item.disabled, selected: (item) => item.selected })?.tag;
159
+ const holder = rovingStop(items, items.find((item) => item.tag === this.last), {
160
+ disabled: (item) => item.disabled,
161
+ selected: (item) => item.selected,
162
+ })?.tag ?? tags[0];
130
163
  for (const tag of tags) {
131
164
  whenHydrated(tag, () => {
132
165
  tag.focusable = tag === holder;
@@ -144,10 +177,36 @@ let HelixHeadlessTagGroup = class HelixHeadlessTagGroup extends HelixElement {
144
177
  if (mode === 'none' || tag.disabled) {
145
178
  return;
146
179
  }
180
+ this.anchor = tag;
181
+ this.reached = tag;
147
182
  requestValue(this, pressedValue(mode, this.written, this.valueFor(tag)), (value) => {
148
183
  this.value = value;
149
184
  });
150
185
  }
186
+ span(from, to) {
187
+ const tags = this.reachable;
188
+ const [start, end] = [tags.indexOf(from), tags.indexOf(to)].sort((a, b) => a - b);
189
+ return start === undefined || end === undefined || start < 0 ? [] : tags.slice(start, end + 1);
190
+ }
191
+ extend(from, to) {
192
+ const anchor = this.anchor?.isConnected ? this.anchor : from;
193
+ const before = new Set(this.span(anchor, this.reached?.isConnected ? this.reached : from));
194
+ const after = this.span(anchor, to);
195
+ this.anchor = anchor;
196
+ this.reached = to;
197
+ const kept = this.tags.filter((tag) => commaList(this.written).includes(this.valueFor(tag)) && !before.has(tag));
198
+ const chosen = [...new Set([...kept, ...after])];
199
+ this.choose(this.tags.filter((tag) => chosen.includes(tag)));
200
+ }
201
+ choose(tags) {
202
+ const next = [...new Set(tags.map((tag) => this.valueFor(tag)))].join(', ');
203
+ if (next === this.written) {
204
+ return;
205
+ }
206
+ requestValue(this, next, (value) => {
207
+ this.value = value;
208
+ });
209
+ }
151
210
  connectedCallback() {
152
211
  super.connectedCallback();
153
212
  this.addEventListener('keydown', this.handleKeydown);
@@ -199,3 +258,4 @@ HelixHeadlessTagGroup = __decorate([
199
258
  customElement('helix-headless-tag-group')
200
259
  ], HelixHeadlessTagGroup);
201
260
  export { HelixHeadlessTagGroup };
261
+ const MOVES = new Set(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home']);