@fusion.dev/helix-headless 0.2.0 → 0.3.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 (127) hide show
  1. package/custom-elements.json +234 -234
  2. package/dist/accordion/item/accordion-item.js +0 -108
  3. package/dist/accordion/root/accordion-root.js +0 -33
  4. package/dist/announcement/root/announcement-root.js +1 -76
  5. package/dist/app-header/root/app-header-root.js +0 -84
  6. package/dist/avatar/group/avatar-group.js +0 -77
  7. package/dist/avatar/root/avatar-root.js +0 -72
  8. package/dist/badge/root/badge-root.js +0 -67
  9. package/dist/bar-chart/root/bar-chart-root.js +3 -263
  10. package/dist/blockquote/root/blockquote-root.js +0 -46
  11. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
  12. package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
  13. package/dist/button/root/button-root.js +0 -215
  14. package/dist/button-group/root/button-group-root.js +0 -30
  15. package/dist/callout/root/callout-root.js +0 -52
  16. package/dist/card/root/card-root.js +0 -41
  17. package/dist/carousel/root/carousel-root.d.ts +2 -2
  18. package/dist/carousel/root/carousel-root.js +2 -362
  19. package/dist/chat/composer/chat-composer.js +0 -131
  20. package/dist/chat/message/chat-message.js +0 -137
  21. package/dist/chat/root/chat-root.js +0 -193
  22. package/dist/checkbox/root/checkbox-root.js +0 -113
  23. package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
  24. package/dist/code/root/code-root.js +0 -26
  25. package/dist/code-block/root/code-block-root.js +0 -100
  26. package/dist/color-input/root/color-input-root.js +0 -217
  27. package/dist/color-picker/color.js +0 -69
  28. package/dist/color-picker/root/color-picker-root.js +0 -272
  29. package/dist/combobox/root/combobox-root.js +0 -428
  30. package/dist/command/root/command-root.js +0 -177
  31. package/dist/copy-button/root/copy-button-root.js +0 -134
  32. package/dist/data-table/root/data-table-root.js +0 -336
  33. package/dist/data-table/root/order.js +0 -100
  34. package/dist/date-input/root/date-input-root.js +1 -398
  35. package/dist/date-picker/root/date-picker-root.js +0 -358
  36. package/dist/dialog/root/dialog-root.js +0 -189
  37. package/dist/divider/root/divider-root.js +0 -71
  38. package/dist/donut-chart/root/donut-chart-root.js +0 -214
  39. package/dist/drawer/root/drawer-root.js +0 -116
  40. package/dist/drilldown/item/drilldown-item.js +0 -82
  41. package/dist/drilldown/root/drilldown-root.js +0 -91
  42. package/dist/element-events.generated.js +0 -5
  43. package/dist/empty/root/empty-root.js +0 -35
  44. package/dist/file-input/root/file-input-root.js +0 -232
  45. package/dist/format-date/root/format-date-root.js +0 -95
  46. package/dist/heatmap/root/buckets.js +0 -57
  47. package/dist/heatmap/root/heatmap-root.js +0 -313
  48. package/dist/icon/root/icon-root.js +0 -48
  49. package/dist/icon-button/root/icon-button-root.js +0 -30
  50. package/dist/image/root/image-root.js +0 -174
  51. package/dist/index.js +0 -9
  52. package/dist/input/root/input-root.js +0 -228
  53. package/dist/kbd/root/kbd-root.js +0 -117
  54. package/dist/legend/root/legend-root.js +0 -73
  55. package/dist/line-chart/root/line-chart-root.js +0 -172
  56. package/dist/list/item/list-item.js +0 -92
  57. package/dist/list/root/list-root.js +0 -26
  58. package/dist/listbox/root/listbox-root.js +2 -429
  59. package/dist/loader/root/loader-root.js +0 -40
  60. package/dist/megamenu/root/megamenu-root.js +0 -284
  61. package/dist/menu/item/menu-item.js +1 -221
  62. package/dist/menu/root/menu-root.js +1 -171
  63. package/dist/meter/root/meter-root.js +1 -152
  64. package/dist/navigation/root/navigation-root.js +0 -56
  65. package/dist/notifications/notification/notification.js +0 -159
  66. package/dist/notifications/root/notifications-root.js +0 -148
  67. package/dist/number-input/root/number-input-root.js +1 -263
  68. package/dist/option/group/option-group.js +0 -26
  69. package/dist/option/root/option-root.js +0 -157
  70. package/dist/outline/root/outline-root.js +0 -292
  71. package/dist/pagination/root/pagination-root.js +0 -361
  72. package/dist/pagination/size/pagination-size.js +0 -104
  73. package/dist/pagination/summary/pagination-summary.js +0 -90
  74. package/dist/pin-input/root/pin-input-root.js +0 -276
  75. package/dist/popover/root/popover-root.js +0 -103
  76. package/dist/progress/root/progress-root.js +1 -133
  77. package/dist/qr-code/root/qr-code-root.js +0 -96
  78. package/dist/quote/root/quote-root.js +0 -25
  79. package/dist/radio/group/radio-group.js +0 -81
  80. package/dist/radio/root/radio-root.js +0 -85
  81. package/dist/rating/root/rating-root.js +0 -220
  82. package/dist/scroll-area/root/scroll-area-root.js +0 -100
  83. package/dist/select/root/select-root.js +2 -218
  84. package/dist/skeleton/root/skeleton-root.js +0 -20
  85. package/dist/slider/root/slider-root.js +1 -267
  86. package/dist/sparkline/root/sparkline-root.js +3 -164
  87. package/dist/speed-dial/item/speed-dial-item.js +0 -52
  88. package/dist/speed-dial/root/speed-dial-root.js +1 -116
  89. package/dist/splitter/root/splitter-root.js +0 -489
  90. package/dist/stat/group/stat-group.js +0 -18
  91. package/dist/stat/root/stat-root.js +0 -23
  92. package/dist/steps/root/steps-root.js +0 -62
  93. package/dist/steps/step/step.js +0 -131
  94. package/dist/switch/root/switch-root.js +0 -80
  95. package/dist/table/cell/table-cell.js +0 -23
  96. package/dist/table/column/table-column.js +0 -158
  97. package/dist/table/root/table-root.js +0 -82
  98. package/dist/table/row/table-row.js +0 -21
  99. package/dist/tabs/panel/tab-panel.js +0 -49
  100. package/dist/tabs/root/tabs-root.js +0 -98
  101. package/dist/tabs/tab/tab.js +0 -59
  102. package/dist/tag/root/tag-root.js +0 -31
  103. package/dist/textarea/root/textarea-root.js +0 -162
  104. package/dist/time-input/root/time-input-root.js +0 -283
  105. package/dist/time-picker/root/time-picker-root.js +0 -326
  106. package/dist/timeline/item/timeline-item.js +0 -66
  107. package/dist/timeline/root/timeline-root.js +0 -41
  108. package/dist/toast/item/toast-item.js +0 -165
  109. package/dist/toast/root/toast-root.js +0 -51
  110. package/dist/toggle/group/toggle-group.js +0 -150
  111. package/dist/toggle/root/toggle-root.js +0 -78
  112. package/dist/toolbar/root/toolbar-root.js +0 -116
  113. package/dist/tooltip/root/tooltip-root.js +0 -427
  114. package/dist/tour/root/tour-root.js +0 -130
  115. package/dist/tour/stop/tour-stop.js +1 -127
  116. package/dist/tree/item/tree-item.js +0 -198
  117. package/dist/tree/root/tree-root.js +0 -97
  118. package/dist/trend/root/trend-root.js +0 -53
  119. package/dist/video/root/video-root.d.ts +8 -12
  120. package/dist/video/root/video-root.js +5 -756
  121. package/dist/video-playlist/item/video-playlist-item.js +1 -90
  122. package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
  123. package/dist/video-playlist/root/video-playlist-root.js +0 -113
  124. package/dist/virtual-list/root/virtual-list-root.js +0 -105
  125. package/jsx.d.ts +1408 -2576
  126. package/package.json +6 -6
  127. package/vue.d.ts +1 -1
@@ -11,43 +11,9 @@ import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { HelixHeadlessOptionGroup } from '../../option/group/option-group.js';
13
13
  import { HelixHeadlessOption } from '../../option/root/option-root.js';
14
- /** The keys that move within a list, which is every key that moves at all. */
15
14
  const MOVES = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home']);
16
- /**
17
- * The ranges each filtering list has matched, so the union can be rebuilt when
18
- * any one of them changes.
19
- *
20
- * `CSS.highlights` is one registry for the whole document, keyed by name, so a
21
- * second list writing under the same name would erase the first one's marks.
22
- * Keyed by element and rebuilt from every entry, two filtered lists on a page
23
- * both stay marked. A `Map` rather than a `WeakMap` because the union has to be
24
- * walked and a `WeakMap` cannot be, which is why a list takes itself out on
25
- * disconnect.
26
- */
27
15
  const matched = new Map();
28
- /** What the marks are called in `CSS.highlights`, and in `preview.css`. */
29
16
  const HIGHLIGHT = 'helix-filter-match';
30
- /*
31
- * Marks what the filter matched, without touching what it matched it in.
32
- *
33
- * The words are the consumer's own light DOM, slotted through as many as two
34
- * shadow roots, and wrapping the matched part in a `<mark>` would mean this
35
- * element rewriting their content: markup they wrote would come back changed.
36
- * The Custom Highlight API paints a range instead. Nothing is inserted and
37
- * nothing is removed, so an option holding a link or an icon is marked as
38
- * readily as one holding a bare word.
39
- *
40
- * The rule that paints it lives in `preview.css` rather than in a component's
41
- * styles, which is not a choice either: a highlight is styled from the tree its
42
- * text lives in, and that text lives in the consumer's document.
43
- */
44
- /**
45
- * Whether this environment can paint a highlight at all.
46
- *
47
- * Asked before the ranges are built rather than after: a server, a test
48
- * environment and an older browser all take that path, and walking every shown
49
- * option's text nodes for an API that is not there is work with no output.
50
- */
51
17
  const paintable = () => typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined';
52
18
  const paintMatches = () => {
53
19
  if (!paintable()) {
@@ -60,14 +26,6 @@ const paintMatches = () => {
60
26
  }
61
27
  CSS.highlights.set(HIGHLIGHT, new Highlight(...all));
62
28
  };
63
- /**
64
- * Where a needle sits inside an element, as a range over its text nodes.
65
- *
66
- * Walked rather than measured off `textContent`, because the words may be split
67
- * across several nodes and wrapped in elements of the consumer's, and a match
68
- * can begin in one node and end in another. The offsets are mapped back onto
69
- * the nodes they came from, so the range covers the real text either way.
70
- */
71
29
  const rangeFor = (root, needle) => {
72
30
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
73
31
  const nodes = [];
@@ -97,159 +55,23 @@ const rangeFor = (root, needle) => {
97
55
  }
98
56
  return [];
99
57
  };
100
- /**
101
- * @summary A list of options, one or several of which are the answer.
102
- *
103
- * @description
104
- * **The listbox is the control and the options are its vocabulary.** A Select
105
- * is this with a trigger in front of it, a Combobox is this with a text input,
106
- * and a command palette is this again: they differ in what opens the list and
107
- * what filters it, not in what the list is. Building it as its own family is
108
- * what stops each of those reimplementing the keyboard and the filtering.
109
- *
110
- * **`value` is comma separated when `multiple` is set**, the way
111
- * `Checkbox.Group`'s is. It stays a string because that is what a form submits
112
- * and what `MixinControllerValue` holds; an array property would be a second
113
- * shape for the same fact, and the two would disagree the first time one was
114
- * set without the other.
115
- *
116
- * **Chosen and active are two different things.** Chosen is the answer; active
117
- * is where the keyboard is. In a single-select list they move together, which
118
- * is the radio group pattern and is what a reader expects from arrow keys. With
119
- * `multiple` they come apart: the arrows move without choosing and Space
120
- * chooses, because a reader passing over four options on the way to the fifth
121
- * did not mean to pick all five.
122
- *
123
- * **This does not use `KeyboardNavigationController`, and the reason is where
124
- * focus lives rather than whether moving chooses.** The controller already
125
- * separates those, taking `origin: 'focus' | 'selection'`. What it cannot do is
126
- * leave focus alone: `NavigableItem.focusItem()` moves DOM focus to the item,
127
- * its model being a roving tab stop. A filterable list cannot have that, focus
128
- * having to stay in the field or typing stops, so what moves is a virtual
129
- * cursor and `aria-activedescendant` is what says where it is. No flag
130
- * reconciles the two.
131
- *
132
- * **Filtering hides rather than removes.** An option the filter has hidden is
133
- * still the consumer's element in the consumer's order, so a filter that clears
134
- * puts the list back exactly as it was with nothing re-rendered. What counts as
135
- * a match is a plain case-insensitive substring, and a consumer wanting more
136
- * hides options itself: `filter` is a convenience over the same `hidden` the
137
- * component uses.
138
- *
139
- * @since 0.1.0
140
- * @status experimental
141
- * @category forms
142
- * @aliases Options, Select list
143
- * @documentation https://helix.fusion.dev/components/listbox
144
- *
145
- * @slot - The options, and any groups of them.
146
- * @slot hint - Richer than a string.
147
- * @slot label - Richer than a string.
148
- *
149
- * @event {Event} change - Announced once the value has moved.
150
- * @event {HelixBeforeChangeEvent} helix-before-change - Fired when the answer changes, and cancelable.
151
- * @event {HelixFilterEvent} helix-filter - Fired before the list narrows, and cancelable.
152
- * @event {Event} input - Announced once the value has moved.
153
- *
154
- * @csspart empty - Shown when a filter has hidden everything.
155
- * @csspart feedback - What is wrong with it.
156
- * @csspart filter-icon - The mark beside that field.
157
- * @csspart filter-mark - The glyph beside the filter field, where none is slotted.
158
- * @csspart hint - Help under the list.
159
- * @csspart items - The list itself.
160
- * @csspart label - The question, over the list.
161
- * @csspart loading - Shown while the options are still on their way.
162
- * @csspart search - The region holding the field that narrows the list.
163
- * @csspart search-field - The field itself.
164
- */
165
58
  let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(MixinControllerValue(MixinField(HelixFormElement))) {
166
59
  constructor() {
167
60
  super(...arguments);
168
- /* A consumer's `aria-describedby` names ids in their own tree and the control
169
- is in this shadow root, so the reference has to be carried down as an
170
- element rather than copied as an id. See `AriaForwardController`: measured
171
- in Chrome's own tree, the host held the description and the control the
172
- reader is actually on was exposed without it. */
173
61
  this.aria = new AriaForwardController(this, {
174
62
  target: () => this.renderRoot?.querySelector('[part="items"]') ?? null,
175
63
  });
176
- /**
177
- * What the browser may autofill in the text field, as a platform token.
178
- *
179
- * **Defaults to `off`, which is not the platform's default and is the
180
- * point.** The field filters a list rather than collecting a value, so a
181
- * browser offering to fill it with a saved address is competing for the same
182
- * keystrokes as the listbox. A consumer who wants autofill can still ask.
183
- */
184
64
  this.autocomplete = 'off';
185
- /**
186
- * What to start narrowed to, where nothing declared it.
187
- */
188
65
  this.defaultFilter = '';
189
- /** What the list is narrowed to, whoever set it. */
190
66
  this.filterState = undefined;
191
- /**
192
- * What is shown when a filter has hidden every option.
193
- *
194
- * From `MixinCollection`, which every collection in the library now shares,
195
- * and re-declared here only to keep this list's own wording: "No matches" is
196
- * about a filter, and the mixin's "Nothing to show" is about a list that was
197
- * never given anything.
198
- */
199
67
  this.emptyText = 'No matches';
200
- /**
201
- * Whether the list renders a field for narrowing itself.
202
- *
203
- * The field lives here rather than in whatever frames the list, because the
204
- * filtering already does: `filter`, the empty state and the keyboard are all
205
- * this component's. A select, a combobox and a command palette drawing their
206
- * own search boxes would be three copies of one field.
207
- */
208
68
  this.filterable = false;
209
- /**
210
- * Placeholder for the field that narrows the list.
211
- */
212
69
  this.filterLabel = 'Filter';
213
- /**
214
- * Whether more than one option can be the answer.
215
- */
216
70
  this.multiple = false;
217
- /**
218
- * Which option the keyboard is on, which is not what is chosen.
219
- */
220
71
  this.activeValue = '';
221
- /**
222
- * Whether the key being handled was forwarded rather than typed into this.
223
- *
224
- * Plain state rather than reactive: it is set and cleared inside one
225
- * synchronous call, so nothing renders while it is true and nothing outside
226
- * can observe it.
227
- */
228
72
  this.forwarded = false;
229
- /**
230
- * Whether anything survived the filter, kept as state rather than counted at
231
- * render time.
232
- *
233
- * Counting during render reads the hiding from the *previous* pass, because
234
- * hiding happens in `updated` against options that are the consumer's
235
- * elements rather than this component's markup, so a filter matching nothing
236
- * leaves an empty box with no message in it. Written to state instead, which
237
- * schedules the one further render that shows it.
238
- */
239
73
  this.hasMatches = true;
240
- /**
241
- * Whether the options have been looked at yet.
242
- *
243
- * Emptiness is a fact about light DOM children, and the server has none to
244
- * count: without this the list shipped "Nothing to show" over markup that was
245
- * about to have options in it, which is the one thing an empty state must
246
- * never do. `place` runs from `updated`, which the server never reaches, so
247
- * this is false exactly when the answer is unknown.
248
- */
249
74
  this.counted = false;
250
- /* Stated once, and the group never thinks about it again. See the
251
- controller: cascading by hand from each method that changes the set
252
- leaves the paths that reached it differing by component. */
253
75
  this.cascade = new DisabledCascadeController(this, {
254
76
  items: () => this.options,
255
77
  });
@@ -258,13 +80,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
258
80
  this.activeValue = event.detail.value;
259
81
  this.choose(event.detail.value);
260
82
  };
261
- /**
262
- * Moves the keyboard, and chooses with it only where that is the pattern.
263
- *
264
- * Space and Enter are how a multi-select list chooses, and they are also how
265
- * a single-select one confirms what the arrows already did, so they act
266
- * either way rather than being guarded on `multiple`.
267
- */
268
83
  this.handleKeydown = (event) => {
269
84
  const shown = this.shown.filter((option) => !option.disabled);
270
85
  if (shown.length === 0) {
@@ -277,9 +92,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
277
92
  }
278
93
  return;
279
94
  }
280
- /* The list's own movement keys, plus Enter, which is choosing rather than
281
- moving and is still the list's: a reader who has narrowed to one option
282
- expects Enter to take it without leaving the field first. */
283
95
  if (!MOVES.has(event.key) && event.key !== 'Enter') {
284
96
  return;
285
97
  }
@@ -304,30 +116,11 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
304
116
  return;
305
117
  }
306
118
  this.activeValue = target.value;
307
- /* Chosen on the way past only where moving is choosing. With `multiple` a
308
- reader passing over four options on the way to the fifth did not mean to
309
- pick all five.
310
-
311
- Nor where the keys were forwarded. A list with the focus on it selects as
312
- it moves, which is the pattern for a listbox; a combobox's arrows move
313
- the highlight and Enter takes it, which is the pattern for a combobox,
314
- and the two differ by exactly which element the reader is in. Measured
315
- before this: the first Down in a combobox committed the second option
316
- and shut the panel, one keystroke into the interaction. */
317
119
  if (!this.multiple && !this.forwarded) {
318
120
  this.choose(target.value);
319
121
  }
320
122
  };
321
- /**
322
- * Narrows the list as the reader types, announcing it first.
323
- *
324
- * `helix-filter` and not `helix-change`, because nothing about the value
325
- * changes: the options are hidden rather than removed, and telling a consumer
326
- * their answer moved when it did not is worse than telling them nothing.
327
- */
328
123
  this.handleFilter = (event) => {
329
- // Stopped for the reason `Input.handleInput` gives: the inner control's own
330
- // `input` is composed and would reach a consumer beside this element's.
331
124
  event.stopPropagation();
332
125
  const control = event.target;
333
126
  this.requestFilter(control.value);
@@ -335,26 +128,8 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
335
128
  control.value = this.filter;
336
129
  }
337
130
  };
338
- /**
339
- * Sends the keys that belong to the list to the list.
340
- *
341
- * A reader filtering is still choosing: they type two letters and press Down
342
- * to reach what is left, and the field would otherwise swallow it because the
343
- * arrows move a caret. Typing keys stay with the field, which is what makes
344
- * this different from forwarding everything.
345
- *
346
- * Escape clears rather than closing. What frames the list may close on Escape
347
- * too, so this stops the event when there is something to clear: one press
348
- * empties the filter, a second reaches whatever is above and closes it. A
349
- * filter that closed the panel would lose the reader's place for a keystroke
350
- * they meant as an undo.
351
- */
352
131
  this.handleFilterKeydown = (event) => {
353
132
  if (event.key === 'Escape' && this.filter !== '') {
354
- /* Stopped and prevented whether or not the clearing stands. The reader
355
- pressed Escape at a filtered list, so the key was for this list either
356
- way, and letting it through to close the surface around it would be a
357
- second thing happening from one press. */
358
133
  event.stopPropagation();
359
134
  event.preventDefault();
360
135
  this.requestFilter('');
@@ -363,35 +138,10 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
363
138
  if (!MOVES.has(event.key)) {
364
139
  return;
365
140
  }
366
- /* **Forwarded, because the reader is in the field.** `navigate` says the
367
- rule this has to follow: a listbox and a combobox differ by which element
368
- the focus is in, not by a mode, and a reader typing into a filter is in
369
- the second case whether or not the field belongs to this element. So the
370
- arrows move the highlight and Enter takes it, which is what Enter already
371
- does by bubbling to the host handler.
372
-
373
- **Never move focus to `[part="items"]` and re-dispatch the key instead.**
374
- Both halves fail: focus on the list means moving chooses, so a single
375
- press commits a value the reader was arrowing past, and the original
376
- press goes on bubbling to the same host handler the re-dispatch already
377
- reached. Measured on a bare `<helix-listbox filterable>`, one Down
378
- produced two `helix-change` events and a highlight two rows down.
379
-
380
- `stopPropagation` is what closes the second half: the field handled the
381
- key, so it must not arrive at the host as well. The move keys are the
382
- only ones taken, so Enter, Tab and every character still pass. */
383
141
  event.stopPropagation();
384
142
  this.navigate(event);
385
143
  };
386
144
  }
387
- /**
388
- * What the list is narrowed to, if anything.
389
- *
390
- * Filtering hides rather than removes, so nothing about the value changes and
391
- * the list is still the consumer's own elements in their own order. Reading
392
- * gives what is being filtered on; writing declares it theirs, and
393
- * `default-filter` seeds one the list owns.
394
- */
395
145
  get filter() {
396
146
  return this.filterState ?? this.defaultFilter;
397
147
  }
@@ -400,18 +150,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
400
150
  this.filterState = next;
401
151
  this.requestUpdate('filter', previous);
402
152
  }
403
- /**
404
- * Which option is chosen, as an index, or `-1` where none is.
405
- *
406
- * `<select>`'s own pair, and this control is what stands in for one. The
407
- * options are the consumer's own light-DOM children, so an index is a
408
- * position among the elements they wrote rather than into anything this
409
- * renders, which is what makes it the same number `<select>` would give.
410
- *
411
- * With `multiple` it is the first chosen one, which is also what the platform
412
- * answers: a single index cannot describe a set, and `value` is the property
413
- * that can.
414
- */
415
153
  get selectedIndex() {
416
154
  return this.options.findIndex((option) => option.selected);
417
155
  }
@@ -419,34 +157,12 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
419
157
  const option = this.options[at];
420
158
  this.value = option?.value ?? '';
421
159
  }
422
- /**
423
- * Which kind of chooser this is, in the platform's two words.
424
- *
425
- * Derived rather than declared, because `multiple` already decides it and a
426
- * second property could disagree.
427
- */
428
160
  get type() {
429
161
  return this.multiple ? 'select-multiple' : 'select-one';
430
162
  }
431
- /**
432
- * Every option, in the order a reader meets them.
433
- *
434
- * Descendants rather than children, because a group puts a level between the
435
- * list and its options and the keyboard has to move through a grouped list
436
- * exactly as it moves through a flat one.
437
- */
438
163
  get options() {
439
164
  return this.collect(this.children);
440
165
  }
441
- /**
442
- * Walks the tree, and walks *through* a slot when it meets one.
443
- *
444
- * Through a slot because that is how this gets composed. `Select` renders a
445
- * listbox in its own shadow root with a slot inside it, so the options are
446
- * the *select's* light DOM and the listbox's only child is the slot: a plain
447
- * `querySelectorAll` finds the slot and no options at all, and the whole
448
- * component comes up empty.
449
- */
450
166
  collect(nodes) {
451
167
  const found = [];
452
168
  for (const node of nodes) {
@@ -462,7 +178,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
462
178
  }
463
179
  return found;
464
180
  }
465
- /** The groups, found the same way and for the same reason. */
466
181
  get groups() {
467
182
  const found = [];
468
183
  const walk = (nodes) => {
@@ -481,20 +196,12 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
481
196
  walk(this.children);
482
197
  return found;
483
198
  }
484
- /** The options a filter has left showing. */
485
199
  get shown() {
486
200
  return this.options.filter((option) => !option.hidden);
487
201
  }
488
- /** The chosen values, which is a list whether or not `multiple` is set. */
489
202
  get values() {
490
203
  return this.value === '' ? [] : this.value.split(',');
491
204
  }
492
- /**
493
- * Applies a choice, announcing first so a listener can refuse it.
494
- *
495
- * With `multiple` a choice is a toggle, because the same press has to be able
496
- * to take an answer back; without it a choice replaces whatever was there.
497
- */
498
205
  choose(value) {
499
206
  const chosen = this.values;
500
207
  const next = this.multiple
@@ -506,31 +213,8 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
506
213
  if (joined === this.value) {
507
214
  return;
508
215
  }
509
- /* Through the mixin rather than hand-rolled: writing `value` is how a
510
- consumer says it is theirs, so a listbox writing its own would take
511
- ownership away from them on every change. */
512
216
  this.requestValue(joined);
513
217
  }
514
- /**
515
- * Runs the list's own key handling for a caller whose focus is elsewhere.
516
- *
517
- * A combobox keeps focus in its text field, because that is the whole of what
518
- * a combobox is, and still has to move this list with the arrows. Without
519
- * this it would reimplement the moving, the wrapping, the skipping of
520
- * disabled options and the seeding from the current value.
521
- *
522
- * Public and taking the event rather than a direction, so a caller forwards
523
- * what it received and this decides what the key means. `preventDefault` is
524
- * called in here on the keys it acts on, so a caller can hand over every
525
- * keydown and let the list ignore the ones that are not its business.
526
- *
527
- * The entry point is also what says whether moving chooses, which is why the
528
- * flag is set here rather than exposed as a property. The difference between
529
- * a listbox and a combobox is not a mode a consumer picks: it is which
530
- * element the reader's focus is in, and that is exactly what calling this
531
- * rather than being typed into means. Enter and Space still choose, because
532
- * those are the caller asking for the answer rather than for a move.
533
- */
534
218
  navigate(event) {
535
219
  this.forwarded = true;
536
220
  try {
@@ -540,34 +224,12 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
540
224
  this.forwarded = false;
541
225
  }
542
226
  }
543
- /**
544
- * Brings the chosen option to rest in the middle of the list.
545
- *
546
- * A method rather than something this does on its own, because a listbox
547
- * opening on its answer usually wants it merely *visible* and the browser
548
- * already manages that: centering is what a caller wants when the list is being
549
- * read as a dial rather than scanned as a list, which is what Time Picker's
550
- * wheels do with three of these.
551
- *
552
- * A delta from the two boxes rather than `offsetTop`. An option is a slotted
553
- * light DOM child, so its `offsetParent` is whatever is positioned above it,
554
- * which is not this list and is not guaranteed to be anything: measuring both
555
- * rects and moving by the difference needs no such assumption.
556
- */
557
227
  reveal() {
558
228
  const items = this.renderRoot?.querySelector('[part="items"]');
559
229
  const chosen = this.options.find((option) => option.selected && !option.hidden);
560
230
  if (!items || !chosen) {
561
231
  return;
562
232
  }
563
- /* Twice, and the second pass is not superstition. The first delta is
564
- computed against whatever layout is current when this is called, and
565
- inside a panel that has just been revealed it is a frame behind: measured,
566
- one call landed a whole row short, 672 where 704 was wanted, and calling
567
- it again from a settled layout put it exactly on center. Reading a rect
568
- forces layout, so the second pass sees the result of the first and
569
- corrects the remainder. It stops as soon as there is nothing left to
570
- correct, so the usual case is one pass and a cheap check. */
571
233
  for (let pass = 0; pass < 2; pass += 1) {
572
234
  const box = items.getBoundingClientRect();
573
235
  const row = chosen.getBoundingClientRect();
@@ -581,7 +243,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
581
243
  items.scrollTop += off;
582
244
  }
583
245
  }
584
- /** Hides what the filter excludes, and writes the rest onto the options. */
585
246
  place() {
586
247
  const needle = this.filter.trim().toLowerCase();
587
248
  const chosen = new Set(this.values);
@@ -589,26 +250,10 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
589
250
  option.hidden = needle !== '' && !option.text.toLowerCase().includes(needle);
590
251
  option.selected = chosen.has(option.value);
591
252
  }
592
- /* A group whose options the filter has all hidden goes with them. CSS
593
- cannot do this: a heading and its options are siblings under the group,
594
- so the rule would be `:host(:has(...))`, which is dropped at parse time.
595
- The listbox can, because it is the thing that did the hiding and already
596
- knows which. Left showing, the picture was a heading over nothing, which
597
- reads as a group whose contents failed to render. */
598
253
  for (const group of this.groups) {
599
254
  const inside = this.collect(group.children);
600
255
  group.hidden = inside.length > 0 && inside.every((option) => option.hidden);
601
256
  }
602
- /* The keyboard cannot sit on something the filter has hidden, so it moves
603
- to the first thing still showing rather than nowhere: a list whose active
604
- option has just been filtered away is a list the next arrow press would
605
- start from the top of anyway.
606
-
607
- But only where there is a filter to have hidden it, or a highlight worth
608
- rescuing. Clearing one must not *invent* a highlight out of nothing:
609
- measured, a combobox clears its filter as it closes, and that alone
610
- parked the highlight on the first option while the panel was shut, so
611
- the reader's first Down arrived at the second. */
612
257
  const shown = this.shown;
613
258
  const rescue = needle !== '' || this.activeValue !== '';
614
259
  if (rescue && !shown.some((option) => option.value === this.activeValue)) {
@@ -617,8 +262,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
617
262
  for (const option of this.options) {
618
263
  option.active = option.value === this.activeValue;
619
264
  }
620
- /* And the marks, from the same needle that did the hiding: the two answers
621
- have to come from one place or a list can show a match it does not mark. */
622
265
  if (needle === '' || !paintable()) {
623
266
  matched.delete(this);
624
267
  }
@@ -637,7 +280,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
637
280
  scrollItemIntoView(active, { padding: 4, ...(items ? { scroller: items } : {}) });
638
281
  }
639
282
  }
640
- /** Reports the answer and the verdict on it. */
641
283
  sync() {
642
284
  this.internals.setFormValue(this.value === '' ? null : this.value);
643
285
  this.reportRequired(this.value === '', 'Choose one to continue.', 'items');
@@ -651,9 +293,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
651
293
  super.disconnectedCallback();
652
294
  this.removeEventListener('helix-select', this.handleSelection);
653
295
  this.removeEventListener('keydown', this.handleKeydown);
654
- /* Its marks go with it. The registry is the document's and outlives this
655
- element, so a list removed while filtered would leave ranges over text
656
- that is no longer on the page. */
657
296
  matched.delete(this);
658
297
  paintMatches();
659
298
  }
@@ -662,44 +301,10 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
662
301
  hostAria(this.internals, { label: this.label, role: 'listbox' });
663
302
  this.internals.ariaMultiSelectable = this.multiple ? 'true' : null;
664
303
  }
665
- /**
666
- * Where the reader is, and failing that where the answer is.
667
- *
668
- * `activeValue` starts empty, so the first arrow press in a list that already
669
- * has a chosen option started from nowhere: Down went to the first option,
670
- * which is a move backwards from anything below it, and Up went to the last.
671
- * Seeding from the value means the first press moves from what the reader can
672
- * see is selected.
673
- *
674
- * Read by `linkActive` as well as by the keys, so the option announced and
675
- * the option the arrows move from cannot disagree: asking for `activeValue`
676
- * alone points `aria-activedescendant` at nothing until a key has moved it.
677
- */
678
304
  get currentValue() {
679
305
  return this.activeValue || (this.value.split(',')[0] ?? '');
680
306
  }
681
- /*
682
- * Points the focused list at the option the keyboard is on.
683
- *
684
- * **An element reference and not an id, because an IDREF cannot do this.**
685
- * The options are the consumer's light DOM and the element that takes focus
686
- * is in this shadow root, so `aria-activedescendant="whatever"` resolves
687
- * against the shadow tree, finds nothing, and is dropped in silence. Element
688
- * references resolve outward: a shadow-tree element may point at a light-DOM
689
- * one, which is the direction this needs.
690
- *
691
- * Feature-tested rather than assumed, for the reason `hostAria` gives:
692
- * assigning to a property an engine does not implement is a no-op rather than
693
- * an error. Where it is missing the list still works and still announces its
694
- * selection; what is lost is the running commentary as the keys move.
695
- */
696
307
  linkActive() {
697
- /* Whichever element the reader is actually in. `aria-activedescendant` is
698
- read from the focused element, so pointing the list at the active option
699
- says nothing while focus is in the filter, which is where it now stays.
700
- A filterable list announces from its field and a plain one from its
701
- items, for the same reason the arrows are forwarded in one and not the
702
- other. */
703
308
  const list = this.renderRoot.querySelector(this.filterable ? '[part="search-field"]' : '[part="items"]');
704
309
  if (!list || !('ariaActiveDescendantElement' in list)) {
705
310
  return;
@@ -714,7 +319,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
714
319
  this.linkHost();
715
320
  this.linkActive();
716
321
  }
717
- /** Requests a narrowing, and reports whether the request stood. */
718
322
  requestFilter(next) {
719
323
  if (next === this.filter) {
720
324
  return true;
@@ -730,29 +334,10 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
730
334
  next,
731
335
  });
732
336
  }
733
- /**
734
- * Focusing the host focuses the list inside it.
735
- *
736
- * The focusable node is `[part="items"]`, a div in this shadow root, and a
737
- * caller outside has no way to reach it: `select` opened its panel and called
738
- * `.focus()` on this element, which is not focusable, so focus stayed on the
739
- * trigger and the arrow keys never reached the options at all. Delegation is
740
- * the platform answering that, and it keeps `document.activeElement` on the
741
- * host, which is what a caller reasons about.
742
- */
743
337
  static { this.shadowRootOptions = {
744
338
  ...HelixFormElement.shadowRootOptions,
745
339
  delegatesFocus: true,
746
340
  }; }
747
- /**
748
- * The mark in the filter's field, behind a seam.
749
- *
750
- * Slot fallback, so a consumer replaces it by slotting; and a method, so the
751
- * tier above replaces it by overriding. Those answer different people: the
752
- * slot is for the page and the method is for whoever is building the styled
753
- * layer, and a fallback with only the first is one a tier can restyle and
754
- * cannot change.
755
- */
756
341
  renderFilterIcon() {
757
342
  return html `<slot name="filter-icon">
758
343
  <svg part="filter-mark" fill="currentColor" viewBox=${magnifyingGlass.viewBox}>
@@ -761,9 +346,6 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
761
346
  </slot>`;
762
347
  }
763
348
  render() {
764
- /* One tab stop for the whole list, which is what a listbox is in the tab
765
- order: the arrows move within it. `tabindex` on the wrapper rather than
766
- the host so the host stays as the consumer wrote it. */
767
349
  return html `${this.renderGroupLabel()}
768
350
  ${this.filterable
769
351
  ? html `<div part="search">
@@ -789,20 +371,11 @@ let HelixHeadlessListbox = class HelixHeadlessListbox extends MixinCollection(Mi
789
371
  part="items"
790
372
  id="items"
791
373
  aria-busy=${this.loading ? 'true' : nothing}
792
- ${
793
- /* `isDisabled`, matching the filter input above it: on `disabled`
794
- alone, a listbox in a disabled fieldset keeps its tab stop and
795
- takes arrow keys while its own filter is correctly out. */
796
- ''}
374
+ ${''}
797
375
  tabindex=${this.isDisabled ? -1 : 0}
798
376
  >
799
377
  <slot @slotchange=${() => this.place()}></slot>
800
- ${
801
- /* Loading and empty come from the shared mixin; what is local is what
802
- counts as content. A list with a filter that matched nothing is
803
- empty, and one with no filter and no options is empty too, but a
804
- list still waiting on its options is neither. */
805
- this.renderStatus(this.counted && !this.loading
378
+ ${this.renderStatus(this.counted && !this.loading
806
379
  ? this.filter === ''
807
380
  ? this.options.length > 0
808
381
  : this.hasMatches