@fusion.dev/helix-headless 0.1.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.
- package/custom-elements.json +234 -234
- package/dist/accordion/item/accordion-item.js +0 -108
- package/dist/accordion/root/accordion-root.js +0 -33
- package/dist/announcement/root/announcement-root.js +1 -76
- package/dist/app-header/root/app-header-root.js +0 -84
- package/dist/avatar/group/avatar-group.js +0 -77
- package/dist/avatar/root/avatar-root.js +0 -72
- package/dist/badge/root/badge-root.js +0 -67
- package/dist/bar-chart/root/bar-chart-root.js +3 -263
- package/dist/blockquote/root/blockquote-root.js +0 -46
- package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
- package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
- package/dist/button/root/button-root.js +0 -215
- package/dist/button-group/root/button-group-root.js +0 -30
- package/dist/callout/root/callout-root.js +0 -52
- package/dist/card/root/card-root.js +0 -41
- package/dist/carousel/root/carousel-root.d.ts +2 -2
- package/dist/carousel/root/carousel-root.js +2 -362
- package/dist/chat/composer/chat-composer.js +0 -131
- package/dist/chat/message/chat-message.js +0 -137
- package/dist/chat/root/chat-root.js +0 -193
- package/dist/checkbox/root/checkbox-root.js +0 -113
- package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
- package/dist/code/root/code-root.js +0 -26
- package/dist/code-block/root/code-block-root.js +0 -100
- package/dist/color-input/root/color-input-root.js +0 -217
- package/dist/color-picker/color.js +0 -69
- package/dist/color-picker/root/color-picker-root.js +0 -272
- package/dist/combobox/root/combobox-root.js +0 -428
- package/dist/command/root/command-root.js +0 -177
- package/dist/copy-button/root/copy-button-root.js +0 -134
- package/dist/data-table/root/data-table-root.js +0 -336
- package/dist/data-table/root/order.js +0 -100
- package/dist/date-input/root/date-input-root.js +1 -398
- package/dist/date-picker/root/date-picker-root.js +0 -358
- package/dist/dialog/root/dialog-root.js +0 -189
- package/dist/divider/root/divider-root.js +0 -71
- package/dist/donut-chart/root/donut-chart-root.js +0 -214
- package/dist/drawer/root/drawer-root.js +0 -116
- package/dist/drilldown/item/drilldown-item.js +0 -82
- package/dist/drilldown/root/drilldown-root.js +0 -91
- package/dist/element-events.generated.js +0 -5
- package/dist/empty/root/empty-root.js +0 -35
- package/dist/file-input/root/file-input-root.js +0 -232
- package/dist/format-date/root/format-date-root.js +0 -95
- package/dist/heatmap/root/buckets.js +0 -57
- package/dist/heatmap/root/heatmap-root.js +0 -313
- package/dist/icon/root/icon-root.js +0 -48
- package/dist/icon-button/root/icon-button-root.js +0 -30
- package/dist/image/root/image-root.js +0 -174
- package/dist/index.js +0 -9
- package/dist/input/root/input-root.js +0 -228
- package/dist/kbd/root/kbd-root.js +0 -117
- package/dist/legend/root/legend-root.js +0 -73
- package/dist/line-chart/root/line-chart-root.js +0 -172
- package/dist/list/item/list-item.js +0 -92
- package/dist/list/root/list-root.js +0 -26
- package/dist/listbox/root/listbox-root.js +2 -429
- package/dist/loader/root/loader-root.js +0 -40
- package/dist/megamenu/root/megamenu-root.js +0 -284
- package/dist/menu/item/menu-item.js +1 -221
- package/dist/menu/root/menu-root.js +1 -171
- package/dist/meter/root/meter-root.js +1 -152
- package/dist/navigation/root/navigation-root.js +0 -56
- package/dist/notifications/notification/notification.js +0 -159
- package/dist/notifications/root/notifications-root.js +0 -148
- package/dist/number-input/root/number-input-root.js +1 -263
- package/dist/option/group/option-group.js +0 -26
- package/dist/option/root/option-root.js +0 -157
- package/dist/outline/root/outline-root.js +0 -292
- package/dist/pagination/root/pagination-root.js +0 -361
- package/dist/pagination/size/pagination-size.js +0 -104
- package/dist/pagination/summary/pagination-summary.js +0 -90
- package/dist/pin-input/root/pin-input-root.js +0 -276
- package/dist/popover/root/popover-root.js +0 -103
- package/dist/progress/root/progress-root.js +1 -133
- package/dist/qr-code/root/qr-code-root.js +0 -96
- package/dist/quote/root/quote-root.js +0 -25
- package/dist/radio/group/radio-group.js +0 -81
- package/dist/radio/root/radio-root.js +0 -85
- package/dist/rating/root/rating-root.js +0 -220
- package/dist/scroll-area/root/scroll-area-root.js +0 -100
- package/dist/select/root/select-root.js +2 -218
- package/dist/skeleton/root/skeleton-root.js +0 -20
- package/dist/slider/root/slider-root.js +1 -267
- package/dist/sparkline/root/sparkline-root.js +3 -164
- package/dist/speed-dial/item/speed-dial-item.js +0 -52
- package/dist/speed-dial/root/speed-dial-root.js +1 -116
- package/dist/splitter/root/splitter-root.js +0 -489
- package/dist/stat/group/stat-group.js +0 -18
- package/dist/stat/root/stat-root.js +0 -23
- package/dist/steps/root/steps-root.js +0 -62
- package/dist/steps/step/step.js +0 -131
- package/dist/switch/root/switch-root.js +0 -80
- package/dist/table/cell/table-cell.js +0 -23
- package/dist/table/column/table-column.js +0 -158
- package/dist/table/root/table-root.js +0 -82
- package/dist/table/row/table-row.js +0 -21
- package/dist/tabs/panel/tab-panel.js +0 -49
- package/dist/tabs/root/tabs-root.js +0 -98
- package/dist/tabs/tab/tab.js +0 -59
- package/dist/tag/root/tag-root.js +0 -31
- package/dist/textarea/root/textarea-root.js +0 -162
- package/dist/time-input/root/time-input-root.js +0 -283
- package/dist/time-picker/root/time-picker-root.js +0 -326
- package/dist/timeline/item/timeline-item.js +0 -66
- package/dist/timeline/root/timeline-root.js +0 -41
- package/dist/toast/item/toast-item.js +0 -165
- package/dist/toast/root/toast-root.js +0 -51
- package/dist/toggle/group/toggle-group.js +0 -150
- package/dist/toggle/root/toggle-root.js +0 -78
- package/dist/toolbar/root/toolbar-root.js +0 -116
- package/dist/tooltip/root/tooltip-root.js +0 -427
- package/dist/tour/root/tour-root.js +0 -130
- package/dist/tour/stop/tour-stop.js +1 -127
- package/dist/tree/item/tree-item.js +0 -198
- package/dist/tree/root/tree-root.js +0 -97
- package/dist/trend/root/trend-root.js +0 -53
- package/dist/video/root/video-root.d.ts +8 -12
- package/dist/video/root/video-root.js +5 -756
- package/dist/video-playlist/item/video-playlist-item.js +1 -90
- package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
- package/dist/video-playlist/root/video-playlist-root.js +0 -113
- package/dist/virtual-list/root/virtual-list-root.js +0 -105
- package/jsx.d.ts +1408 -2576
- package/package.json +6 -6
- 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
|