@fusion.dev/helix-headless 0.157.0 → 0.159.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 (56) hide show
  1. package/custom-elements.json +851 -56
  2. package/dist/app-header/root/app-header-root.d.ts +59 -12
  3. package/dist/app-header/root/app-header-root.js +68 -29
  4. package/dist/button/root/button-root.d.ts +8 -0
  5. package/dist/button/root/button-root.js +4 -0
  6. package/dist/button-group/root/button-group-root.d.ts +20 -7
  7. package/dist/button-group/root/button-group-root.js +30 -5
  8. package/dist/command/root/command-root.d.ts +19 -5
  9. package/dist/command/root/command-root.js +14 -4
  10. package/dist/data-table/root/data-table-root.js +5 -2
  11. package/dist/element-events.generated.js +7 -1
  12. package/dist/lightbox/item/lightbox-item.d.ts +7 -1
  13. package/dist/lightbox/item/lightbox-item.js +15 -2
  14. package/dist/lightbox/root/lightbox-root.d.ts +8 -4
  15. package/dist/lightbox/root/lightbox-root.js +9 -1
  16. package/dist/lightbox/zoom-controller.d.ts +13 -4
  17. package/dist/lightbox/zoom-controller.js +17 -0
  18. package/dist/list/item/list-item.d.ts +15 -1
  19. package/dist/list/item/list-item.js +20 -1
  20. package/dist/list/root/list-root.d.ts +29 -1
  21. package/dist/list/root/list-root.js +44 -10
  22. package/dist/listbox/root/listbox-root.d.ts +19 -2
  23. package/dist/listbox/root/listbox-root.js +16 -8
  24. package/dist/map/root/map-root.d.ts +4 -0
  25. package/dist/map/root/map-root.js +1 -1
  26. package/dist/menu/item/menu-item.js +1 -1
  27. package/dist/menu/root/menu-root.d.ts +6 -2
  28. package/dist/menu/root/menu-root.js +9 -2
  29. package/dist/number-input/root/number-input-root.d.ts +7 -4
  30. package/dist/number-input/root/number-input-root.js +3 -3
  31. package/dist/progress/root/progress-root.js +1 -1
  32. package/dist/select/root/select-root.d.ts +8 -1
  33. package/dist/select/root/select-root.js +8 -5
  34. package/dist/skeleton/root/skeleton-root.d.ts +2 -1
  35. package/dist/skeleton/root/skeleton-root.js +1 -1
  36. package/dist/steps/root/steps-root.js +8 -6
  37. package/dist/steps/step/step.d.ts +9 -0
  38. package/dist/steps/step/step.js +19 -2
  39. package/dist/tabs/panel/tab-panel.d.ts +22 -1
  40. package/dist/tabs/panel/tab-panel.js +22 -1
  41. package/dist/tabs/root/tabs-root.d.ts +21 -5
  42. package/dist/tabs/root/tabs-root.js +24 -13
  43. package/dist/tabs/tab/tab.d.ts +4 -2
  44. package/dist/tabs/tab/tab.js +3 -1
  45. package/dist/toast/item/toast-item.d.ts +27 -8
  46. package/dist/toast/item/toast-item.js +47 -11
  47. package/dist/toast/root/toast-root.d.ts +24 -1
  48. package/dist/toast/root/toast-root.js +54 -0
  49. package/dist/tour/root/tour-root.d.ts +34 -0
  50. package/dist/tour/root/tour-root.js +92 -3
  51. package/dist/tour/stop/tour-stop.d.ts +33 -1
  52. package/dist/tour/stop/tour-stop.js +12 -1
  53. package/lint.json +1 -1
  54. package/package.json +5 -5
  55. package/types/jsx.d.ts +667 -102
  56. package/types/vue.d.ts +148 -23
@@ -4,7 +4,7 @@ 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 { AriaForwardController, centerInView, columnStep, countLabel, customElement, DelayController, DisabledCascadeController, drawAutofill, endInSight, formatWords, HelixFilterEvent, HelixFormElement, HelixLoadMoreEvent, hostAria, inlineStep, MixinCollection, MixinControllerValue, MixinField, markMatches, OptionWindowController, onApple, pageRows, rangeAdd, reportValues, requestChange, scrollItemIntoView, selectsAll, Typeahead, toggleEvery, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, centerInView, chosenValues, columnStep, countLabel, customElement, DelayController, DisabledCascadeController, drawAutofill, endInSight, formatWords, HelixFilterEvent, HelixFormElement, HelixLoadMoreEvent, hostAria, inlineStep, MixinCollection, MixinControllerValue, MixinField, markMatches, OptionWindowController, onApple, pageRows, rangeAdd, reportValues, requestChange, scrollItemIntoView, selectsAll, Typeahead, toggleEvery, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { magnifyingGlass } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
@@ -326,7 +326,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
326
326
  return this.entries.filter((option) => !option.hidden);
327
327
  }
328
328
  get values() {
329
- return this.value === '' ? [] : this.value.split(',');
329
+ return chosenValues(this.value, new Set(this.entries.map((option) => option.value)), this.multiple);
330
330
  }
331
331
  choose(value) {
332
332
  const chosen = this.values;
@@ -392,10 +392,14 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
392
392
  }
393
393
  place() {
394
394
  this.forgetRemoved();
395
- const needle = this.filter.trim().toLowerCase();
395
+ const typed = this.filter.trim();
396
+ const needle = typed.toLowerCase();
397
+ const match = this.filterMatch;
396
398
  const chosen = new Set(this.values);
397
399
  for (const option of this.entries) {
398
- option.hidden = needle !== '' && !option.text.toLowerCase().includes(needle);
400
+ option.hidden =
401
+ needle !== '' &&
402
+ !(match ? match(option.text, typed) : option.text.toLowerCase().includes(needle));
399
403
  option.selected = chosen.has(option.value);
400
404
  }
401
405
  for (const group of this.groups) {
@@ -425,10 +429,11 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
425
429
  return;
426
430
  }
427
431
  const present = new Set(this.entries.map((option) => option.value));
428
- const gone = this.values.filter((value) => this.seen.has(value) && !present.has(value));
432
+ const held = chosenValues(this.value, new Set([...present, ...this.seen]), this.multiple);
433
+ const gone = held.filter((value) => this.seen.has(value) && !present.has(value));
429
434
  this.seen = present;
430
435
  if (gone.length > 0) {
431
- this.value = this.values.filter((value) => !gone.includes(value)).join(',');
436
+ this.value = held.filter((value) => !gone.includes(value)).join(',');
432
437
  }
433
438
  }
434
439
  followActive(changed) {
@@ -444,7 +449,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
444
449
  }
445
450
  sync() {
446
451
  if (this.multiple) {
447
- reportValues(this.internals, this.name, this.value === '' ? [] : this.value.split(','));
452
+ reportValues(this.internals, this.name, this.values);
448
453
  }
449
454
  else {
450
455
  this.internals.setFormValue(this.value === '' ? null : this.value);
@@ -498,7 +503,7 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
498
503
  }
499
504
  }
500
505
  get currentValue() {
501
- return this.activeValue || (this.value.split(',')[0] ?? '');
506
+ return this.activeValue || (this.values[0] ?? '');
502
507
  }
503
508
  linkActive() {
504
509
  const list = this.renderRoot.querySelector(this.filterable ? '[part="search-field"]' : '[part="items"]');
@@ -666,6 +671,9 @@ __decorate([
666
671
  words,
667
672
  property({ attribute: 'filter-label' })
668
673
  ], HelixHeadlessListbox.prototype, "filterLabel", void 0);
674
+ __decorate([
675
+ property({ attribute: false })
676
+ ], HelixHeadlessListbox.prototype, "filterMatch", void 0);
669
677
  __decorate([
670
678
  property({ attribute: false })
671
679
  ], HelixHeadlessListbox.prototype, "items", void 0);
@@ -358,6 +358,10 @@ export declare class HelixHeadlessMap extends HelixHeadlessMap_base {
358
358
  /**
359
359
  * A route or area renamed, or its description changed, which the list says
360
360
  * and a slot change does not report.
361
+ *
362
+ * Not the map's own `gestures` news, which it sends from `updated` and which
363
+ * changes nothing it draws: an update asked for there ran every map's
364
+ * update twice, and Lit's development build warned about it.
361
365
  */
362
366
  private readonly handlePartChange;
363
367
  /**
@@ -90,7 +90,7 @@ let HelixHeadlessMap = class HelixHeadlessMap extends MixinViewport(HelixElement
90
90
  }
91
91
  };
92
92
  this.handlePartChange = (event) => {
93
- if (event.detail.kind !== 'place') {
93
+ if (event.detail.kind !== 'place' && event.detail.kind !== 'gestures') {
94
94
  this.requestUpdate();
95
95
  }
96
96
  };
@@ -104,7 +104,7 @@ let HelixHeadlessMenuItem = class HelixHeadlessMenuItem extends HelixElement {
104
104
  updated(changed) {
105
105
  super.updated(changed);
106
106
  if (this.partNamed('item')) {
107
- auditName(this, () => this.partNamed('item'), 'Give the row words in its default slot, or a label on the icon it holds.');
107
+ auditName(this, () => this.partNamed('item') ?? undefined, 'Give the row words in its default slot, or a label on the icon it holds.');
108
108
  }
109
109
  }
110
110
  renderDescription() {
@@ -42,8 +42,8 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
42
42
  * rows, Home and End go to the first and the last, a typed letter moves to
43
43
  * the next row whose name starts with it, Enter and Space choose, Right Arrow
44
44
  * opens a row's submenu and Left Arrow goes back, the two swapping on a
45
- * right-to-left page, and Escape closes the menu and returns focus to the
46
- * trigger. Tab closes it and moves on.
45
+ * right-to-left page. Left Arrow on the top page does nothing. Escape closes
46
+ * the menu and returns focus to the trigger. Tab closes it and moves on.
47
47
  *
48
48
  * While it is open, the surface carries the trigger's width as
49
49
  * `--floating-anchor-inline-size` and the room left below it as
@@ -93,6 +93,10 @@ declare const HelixHeadlessMenu_base: (new (...args: any[]) => import("@fusion.d
93
93
  * @cssstate drilled - A submenu's page is showing, so content that is not a row can hide with the top page.
94
94
  * @cssstate mark - A row on the current page has a checkbox or a link, so every row keeps the trailing column.
95
95
  * @cssstate open - While the menu is open.
96
+ * @cssstate placed-bottom - While the open panel sits below its trigger.
97
+ * @cssstate placed-left - While the open panel sits to the left of its trigger.
98
+ * @cssstate placed-right - While the open panel sits to the right of its trigger.
99
+ * @cssstate placed-top - While the open panel sits above its trigger.
96
100
  * @cssstate start - A row on the current page has content in its leading column, so every row keeps that column.
97
101
  */
98
102
  export declare class HelixHeadlessMenu extends HelixHeadlessMenu_base {
@@ -4,7 +4,7 @@ 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 { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, focusTarget, HelixElement, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, PopoverController, scrollItemIntoView, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, anchorElement, customElement, DrilldownController, deepActiveElement, deepContains, focusTarget, HelixElement, inlineStep, KeyboardNavigationController, linkDescription, MixinCollection, MixinControllerOpen, PopoverController, scrollItemIntoView, sideOf, slottedWords, tellDisclosure, typeaheadBy, unlinkDescription, words, } from '@fusion.dev/helix-core';
8
8
  import { caretLeft } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
@@ -76,6 +76,13 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
76
76
  boundary: () => this,
77
77
  offset: () => this.offset,
78
78
  onDismiss: () => this.hide(),
79
+ onPlace: (placement) => {
80
+ const side = sideOf(placement);
81
+ this.toggleState('placed-bottom', side === 'bottom');
82
+ this.toggleState('placed-left', side === 'left');
83
+ this.toggleState('placed-right', side === 'right');
84
+ this.toggleState('placed-top', side === 'top');
85
+ },
79
86
  open: () => this.open,
80
87
  placement: () => this.placement,
81
88
  surface: () => this.partNamed('surface'),
@@ -145,7 +152,7 @@ let HelixHeadlessMenu = class HelixHeadlessMenu extends MixinCollection(MixinCon
145
152
  this.enter(items[at]);
146
153
  }
147
154
  else if (along === -1) {
148
- if (this.flies && this.keyPage === null) {
155
+ if (this.flies ? this.keyPage === null : this.drilldown.atRoot) {
149
156
  return;
150
157
  }
151
158
  this.leave();
@@ -25,9 +25,9 @@ declare const HelixHeadlessNumberInput_base: (new (...args: any[]) => import("@f
25
25
  * The locale's group separators are read in every field, so `1,234` is a
26
26
  * number in `en-US`.
27
27
  *
28
- * The field and its two buttons sit in a `role="group"`, and the field carries
29
- * `role-label`, "number field" in English, as its `aria-roledescription`, as
30
- * React Aria's NumberField does. On iOS and iPadOS the field is a plain text
28
+ * The field and its two buttons sit in a `role="group"` named by the label,
29
+ * and the field carries `role-label`, "number field" in English, as its
30
+ * `aria-roledescription`, as React Aria's NumberField does. On iOS and iPadOS the field is a plain text
31
31
  * field, with no `role="spinbutton"`, which VoiceOver reads as a slider you
32
32
  * cannot type in, and no role description, so VoiceOver says the field is
33
33
  * required. React Aria's NumberField does the same.
@@ -492,8 +492,11 @@ export declare class HelixHeadlessNumberInput extends HelixHeadlessNumberInput_b
492
492
  protected renderIncrementIcon(): TemplateResult;
493
493
  protected render(): TemplateResult;
494
494
  /**
495
- * The value as a number, or `NaN` while the control is empty.
495
+ * The value as a number, or `NaN` while the control is empty or does not
496
+ * hold a number.
496
497
  *
498
+ * Read in the locale and the format, as the form reads it, so `1,5` typed
499
+ * in a locale with a decimal comma is 1.5 here while the form holds 1.5.
497
500
  * `NaN` and not zero, which is the trap the platform already avoids: `Number('')`
498
501
  * is zero, so an empty control would report a number it does not hold and a
499
502
  * form would submit it.
@@ -4,7 +4,7 @@ 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 { AriaForwardController, appleTouch, valueAsNumber as asNumber, customElement, formatNumber, formatWords, numberAsValue as fromNumber, HelixFormElement, holdCaret, MixinControllerValue, MixinField, parseNumber, restoreValue, submitImplicitly, words, wordsLocale, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, appleTouch, customElement, formatNumber, formatWords, numberAsValue as fromNumber, HelixFormElement, holdCaret, MixinControllerValue, MixinField, parseNumber, restoreValue, submitImplicitly, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { minus, plus } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
@@ -410,7 +410,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
410
410
  const atMin = parsed !== null && this.min !== undefined && parsed <= this.min;
411
411
  const atMax = parsed !== null && this.max !== undefined && parsed >= this.max;
412
412
  return html `${this.renderLabel()}
413
- <div part="field" role="group">
413
+ <div part="field" role="group" aria-labelledby=${this.labeled ? this.labelId : nothing}>
414
414
  <button
415
415
  part="decrement"
416
416
  type="button"
@@ -484,7 +484,7 @@ let HelixHeadlessNumberInput = class HelixHeadlessNumberInput extends MixinContr
484
484
  ${this.renderHint()}${this.renderFeedback()}`;
485
485
  }
486
486
  get valueAsNumber() {
487
- return asNumber(this.value);
487
+ return this.numeric ?? Number.NaN;
488
488
  }
489
489
  set valueAsNumber(count) {
490
490
  this.value = fromNumber(count);
@@ -51,7 +51,7 @@ let HelixHeadlessProgress = class HelixHeadlessProgress extends HelixElement {
51
51
  this.internals.ariaValueMax = String(max);
52
52
  this.internals.ariaValueNow = this.indeterminate
53
53
  ? null
54
- : String(Math.min(Math.max(this.value || min, min), max));
54
+ : String(Math.min(Math.max(Number.isNaN(this.value) ? min : this.value, min), max));
55
55
  const shown = this.valueText || slottedWords(this, ['']);
56
56
  this.internals.ariaValueText = this.indeterminate ? shown || null : shown || this.text;
57
57
  this.toggleState('complete', this.complete);
@@ -19,7 +19,9 @@ declare const HelixHeadlessSelect_base: (new (...args: any[]) => import("@fusion
19
19
  * whole set.
20
20
  *
21
21
  * `Select` owns the value, passes it down to the listbox, and takes changes
22
- * back, so the value lives in one place.
22
+ * back, so the value lives in one place. With `multiple`, the value is the
23
+ * chosen values joined with commas, and the form receives one entry for each.
24
+ * An option's value may hold a comma, and it is read as one value.
23
25
  *
24
26
  * Set `valueContent` to draw the trigger's content from the chosen options,
25
27
  * and several chosen options are written as a list in the reader's language.
@@ -291,6 +293,11 @@ export declare class HelixHeadlessSelect extends HelixHeadlessSelect_base {
291
293
  private get display();
292
294
  /** The chosen options, in the order they appear. None on a server or while hydrating, and none for `items`. */
293
295
  private get chosenOptions();
296
+ /**
297
+ * The chosen values, read against the options' values, so an option whose
298
+ * value holds a comma is one value. See `chosenValues`.
299
+ */
300
+ private get chosen();
294
301
  /** `items` as the list reads them, built once per array. */
295
302
  private data;
296
303
  /**
@@ -4,7 +4,7 @@ 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 { AriaForwardController, afterLinkFollowed, customElement, dataOptions, drawAutofill, drawStatus, HelixFormElement, hostAria, hostWords, isRtl, MixinControllerOpen, MixinControllerValue, MixinField, onApple, PopoverController, reportValues, StatusController, sideOf, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, afterLinkFollowed, chosenValues, customElement, dataOptions, drawAutofill, drawStatus, HelixFormElement, hostAria, hostWords, isRtl, MixinControllerOpen, MixinControllerValue, MixinField, onApple, PopoverController, reportValues, StatusController, sideOf, typesCharacter, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { caretDown } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
@@ -173,7 +173,7 @@ let HelixHeadlessSelect = class HelixHeadlessSelect extends MixinControllerOpen(
173
173
  return this.partNamed('listbox');
174
174
  }
175
175
  get display() {
176
- const chosen = new Set(this.value === '' ? [] : this.value.split(','));
176
+ const chosen = new Set(this.chosen);
177
177
  const words = this.entries.filter((option) => chosen.has(option.value)).map((one) => one.text);
178
178
  if (words.length > 0) {
179
179
  return new Intl.ListFormat(wordsLocale(this), { style: 'short', type: 'unit' }).format(words);
@@ -184,9 +184,12 @@ let HelixHeadlessSelect = class HelixHeadlessSelect extends MixinControllerOpen(
184
184
  if (this.items) {
185
185
  return [];
186
186
  }
187
- const chosen = new Set(this.value === '' ? [] : this.value.split(','));
187
+ const chosen = new Set(this.chosen);
188
188
  return optionsAmong(this.knownChildren).filter((option) => chosen.has(option.value));
189
189
  }
190
+ get chosen() {
191
+ return chosenValues(this.value, new Set(this.entries.map((option) => option.value)), this.multiple);
192
+ }
190
193
  get entries() {
191
194
  if (!this.items) {
192
195
  return optionsAmong(this.knownChildren);
@@ -257,7 +260,7 @@ let HelixHeadlessSelect = class HelixHeadlessSelect extends MixinControllerOpen(
257
260
  }
258
261
  }
259
262
  const present = new Set(options.map((option) => option.value));
260
- const held = this.value === '' ? [] : this.value.split(',');
263
+ const held = chosenValues(this.value, new Set([...present, ...this.seen]), this.multiple);
261
264
  const gone = held.filter((value) => this.seen.has(value) && !present.has(value));
262
265
  this.seen = present;
263
266
  if (gone.length > 0) {
@@ -266,7 +269,7 @@ let HelixHeadlessSelect = class HelixHeadlessSelect extends MixinControllerOpen(
266
269
  }
267
270
  sync() {
268
271
  if (this.multiple) {
269
- reportValues(this.internals, this.name, this.value === '' ? [] : this.value.split(','));
272
+ reportValues(this.internals, this.name, this.chosen);
270
273
  }
271
274
  else {
272
275
  this.internals.setFormValue(this.value === '' ? null : this.value);
@@ -41,7 +41,8 @@ export declare class HelixHeadlessSkeleton extends HelixElement {
41
41
  * What is loading, read out as the placeholder's name, such as "Loading
42
42
  * profile".
43
43
  *
44
- * Absent hides the placeholder from screen readers.
44
+ * Absent hides the placeholder from screen readers. Around content, the name
45
+ * is read while `loading` is set, and the loaded content is read as it is.
45
46
  */
46
47
  label: string;
47
48
  /**
@@ -22,7 +22,7 @@ let HelixHeadlessSkeleton = class HelixHeadlessSkeleton extends HelixElement {
22
22
  const wraps = this.wraps;
23
23
  this.toggleState('wrapping', wraps);
24
24
  this.internals.ariaBusy = this.loading ? 'true' : null;
25
- if (this.label) {
25
+ if (this.label && (this.loading || !wraps)) {
26
26
  hostAria(this.internals, { label: this.label, role: 'progressbar' });
27
27
  this.internals.ariaHidden = null;
28
28
  }
@@ -34,14 +34,16 @@ let HelixHeadlessSteps = class HelixHeadlessSteps extends MixinControllerActive(
34
34
  wrap: false,
35
35
  });
36
36
  this.handleSelection = (event) => {
37
- if (event.defaultPrevented || !this.steps.includes(event.target)) {
37
+ if (!this.steps.includes(event.target)) {
38
38
  return;
39
39
  }
40
- const index = this.steps.indexOf(event.target);
41
- if (!this.reachable(index) || !this.valid(index)) {
42
- return;
43
- }
44
- this.requestActive(event.detail.value);
40
+ queueMicrotask(() => {
41
+ const index = this.steps.indexOf(event.target);
42
+ if (event.defaultPrevented || !this.reachable(index) || !this.valid(index)) {
43
+ return;
44
+ }
45
+ this.requestActive(event.detail.value);
46
+ });
45
47
  };
46
48
  this.handleSlotChange = () => {
47
49
  this.sync();
@@ -27,6 +27,10 @@ import '../../button/index.js';
27
27
  * region. The current stage's button carries `aria-current="step"`, and only
28
28
  * the button, so the state is said once, on the control.
29
29
  *
30
+ * A stage's `content`, while it holds anything, is a group named by the
31
+ * stage's name, so a screen reader moving into it hears which stage it
32
+ * belongs to.
33
+ *
30
34
  * @since 0.1.0
31
35
  * @status stable
32
36
  * @aliases Stage
@@ -105,6 +109,11 @@ export declare class HelixHeadlessStep extends HelixElement {
105
109
  * The value the step sends in `helix-select`.
106
110
  */
107
111
  value: string;
112
+ /** Whether anything is slotted into `content`, which makes it a named group. */
113
+ private holding;
114
+ /** Reads whether the `content` slot holds anything. */
115
+ private readonly readContent;
116
+ protected firstUpdated(changed: PropertyValues): void;
108
117
  /** Says a stage was finished, from the first time one is. */
109
118
  private readonly status;
110
119
  /**
@@ -7,7 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { customElement, drawStatus, formatWords, HelixElement, HelixSelectEvent, hostAria, StatusController, slottedWords, words, } from '@fusion.dev/helix-core';
8
8
  import { check, circle, record } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
- import { property } from 'lit/decorators.js';
10
+ import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { html } from 'lit/static-html.js';
13
13
  import '../../button/index.js';
@@ -23,9 +23,18 @@ let HelixHeadlessStep = class HelixHeadlessStep extends HelixElement {
23
23
  this.locked = false;
24
24
  this.optional = false;
25
25
  this.value = '';
26
+ this.holding = false;
27
+ this.readContent = () => {
28
+ const slot = this.renderRoot.querySelector('slot[name="content"]');
29
+ this.holding = (slot?.assignedNodes() ?? []).some((node) => node.nodeType === Node.ELEMENT_NODE || (node.textContent ?? '').trim() !== '');
30
+ };
26
31
  this.status = new StatusController(this);
27
32
  }
28
33
  static { this.words = 'helix-step'; }
34
+ firstUpdated(changed) {
35
+ super.firstUpdated(changed);
36
+ this.readContent();
37
+ }
29
38
  focus(options) {
30
39
  this.partNamed('label')?.focus(options);
31
40
  }
@@ -60,6 +69,7 @@ let HelixHeadlessStep = class HelixHeadlessStep extends HelixElement {
60
69
  }
61
70
  renderLabel() {
62
71
  return html `<${this.tag('button')}
72
+ id="label"
63
73
  part="label"
64
74
  exportparts="button: label-button, live: label-live, spinner: label-spinner, text: label-text"
65
75
  emphasis="ghost"
@@ -79,7 +89,11 @@ let HelixHeadlessStep = class HelixHeadlessStep extends HelixElement {
79
89
  ${this.renderLabel()}
80
90
  <span part="description"><slot name="description"></slot></span>
81
91
  </span>
82
- <div part="content"><slot name="content"></slot></div>${drawStatus(this.status)}`;
92
+ <div
93
+ part="content"
94
+ role=${this.holding ? 'group' : nothing}
95
+ aria-labelledby=${this.holding ? 'label' : nothing}
96
+ ><slot name="content" @slotchange=${this.readContent}></slot></div>${drawStatus(this.status)}`;
83
97
  }
84
98
  };
85
99
  __decorate([
@@ -111,6 +125,9 @@ __decorate([
111
125
  __decorate([
112
126
  property()
113
127
  ], HelixHeadlessStep.prototype, "value", void 0);
128
+ __decorate([
129
+ state()
130
+ ], HelixHeadlessStep.prototype, "holding", void 0);
114
131
  HelixHeadlessStep = __decorate([
115
132
  customElement('helix-headless-step')
116
133
  ], HelixHeadlessStep);
@@ -15,15 +15,24 @@ import { type PropertyValues, type TemplateResult } from 'lit';
15
15
  * the tab list lands on the panel's first link or control where there is one,
16
16
  * and on the panel itself where there is none.
17
17
  *
18
+ * **Set `lazy` for a panel that is expensive to draw.** Its content waits in a
19
+ * `<template>` inside it, and goes into the panel the first time the panel is
20
+ * shown, so it costs nothing until a reader opens that tab. Once in, it stays.
21
+ * The panel sends `helix-load` at the same moment, for content your code
22
+ * renders itself.
23
+ *
18
24
  * @since 0.1.0
19
25
  * @status stable
20
26
  * @aliases TabPanel
21
27
  * @documentation https://fusion.dev/helix/components/tabs
22
28
  *
23
- * @slot - The panel's content.
29
+ * @slot - The panel's content. Under `lazy`, a `<template>` holding it.
30
+ *
31
+ * @event {HelixLoadEvent} helix-load - Fired the first time a `lazy` panel is shown, once its template's content is in.
24
32
  *
25
33
  * @csspart panel - The panel container, which takes the focus while it holds nothing focusable.
26
34
  *
35
+ * @cssstate loaded - Once a `lazy` panel has been shown and its content is in.
27
36
  * @cssstate selected - While its tab is the one selected.
28
37
  */
29
38
  export declare class HelixHeadlessTabPanel extends HelixElement {
@@ -40,6 +49,18 @@ export declare class HelixHeadlessTabPanel extends HelixElement {
40
49
  * the pattern names one anyway.
41
50
  */
42
51
  label: string;
52
+ /**
53
+ * Whether the panel's content waits in a `<template>` until the panel is
54
+ * first shown.
55
+ */
56
+ lazy: boolean;
57
+ /** Whether a `lazy` panel has put its content in. */
58
+ private loaded;
59
+ /**
60
+ * Puts a `lazy` panel's content in the first time the panel is shown: the
61
+ * content of each `<template>` it holds, in place of the template.
62
+ */
63
+ private load;
43
64
  /**
44
65
  * Whether this panel is the visible one.
45
66
  */
@@ -4,13 +4,15 @@ 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, focusableElements, HelixElement, hostAria } from '@fusion.dev/helix-core';
7
+ import { customElement, focusableElements, HelixElement, HelixLoadEvent, hostAria, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
10
  let HelixHeadlessTabPanel = class HelixHeadlessTabPanel extends HelixElement {
11
11
  constructor() {
12
12
  super(...arguments);
13
13
  this.label = '';
14
+ this.lazy = false;
15
+ this.loaded = false;
14
16
  this.selected = false;
15
17
  this.value = '';
16
18
  this.holdsStop = false;
@@ -21,6 +23,19 @@ let HelixHeadlessTabPanel = class HelixHeadlessTabPanel extends HelixElement {
21
23
  this.holdsStop = this.selected && focusableElements(this).length > 0;
22
24
  };
23
25
  }
26
+ load() {
27
+ if (!this.lazy || !this.selected || this.loaded) {
28
+ return;
29
+ }
30
+ this.loaded = true;
31
+ for (const template of [...this.children]) {
32
+ if (template instanceof HTMLTemplateElement) {
33
+ template.replaceWith(document.importNode(template.content, true));
34
+ }
35
+ }
36
+ this.toggleState('loaded', true);
37
+ this.dispatchEvent(new HelixLoadEvent());
38
+ }
24
39
  connectedCallback() {
25
40
  super.connectedCallback();
26
41
  this.watcher?.observe(this, {
@@ -36,6 +51,9 @@ let HelixHeadlessTabPanel = class HelixHeadlessTabPanel extends HelixElement {
36
51
  }
37
52
  updated(changed) {
38
53
  super.updated(changed);
54
+ if (changed.has('selected') || changed.has('lazy')) {
55
+ this.load();
56
+ }
39
57
  if (changed.has('selected')) {
40
58
  this.readStops();
41
59
  }
@@ -58,6 +76,9 @@ let HelixHeadlessTabPanel = class HelixHeadlessTabPanel extends HelixElement {
58
76
  __decorate([
59
77
  property({ attribute: false })
60
78
  ], HelixHeadlessTabPanel.prototype, "label", void 0);
79
+ __decorate([
80
+ property({ type: Boolean })
81
+ ], HelixHeadlessTabPanel.prototype, "lazy", void 0);
61
82
  __decorate([
62
83
  property({ type: Boolean })
63
84
  ], HelixHeadlessTabPanel.prototype, "selected", void 0);
@@ -46,9 +46,9 @@ declare const HelixHeadlessTabs_base: (new (...args: any[]) => import("@fusion.d
46
46
  * | Enter, Space | Selects the tab with focus, or follows its link. |
47
47
  * | Tab | Leaves the list for the selected panel. |
48
48
  *
49
- * Removing the selected tab selects the first enabled tab that is left, and
50
- * `active` says so, with `helix-activate`. Where the removed tab had focus,
51
- * focus moves to the tab now selected.
49
+ * Removing the selected tab selects the enabled tab beside it, the next one
50
+ * or else the one before, and `active` says so, with `helix-activate`. Where
51
+ * the removed tab had focus, focus moves to the tab now selected.
52
52
  *
53
53
  * **More tabs than fit.** With `scroll-controls`, a strip that scrolls shows a
54
54
  * button at each end it can scroll toward, as Web Awesome's tab group does,
@@ -74,6 +74,8 @@ declare const HelixHeadlessTabs_base: (new (...args: any[]) => import("@fusion.d
74
74
  * @slot - The tabs, as direct children of this element.
75
75
  * @slot indicator - A mark of your own, drawn inside the `indicator` part, where `--indicator-x`, `--indicator-y`, `--indicator-inline-size` and `--indicator-block-size` reach it.
76
76
  * @slot panels - The panels, as direct children of this element. Panels inside a wrapper are not paired with tabs, because pairing reads only the direct children.
77
+ * @slot scroll-end-icon - Replaces the glyph on the button that scrolls the strip toward its end.
78
+ * @slot scroll-start-icon - Replaces the glyph on the button that scrolls the strip toward its start.
77
79
  *
78
80
  * @event {HelixActivateEvent} helix-activate - Fired after the active tab changes, so the element already holds the new value.
79
81
  * @event {HelixBeforeActivateEvent} helix-before-activate - Fired before the active tab changes, and cancelable. Canceling it is the only way to block a change.
@@ -85,7 +87,9 @@ declare const HelixHeadlessTabs_base: (new (...args: any[]) => import("@fusion.d
85
87
  * and animate yourself.
86
88
  * @csspart panels - The panel container.
87
89
  * @csspart scroll-end - The button that scrolls the strip toward its end, shown under `scroll-controls` while there is more that way.
90
+ * @csspart scroll-end-mark - The glyph on that button, where none is slotted.
88
91
  * @csspart scroll-start - The button that scrolls the strip toward its start, shown under `scroll-controls` while there is more that way.
92
+ * @csspart scroll-start-mark - The glyph on that button, where none is slotted.
89
93
  * @csspart tabs - The tab list.
90
94
  */
91
95
  export declare class HelixHeadlessTabs extends HelixHeadlessTabs_base {
@@ -169,6 +173,12 @@ export declare class HelixHeadlessTabs extends HelixHeadlessTabs_base {
169
173
  private scrollStrip;
170
174
  /** The values of the tabs the last pairing read, to tell a removed tab from one not yet here. */
171
175
  private held;
176
+ /**
177
+ * The enabled tab nearest to where a removed one stood: the next ones
178
+ * nearest first, then the ones before it nearest first. The same order a
179
+ * dismissed tab hands focus in.
180
+ */
181
+ private beside;
172
182
  /** Pushes the current value down to whichever tab and panel own it. */
173
183
  private sync;
174
184
  /** The tab that last had focus, so its removal can hand focus on. */
@@ -205,10 +215,16 @@ export declare class HelixHeadlessTabs extends HelixHeadlessTabs_base {
205
215
  private readonly handleSlotChange;
206
216
  protected updated(changed: PropertyValues): void;
207
217
  /**
208
- * One of the two scroll buttons, pointing the way it scrolls. A caret along
209
- * the line is mirrored on a right-to-left page, and one down a column is not.
218
+ * One of the two scroll buttons, with the glyph pointing the way it scrolls.
210
219
  */
211
220
  private renderScroll;
221
+ /**
222
+ * The glyph on a scroll button, behind a seam: slot fallback, so a consumer
223
+ * replaces it by slotting, and a method, so a tier above replaces it by
224
+ * overriding. A caret along the line is mirrored on a right-to-left page, and
225
+ * one down a column is not.
226
+ */
227
+ protected renderScrollIcon(toward: 1 | -1): TemplateResult;
212
228
  protected render(): TemplateResult;
213
229
  }
214
230
  declare global {