@fusion.dev/helix-headless 0.13.2 → 0.15.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 +676 -44
- package/dist/combobox/root/combobox-root.d.ts +1 -1
- package/dist/combobox/root/combobox-root.js +2 -1
- package/dist/element-events.generated.js +8 -1
- package/dist/filter-input/root/filter-input-root.d.ts +146 -3
- package/dist/filter-input/root/filter-input-root.js +173 -4
- package/package.json +5 -5
- package/types/jsx.d.ts +226 -4
- package/types/vue.d.ts +50 -1
|
@@ -71,7 +71,7 @@ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusi
|
|
|
71
71
|
* @csspart listbox-loading - Shown while the options are still on their way.
|
|
72
72
|
* @csspart listbox-search - The region holding the field that narrows the list.
|
|
73
73
|
* @csspart listbox-search-field - The field itself.
|
|
74
|
-
* @csspart rest - The count of choices held beyond `max-tags`.
|
|
74
|
+
* @csspart rest - The count of choices held beyond `max-tags`. A `<button>` with `expandable`, a `<span>` without it.
|
|
75
75
|
* @csspart surface - The floating panel.
|
|
76
76
|
* @csspart tag - One chosen option.
|
|
77
77
|
* @csspart tag-dismiss - The remove button, when `dismissible`.
|
|
@@ -177,7 +177,8 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
|
|
|
177
177
|
}
|
|
178
178
|
get shown() {
|
|
179
179
|
const held = this.chosen;
|
|
180
|
-
|
|
180
|
+
const limit = this.shownLimit;
|
|
181
|
+
return limit > 0 ? held.slice(0, limit) : held;
|
|
181
182
|
}
|
|
182
183
|
get restCount() {
|
|
183
184
|
return Math.max(0, this.chosen.length - this.shown.length);
|
|
@@ -53,7 +53,14 @@ registerElementEvents({
|
|
|
53
53
|
'helix-headless-drilldown': ['helix-select'],
|
|
54
54
|
'helix-headless-drilldown-item': ['helix-select'],
|
|
55
55
|
'helix-headless-file-input': ['change', 'helix-before-change', 'input'],
|
|
56
|
-
'helix-headless-filter-input': [
|
|
56
|
+
'helix-headless-filter-input': [
|
|
57
|
+
'change',
|
|
58
|
+
'helix-before-change',
|
|
59
|
+
'helix-before-close',
|
|
60
|
+
'helix-before-open',
|
|
61
|
+
'input',
|
|
62
|
+
'toggle',
|
|
63
|
+
],
|
|
57
64
|
'helix-headless-heatmap': ['helix-resize'],
|
|
58
65
|
'helix-headless-image': ['error', 'load'],
|
|
59
66
|
'helix-headless-input': ['change', 'helix-before-change', 'input'],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AriaForwardController, HelixFormElement } from '@fusion.dev/helix-core';
|
|
1
|
+
import { AriaForwardController, HelixFormElement, PopoverController } from '@fusion.dev/helix-core';
|
|
2
2
|
import { type PropertyValues, type TemplateResult } from 'lit';
|
|
3
|
-
declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinTokensInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
|
|
3
|
+
declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinTokensInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
|
|
4
4
|
/**
|
|
5
5
|
* @summary A field that narrows something else, beside what is already narrowing it.
|
|
6
6
|
*
|
|
@@ -22,6 +22,20 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
22
22
|
* `helix-dismiss` its own remove control fires, so a consumer who handled the
|
|
23
23
|
* pointer has already handled the keyboard.
|
|
24
24
|
*
|
|
25
|
+
* **It can suggest what to type next, and it never learns the grammar.** Slot
|
|
26
|
+
* `helix-option` elements into `suggestions` and the field shows them, moves
|
|
27
|
+
* through them with the arrows, and lets Enter press one; the option fires its
|
|
28
|
+
* own `helix-select` and the consumer decides what choosing meant. Deciding
|
|
29
|
+
* *what* to suggest is theirs too: keys before a colon and values after it are
|
|
30
|
+
* the same panel with different contents, which is all a two-level filter is,
|
|
31
|
+
* and the parse that tells them apart belongs where the domain does.
|
|
32
|
+
*
|
|
33
|
+
* **The panel opens when there is something in it and closes when there is
|
|
34
|
+
* not**, rather than on a choice. A reader who picks a key wants its values
|
|
35
|
+
* next, and closing on the choice would shut the panel between two renders. Use
|
|
36
|
+
* `selectionStart` and `setSelectionRange` to write a completion back and put
|
|
37
|
+
* the caret after it, or the next parse reads the wrong term.
|
|
38
|
+
*
|
|
25
39
|
* @since 0.1.0
|
|
26
40
|
* @status experimental
|
|
27
41
|
* @category forms
|
|
@@ -30,11 +44,15 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
30
44
|
*
|
|
31
45
|
* @slot end - After the text, for a count or a control.
|
|
32
46
|
* @slot start - Before the text, for a search icon.
|
|
47
|
+
* @slot suggestions - What could be typed next, as `Option`s, and `Option.Group`s of them.
|
|
33
48
|
* @slot tags - What is already narrowing the results, as `Tag`s.
|
|
34
49
|
*
|
|
35
50
|
* @event {Event} change - Relayed from the input once the reader has finished.
|
|
36
51
|
* @event {HelixBeforeChangeEvent} helix-before-change - Fired as the text changes, and cancelable.
|
|
52
|
+
* @event {HelixBeforeCloseEvent} helix-before-close - Fired before the suggestions go, and cancelable.
|
|
53
|
+
* @event {HelixBeforeOpenEvent} helix-before-open - Fired before the suggestions show, and cancelable.
|
|
37
54
|
* @event {Event} input - Announced once the text has moved.
|
|
55
|
+
* @event {Event} toggle - Says the panel moved, carrying `newState` and `oldState`.
|
|
38
56
|
*
|
|
39
57
|
* @csspart clear - The button that empties the field.
|
|
40
58
|
* @csspart control - The input itself.
|
|
@@ -42,7 +60,8 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
42
60
|
* @csspart field - The box holding everything.
|
|
43
61
|
* @csspart hint - Help under the field, while nothing is wrong.
|
|
44
62
|
* @csspart label - The question, over the field.
|
|
45
|
-
* @csspart rest - How many tokens did not fit.
|
|
63
|
+
* @csspart rest - How many tokens did not fit. A `<button>` with `expandable`, a `<span>` without it.
|
|
64
|
+
* @csspart surface - The floating panel the suggestions sit in.
|
|
46
65
|
* @csspart tags - The row the tokens sit in.
|
|
47
66
|
*/
|
|
48
67
|
export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_base {
|
|
@@ -70,6 +89,34 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
70
89
|
placeholder: string;
|
|
71
90
|
/** How many tokens did not fit, which is what the row's count renders. */
|
|
72
91
|
private over;
|
|
92
|
+
/**
|
|
93
|
+
* Which suggestion the keyboard is on, or `-1` for none.
|
|
94
|
+
*
|
|
95
|
+
* An index rather than a value, unlike Listbox's: these are the consumer's
|
|
96
|
+
* own list and two of them may legitimately carry the same value, a `label`
|
|
97
|
+
* and an `assignee` both called `ada`. Position is what a reader is moving
|
|
98
|
+
* through and it is what stays right when they do.
|
|
99
|
+
*/
|
|
100
|
+
private activeAt;
|
|
101
|
+
/**
|
|
102
|
+
* Whether anything is offering itself to be suggested.
|
|
103
|
+
*
|
|
104
|
+
* **The ARIA follows this rather than being declared once.** A text field
|
|
105
|
+
* carrying `role="combobox"` promises a popup; a filter field that no consumer
|
|
106
|
+
* ever gives options to has none, and saying otherwise sends a screen reader
|
|
107
|
+
* looking for a list that does not exist. So the role, `aria-expanded` and
|
|
108
|
+
* `aria-controls` all arrive with the first option and leave with the last.
|
|
109
|
+
*/
|
|
110
|
+
private suggesting;
|
|
111
|
+
/**
|
|
112
|
+
* Whether Escape has closed the panel over text that still has suggestions.
|
|
113
|
+
*
|
|
114
|
+
* Without it the panel is open whenever there is something to show, so Escape
|
|
115
|
+
* would close it and the next render would put it straight back. Cleared by
|
|
116
|
+
* the next keystroke, because that is the reader saying something new.
|
|
117
|
+
*/
|
|
118
|
+
private dismissed;
|
|
119
|
+
protected readonly panel: PopoverController;
|
|
73
120
|
/**
|
|
74
121
|
* The caret, held across a value the consumer wrote rather than the reader.
|
|
75
122
|
*
|
|
@@ -83,6 +130,40 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
83
130
|
private get tokens();
|
|
84
131
|
/** Whether there is a clear button on screen right now. */
|
|
85
132
|
private get showsClear();
|
|
133
|
+
/**
|
|
134
|
+
* The suggestions, in the order a reader moves through them.
|
|
135
|
+
*
|
|
136
|
+
* **Groups are opened out rather than walked into.** A slot assigns the group
|
|
137
|
+
* element, not the options inside it, so a panel that read only what was
|
|
138
|
+
* assigned would offer nothing at all the moment a consumer reached for
|
|
139
|
+
* `option-group` to say what a run of values belongs to.
|
|
140
|
+
*
|
|
141
|
+
* A disabled option keeps its place on screen and is skipped here, which is
|
|
142
|
+
* the difference between a list that explains why something cannot be chosen
|
|
143
|
+
* and one that pretends it was never there.
|
|
144
|
+
*/
|
|
145
|
+
private get suggestions();
|
|
146
|
+
/**
|
|
147
|
+
* The caret and the selection, under the platform's own names.
|
|
148
|
+
*
|
|
149
|
+
* **Published for the reason `Input` publishes them, and this element needs
|
|
150
|
+
* them more.** The `<input>` is inside this shadow root, so a consumer has no
|
|
151
|
+
* way to ask where the caret is: `querySelector` does not cross the boundary.
|
|
152
|
+
* A grammar that completes what is being typed cannot work without it, because
|
|
153
|
+
* "what is being typed" is the term the caret is in, and after writing a
|
|
154
|
+
* completion back the caret has to be put after it or the next parse reads the
|
|
155
|
+
* wrong term.
|
|
156
|
+
*
|
|
157
|
+
* Zero before there is a control, which is where an empty field's caret would
|
|
158
|
+
* be anyway.
|
|
159
|
+
*/
|
|
160
|
+
get selectionDirection(): 'backward' | 'forward' | 'none';
|
|
161
|
+
/** Where the selection ends, which is the caret when nothing is selected. */
|
|
162
|
+
get selectionEnd(): number;
|
|
163
|
+
/** Where the selection starts, which is the caret when nothing is selected. */
|
|
164
|
+
get selectionStart(): number;
|
|
165
|
+
/** Moves the caret, or selects a run of the text. */
|
|
166
|
+
setSelectionRange(start: number, end: number, direction?: 'backward' | 'forward' | 'none'): void;
|
|
86
167
|
/**
|
|
87
168
|
* Every keystroke, cancelable, and put back where it is refused.
|
|
88
169
|
*
|
|
@@ -100,7 +181,52 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
100
181
|
* disappear, which would otherwise land on the body.
|
|
101
182
|
*/
|
|
102
183
|
private readonly handleClear;
|
|
184
|
+
/**
|
|
185
|
+
* Opens the panel when there is something in it, and closes it when there is
|
|
186
|
+
* not.
|
|
187
|
+
*
|
|
188
|
+
* **Derived rather than commanded, which is the whole of how two levels
|
|
189
|
+
* work.** A reader who picks `disposition` wants its values next, and the
|
|
190
|
+
* consumer answers by rendering those values; if picking closed the panel it
|
|
191
|
+
* would shut and reopen between two renders, which a reader sees as a flicker
|
|
192
|
+
* and a screen reader hears as a list going away. Nothing here closes on a
|
|
193
|
+
* choice: the panel goes when the consumer stops offering, which is exactly
|
|
194
|
+
* when a value has been completed and there is nothing left to say.
|
|
195
|
+
*
|
|
196
|
+
* Through `requestOpen` for both directions, so `helix-before-open` and
|
|
197
|
+
* `helix-before-close` still gate it and a consumer who refuses is obeyed.
|
|
198
|
+
* **No guard of its own**, because that method already returns early when the
|
|
199
|
+
* answer has not moved: this runs after every render, and a second guard here
|
|
200
|
+
* would only be a copy of that one going stale.
|
|
201
|
+
*/
|
|
202
|
+
private settleOpen;
|
|
203
|
+
/** Moves the highlight, wrapping, and opens the panel if it was closed. */
|
|
204
|
+
private move;
|
|
103
205
|
private readonly handleKeydown;
|
|
206
|
+
/**
|
|
207
|
+
* What is on offer, settled before the render that draws it.
|
|
208
|
+
*
|
|
209
|
+
* **Not left to `slotchange`, for the reason the token row is not.** That
|
|
210
|
+
* event reports the assignment moving and nothing else, and it does not fire
|
|
211
|
+
* at all under the harness these are tested in, so a panel that learned only
|
|
212
|
+
* from it would be a panel that never opened. Reading the slot every update is
|
|
213
|
+
* what makes the answer true whenever anyone asks.
|
|
214
|
+
*
|
|
215
|
+
* State only. Opening is an announcement and belongs after the render, which
|
|
216
|
+
* is why `settleOpen` is not called from here.
|
|
217
|
+
*/
|
|
218
|
+
private refreshSuggesting;
|
|
219
|
+
/** The panel again, whenever what is in it changes. */
|
|
220
|
+
private readonly handleSuggestions;
|
|
221
|
+
/** Focus arriving in the field is a reader asking what they can type. */
|
|
222
|
+
private readonly handleFocusIn;
|
|
223
|
+
/**
|
|
224
|
+
* Focus leaving the element closes it.
|
|
225
|
+
*
|
|
226
|
+
* `relatedTarget` and not a timer: a press inside the panel moves focus within
|
|
227
|
+
* this element and must not close what is being pressed.
|
|
228
|
+
*/
|
|
229
|
+
private readonly handleFocusOut;
|
|
104
230
|
/** The row again, whenever what is in it changes. */
|
|
105
231
|
private readonly handleTokens;
|
|
106
232
|
/**
|
|
@@ -117,6 +243,23 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
117
243
|
protected willUpdate(changed: PropertyValues): void;
|
|
118
244
|
protected firstUpdated(changed: PropertyValues): void;
|
|
119
245
|
protected updated(changed: PropertyValues): void;
|
|
246
|
+
/**
|
|
247
|
+
* Marks the highlighted suggestion, and tells the control which one it is.
|
|
248
|
+
*
|
|
249
|
+
* **Two halves, and neither does the other's job.** `active` is the option's
|
|
250
|
+
* own published flag and is what a stylesheet draws from. The control carries
|
|
251
|
+
* `aria-activedescendant`, because focus never leaves the text: a screen
|
|
252
|
+
* reader is told what is highlighted by the field the caret is in, not by the
|
|
253
|
+
* list.
|
|
254
|
+
*
|
|
255
|
+
* Through `ariaActiveDescendantElement` rather than an id, which is what
|
|
256
|
+
* `Listbox` reaches for and for the same two reasons: it crosses a shadow
|
|
257
|
+
* boundary, where an `aria-activedescendant` naming an id cannot, and it needs
|
|
258
|
+
* no ids invented for the consumer's own elements. Feature-tested, because
|
|
259
|
+
* where it is missing the assignment is a silent no-op rather than an error,
|
|
260
|
+
* and the highlight is still drawn.
|
|
261
|
+
*/
|
|
262
|
+
private markActive;
|
|
120
263
|
/** Puts the caret in the field, the way a label pointing at one would. */
|
|
121
264
|
focus(options?: FocusOptions): void;
|
|
122
265
|
protected render(): TemplateResult;
|
|
@@ -4,12 +4,13 @@ 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, customElement, drawIcon, HelixFormElement, holdCaret, MixinControllerValue, MixinField, MixinTokens, restoreValue, } from '@fusion.dev/helix-core';
|
|
7
|
+
import { AriaForwardController, customElement, drawIcon, HelixFormElement, holdCaret, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, restoreValue, } from '@fusion.dev/helix-core';
|
|
8
8
|
import { x } from '@fusion.dev/helix-icons';
|
|
9
9
|
import { html, nothing } from 'lit';
|
|
10
10
|
import { property, state } from 'lit/decorators.js';
|
|
11
11
|
const asksToGo = (node) => typeof node?.dismiss === 'function';
|
|
12
|
-
|
|
12
|
+
const isOption = (node) => 'active' in node && 'value' in node;
|
|
13
|
+
let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinControllerOpen(MixinTokens(MixinControllerValue(MixinField(HelixFormElement)))) {
|
|
13
14
|
constructor() {
|
|
14
15
|
super(...arguments);
|
|
15
16
|
this.aria = new AriaForwardController(this, {
|
|
@@ -20,11 +21,27 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
20
21
|
this.clearLabel = 'Clear';
|
|
21
22
|
this.placeholder = '';
|
|
22
23
|
this.over = 0;
|
|
24
|
+
this.activeAt = -1;
|
|
25
|
+
this.suggesting = false;
|
|
26
|
+
this.dismissed = false;
|
|
27
|
+
this.panel = new PopoverController(this, {
|
|
28
|
+
anchor: () => this.renderRoot?.querySelector('[part="field"]'),
|
|
29
|
+
boundary: () => [
|
|
30
|
+
this.renderRoot?.querySelector('[part="surface"]'),
|
|
31
|
+
this.renderRoot?.querySelector('[part="field"]'),
|
|
32
|
+
],
|
|
33
|
+
offset: () => 4,
|
|
34
|
+
onDismiss: () => this.requestOpen(false),
|
|
35
|
+
placement: 'bottom-start',
|
|
36
|
+
surface: () => this.renderRoot?.querySelector('[part="surface"]'),
|
|
37
|
+
});
|
|
23
38
|
this.handleInput = (event) => {
|
|
24
39
|
event.stopPropagation();
|
|
25
40
|
const control = event.target;
|
|
26
41
|
this.requestValue(control.value);
|
|
27
42
|
restoreValue(control, this.value);
|
|
43
|
+
this.dismissed = false;
|
|
44
|
+
this.settleOpen();
|
|
28
45
|
};
|
|
29
46
|
this.handleChange = (event) => {
|
|
30
47
|
event.stopPropagation();
|
|
@@ -39,7 +56,68 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
39
56
|
}
|
|
40
57
|
};
|
|
41
58
|
this.handleKeydown = (event) => {
|
|
42
|
-
this.tokenKeydown(event, this.value)
|
|
59
|
+
if (this.tokenKeydown(event, this.value)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!this.suggesting) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
switch (event.key) {
|
|
66
|
+
case 'ArrowDown':
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
this.move(1);
|
|
69
|
+
return;
|
|
70
|
+
case 'ArrowUp':
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
this.move(-1);
|
|
73
|
+
return;
|
|
74
|
+
case 'Home':
|
|
75
|
+
if (this.open) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
this.activeAt = 0;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
case 'End':
|
|
81
|
+
if (this.open) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
this.activeAt = this.suggestions.length - 1;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
case 'Escape':
|
|
87
|
+
if (this.open) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
event.stopPropagation();
|
|
90
|
+
this.dismissed = true;
|
|
91
|
+
this.activeAt = -1;
|
|
92
|
+
this.requestOpen(false);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
case 'Enter': {
|
|
96
|
+
const active = this.suggestions[this.activeAt];
|
|
97
|
+
if (!this.open || !active) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
active.select();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
this.handleSuggestions = () => {
|
|
108
|
+
this.refreshSuggesting();
|
|
109
|
+
this.settleOpen();
|
|
110
|
+
};
|
|
111
|
+
this.handleFocusIn = () => {
|
|
112
|
+
this.settleOpen();
|
|
113
|
+
};
|
|
114
|
+
this.handleFocusOut = (event) => {
|
|
115
|
+
const next = event.relatedTarget;
|
|
116
|
+
if (next instanceof Node && this.contains(next)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.activeAt = -1;
|
|
120
|
+
this.requestOpen(false);
|
|
43
121
|
};
|
|
44
122
|
this.handleTokens = () => {
|
|
45
123
|
this.over = this.hideOverflow(this.tokens);
|
|
@@ -58,6 +136,56 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
58
136
|
get showsClear() {
|
|
59
137
|
return this.clearable && this.value !== '' && !this.isDisabled;
|
|
60
138
|
}
|
|
139
|
+
get suggestions() {
|
|
140
|
+
const slot = this.renderRoot?.querySelector('slot[name="suggestions"]');
|
|
141
|
+
const found = [];
|
|
142
|
+
for (const node of slot?.assignedElements({ flatten: true }) ?? []) {
|
|
143
|
+
if (isOption(node)) {
|
|
144
|
+
found.push(node);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const child of node.children) {
|
|
148
|
+
if (isOption(child)) {
|
|
149
|
+
found.push(child);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return found.filter((option) => !option.disabled);
|
|
154
|
+
}
|
|
155
|
+
get selectionDirection() {
|
|
156
|
+
return this.control?.selectionDirection ?? 'none';
|
|
157
|
+
}
|
|
158
|
+
get selectionEnd() {
|
|
159
|
+
return this.control?.selectionEnd ?? 0;
|
|
160
|
+
}
|
|
161
|
+
get selectionStart() {
|
|
162
|
+
return this.control?.selectionStart ?? 0;
|
|
163
|
+
}
|
|
164
|
+
setSelectionRange(start, end, direction) {
|
|
165
|
+
this.control?.setSelectionRange(start, end, direction);
|
|
166
|
+
}
|
|
167
|
+
settleOpen() {
|
|
168
|
+
const focused = this.matches(':focus-within') || this.contains(document.activeElement);
|
|
169
|
+
this.requestOpen(this.suggesting && !this.dismissed && !this.isDisabled && focused);
|
|
170
|
+
}
|
|
171
|
+
move(by) {
|
|
172
|
+
const options = this.suggestions;
|
|
173
|
+
if (options.length === 0) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!this.open) {
|
|
177
|
+
this.dismissed = false;
|
|
178
|
+
this.settleOpen();
|
|
179
|
+
}
|
|
180
|
+
const from = this.activeAt;
|
|
181
|
+
this.activeAt =
|
|
182
|
+
from < 0 ? (by > 0 ? 0 : options.length - 1) : (from + by + options.length) % options.length;
|
|
183
|
+
}
|
|
184
|
+
refreshSuggesting() {
|
|
185
|
+
const options = this.suggestions;
|
|
186
|
+
this.suggesting = options.length > 0;
|
|
187
|
+
this.activeAt = this.activeAt < options.length ? this.activeAt : -1;
|
|
188
|
+
}
|
|
61
189
|
removeLastToken() {
|
|
62
190
|
const last = this.tokens?.assignedElements({ flatten: true }).at(-1);
|
|
63
191
|
if (asksToGo(last)) {
|
|
@@ -74,23 +202,46 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
74
202
|
this.releaseCaret = holdCaret(control);
|
|
75
203
|
}
|
|
76
204
|
this.handleTokens();
|
|
205
|
+
this.refreshSuggesting();
|
|
77
206
|
}
|
|
78
207
|
firstUpdated(changed) {
|
|
79
208
|
super.firstUpdated(changed);
|
|
80
209
|
this.handleTokens();
|
|
210
|
+
this.refreshSuggesting();
|
|
81
211
|
}
|
|
82
212
|
updated(changed) {
|
|
83
213
|
super.updated(changed);
|
|
84
214
|
this.releaseCaret?.();
|
|
85
215
|
this.releaseCaret = undefined;
|
|
86
216
|
this.internals.setFormValue(this.value);
|
|
217
|
+
this.markActive();
|
|
218
|
+
if (this.open) {
|
|
219
|
+
this.panel.show();
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
this.panel.hide();
|
|
223
|
+
}
|
|
224
|
+
this.settleOpen();
|
|
225
|
+
}
|
|
226
|
+
markActive() {
|
|
227
|
+
const options = this.suggestions;
|
|
228
|
+
const active = this.open ? options[this.activeAt] : undefined;
|
|
229
|
+
for (const [at, option] of options.entries()) {
|
|
230
|
+
option.active = this.open && at === this.activeAt;
|
|
231
|
+
}
|
|
232
|
+
const control = this.control;
|
|
233
|
+
if (control && 'ariaActiveDescendantElement' in control) {
|
|
234
|
+
;
|
|
235
|
+
control.ariaActiveDescendantElement = active ?? null;
|
|
236
|
+
}
|
|
237
|
+
active?.scrollIntoView({ block: 'nearest' });
|
|
87
238
|
}
|
|
88
239
|
focus(options) {
|
|
89
240
|
this.control?.focus(options);
|
|
90
241
|
}
|
|
91
242
|
render() {
|
|
92
243
|
return html `${this.renderLabel()}
|
|
93
|
-
<div part="field">
|
|
244
|
+
<div part="field" @focusin=${this.handleFocusIn} @focusout=${this.handleFocusOut}>
|
|
94
245
|
<slot name="start"></slot>
|
|
95
246
|
${this.renderTokenRow(html `<slot name="tags" @slotchange=${this.handleTokens}></slot>`, this.over)}
|
|
96
247
|
<input
|
|
@@ -101,6 +252,10 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
101
252
|
name=${this.name || nothing}
|
|
102
253
|
placeholder=${this.placeholder || nothing}
|
|
103
254
|
aria-describedby=${this.describedBy ?? nothing}
|
|
255
|
+
role=${this.suggesting ? 'combobox' : nothing}
|
|
256
|
+
aria-autocomplete=${this.suggesting ? 'list' : nothing}
|
|
257
|
+
aria-controls=${this.suggesting ? 'suggestions' : nothing}
|
|
258
|
+
aria-expanded=${this.suggesting ? (this.open ? 'true' : 'false') : nothing}
|
|
104
259
|
?disabled=${this.isDisabled}
|
|
105
260
|
?required=${this.required}
|
|
106
261
|
@input=${this.handleInput}
|
|
@@ -119,6 +274,14 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinToken
|
|
|
119
274
|
</button>`
|
|
120
275
|
: nothing}
|
|
121
276
|
</div>
|
|
277
|
+
<!-- **Always rendered, and hidden rather than absent**, for the reason the
|
|
278
|
+
token row is: a slot that is not in the tree assigns nothing, so a panel
|
|
279
|
+
built only once there were options could never learn that there are
|
|
280
|
+
any. A manual popover puts it in the top layer, so it is not clipped by
|
|
281
|
+
a scrolling table or an overflow on an ancestor. -->
|
|
282
|
+
<div part="surface" id="suggestions" role="listbox" popover="manual" ?hidden=${!this.open}>
|
|
283
|
+
<slot name="suggestions" @slotchange=${this.handleSuggestions}></slot>
|
|
284
|
+
</div>
|
|
122
285
|
${this.renderHint()}${this.renderFeedback()}`;
|
|
123
286
|
}
|
|
124
287
|
};
|
|
@@ -134,6 +297,12 @@ __decorate([
|
|
|
134
297
|
__decorate([
|
|
135
298
|
state()
|
|
136
299
|
], HelixHeadlessFilterInput.prototype, "over", void 0);
|
|
300
|
+
__decorate([
|
|
301
|
+
state()
|
|
302
|
+
], HelixHeadlessFilterInput.prototype, "activeAt", void 0);
|
|
303
|
+
__decorate([
|
|
304
|
+
state()
|
|
305
|
+
], HelixHeadlessFilterInput.prototype, "suggesting", void 0);
|
|
137
306
|
HelixHeadlessFilterInput = __decorate([
|
|
138
307
|
customElement('helix-headless-filter-input')
|
|
139
308
|
], HelixHeadlessFilterInput);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-headless",
|
|
3
3
|
"description": "Headless component tier. Structure, accessibility, state, and form participation. No styling.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
},
|
|
46
46
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@fusion.dev/helix-core": "0.
|
|
49
|
-
"@fusion.dev/helix-icons": "0.
|
|
48
|
+
"@fusion.dev/helix-core": "0.15.0",
|
|
49
|
+
"@fusion.dev/helix-icons": "0.15.0",
|
|
50
50
|
"uqr": "^0.1.3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@custom-elements-manifest/analyzer": "^0.11.0",
|
|
54
|
-
"@fusion.dev/helix-cem": "0.
|
|
55
|
-
"@fusion.dev/helix-testing": "0.
|
|
54
|
+
"@fusion.dev/helix-cem": "0.15.0",
|
|
55
|
+
"@fusion.dev/helix-testing": "0.15.0",
|
|
56
56
|
"@fusion.dev/scripts": "0.0.0-dev",
|
|
57
57
|
"@happy-dom/global-registrator": "^20.0.0",
|
|
58
58
|
"@lit-labs/ssr": "^3.3.0",
|