@fusion.dev/helix-headless 0.177.2 → 0.178.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 +443 -9
- package/dist/filter-input/index.d.ts +1 -0
- package/dist/filter-input/root/filter-input-root.d.ts +92 -6
- package/dist/filter-input/root/filter-input-root.js +182 -10
- package/dist/filter-input/suggest.d.ts +47 -0
- package/dist/filter-input/suggest.js +17 -0
- package/dist/graph/root/graph-root.d.ts +101 -3
- package/dist/graph/root/graph-root.js +188 -9
- package/lint.json +1 -1
- package/package.json +5 -5
- package/types/jsx.d.ts +198 -18
- package/types/vue.d.ts +37 -4
- package/types/words.d.ts +17 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AriaForwardController, HelixFormElement, PopoverController } from '@fusion.dev/helix-core';
|
|
2
2
|
import { type PropertyValues, type TemplateResult } from 'lit';
|
|
3
3
|
import { HelixHeadlessFilterField } from '../field/filter-field.js';
|
|
4
|
+
import { type FilterSource } from '../suggest.js';
|
|
4
5
|
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;
|
|
5
6
|
/**
|
|
6
7
|
* @summary A text field for filters, with the active filters shown in it as tokens.
|
|
@@ -67,9 +68,15 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
67
68
|
*
|
|
68
69
|
* **The picker has an entry of its own.** `FilterMenu` draws its pages with
|
|
69
70
|
* `Menu`, which a field without a picker does not need, so this family's entry
|
|
70
|
-
* leaves it out. A page that writes
|
|
71
|
-
*
|
|
72
|
-
*
|
|
71
|
+
* leaves it out. A page that writes a `FilterMenu` imports both entries:
|
|
72
|
+
*
|
|
73
|
+
* ```js
|
|
74
|
+
* import '@fusion.dev/helix/filter-input'
|
|
75
|
+
* import '@fusion.dev/helix/filter-input/menu'
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* A picker written without the second is reported once the page has loaded,
|
|
79
|
+
* with the entry to add.
|
|
73
80
|
*
|
|
74
81
|
* **Suggestions.** Slot options into `suggestions` and the field shows them,
|
|
75
82
|
* moves through them with the arrow keys, and presses one on Enter. The option
|
|
@@ -85,6 +92,24 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
85
92
|
* completion back and put the caret after it, or the next parse reads the wrong
|
|
86
93
|
* term.
|
|
87
94
|
*
|
|
95
|
+
* **Suggestions from a source.** Set `source` to a function, and the field asks
|
|
96
|
+
* it for suggestions as the text changes and draws them as options in the
|
|
97
|
+
* panel. It is given the term at the caret, the field the term is a value of,
|
|
98
|
+
* the whole text, and an `AbortSignal`, and returns the suggestions or a
|
|
99
|
+
* promise of them. `filter-delay` waits for typing to rest before asking, and
|
|
100
|
+
* a later keystroke aborts a request still out. While a promise is pending, the
|
|
101
|
+
* panel shows `loading-text` and a screen reader hears it, then the count.
|
|
102
|
+
* Choosing a suggestion writes its value over the term at the caret, and a
|
|
103
|
+
* finished `field:value` is read into a chip as Enter would. Cancel its
|
|
104
|
+
* `helix-select` to write it yourself.
|
|
105
|
+
*
|
|
106
|
+
* ```js
|
|
107
|
+
* filter.source = async ({ field, term, signal }) => {
|
|
108
|
+
* const response = await fetch(`/values?field=${field}&q=${term}`, { signal })
|
|
109
|
+
* return response.json()
|
|
110
|
+
* }
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
88
113
|
* **State.** Three things have three different owners:
|
|
89
114
|
*
|
|
90
115
|
* - `value` is the text. The user types, the element applies it, and `input`
|
|
@@ -126,7 +151,7 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
126
151
|
* @event {HelixBeforeOpenEvent} helix-before-open - Fired before the suggestions open. Cancelable: call `preventDefault()` to keep them closed.
|
|
127
152
|
* @event {HelixDismissEvent} helix-dismiss - Fired when the user removes a token with its remove button or with Backspace in an empty field. A `FilterChip` that names a declared field sends `helix-facet` instead. Cancelable: call `preventDefault()` to keep the token.
|
|
128
153
|
* @event {HelixFacetEvent} helix-facet - Fired when a filter changes, whether it was typed, picked, or removed. Cancelable: call `preventDefault()` to leave the text and the chip as they were.
|
|
129
|
-
* @event {HelixSelectEvent} helix-select - Fired by an option in `suggestions` when it is chosen, and bubbles out of the field. Cancelable: call `preventDefault()` to stop other listeners acting on it. The field takes no action on
|
|
154
|
+
* @event {HelixSelectEvent} helix-select - Fired by an option in `suggestions` when it is chosen, and bubbles out of the field. Cancelable: call `preventDefault()` to stop other listeners acting on it. The field takes no action on an option you slotted, and you decide what it does. A suggestion from `source` is written over the term at the caret unless you cancel it.
|
|
130
155
|
* @event {Event} input - Fired when the text changes.
|
|
131
156
|
* @event {ToggleEvent} toggle - Fired when the suggestions panel opens or closes, with `newState` and `oldState`.
|
|
132
157
|
*
|
|
@@ -139,13 +164,13 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
|
|
|
139
164
|
* @csspart label - The label above the field.
|
|
140
165
|
* @csspart lead - The box before the text that holds the `start` slot.
|
|
141
166
|
* @csspart live - The live region that says how many suggestions the open panel offers. It is rendered from the first time the panel opens with suggestions.
|
|
142
|
-
* @csspart loading - Shown in the panel while the suggestions load.
|
|
167
|
+
* @csspart loading - Shown in the panel while the suggestions load: while `loading` is set, or a request to `source` is out.
|
|
143
168
|
* @csspart rest - The count of tokens that did not fit. It is a `<button>` with `expandable` and a `<span>` without it.
|
|
144
169
|
* @csspart surface - The floating panel the suggestions sit in.
|
|
145
170
|
* @csspart tags - The row the tokens sit in.
|
|
146
171
|
*
|
|
147
172
|
* @cssstate invalid - The field has an error to show, from the browser's validation or from your `feedback`.
|
|
148
|
-
* @cssstate loading - While `loading` is set.
|
|
173
|
+
* @cssstate loading - While `loading` is set, or a request to `source` is out.
|
|
149
174
|
* @cssstate no-results - `filter-suggestions` hid every suggestion, and `no-results-text` or the `no-results` slot says so.
|
|
150
175
|
* @cssstate open - The suggestions are open.
|
|
151
176
|
*/
|
|
@@ -186,6 +211,12 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
186
211
|
* an icon.
|
|
187
212
|
*/
|
|
188
213
|
clearLabel: string;
|
|
214
|
+
/**
|
|
215
|
+
* How long typing rests before `source` is asked, in milliseconds. A delay
|
|
216
|
+
* sends one request for a word in place of one for each letter. The arrow
|
|
217
|
+
* keys and Enter ask at once. `0` asks on every keystroke.
|
|
218
|
+
*/
|
|
219
|
+
filterDelay: number;
|
|
189
220
|
/**
|
|
190
221
|
* Whether the panel narrows itself to the suggestions that hold the term
|
|
191
222
|
* being typed, as React Aria's Autocomplete filters. Off, the panel shows
|
|
@@ -219,6 +250,15 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
219
250
|
* Not a label: it goes when typing starts.
|
|
220
251
|
*/
|
|
221
252
|
placeholder: string;
|
|
253
|
+
/**
|
|
254
|
+
* Where the suggestions come from: a function given the term at the caret,
|
|
255
|
+
* the field it is a value of, the whole text, and an `AbortSignal`, which
|
|
256
|
+
* returns the suggestions or a promise of them. Each is a string, or a
|
|
257
|
+
* `value` with a `label` and `disabled`. The field draws them as options in
|
|
258
|
+
* the panel, beside any you slot. A property only, there being no way to
|
|
259
|
+
* write a function in markup. See the class description.
|
|
260
|
+
*/
|
|
261
|
+
source?: FilterSource | undefined;
|
|
222
262
|
/**
|
|
223
263
|
* What a reader hears for one suggestion, where `suggestions-label` should
|
|
224
264
|
* not say it. `{count}` is the count.
|
|
@@ -465,6 +505,52 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
|
|
|
465
505
|
* would only be a copy of that one going stale.
|
|
466
506
|
*/
|
|
467
507
|
private settleOpen;
|
|
508
|
+
/** Whether a request to `source` is out, which the panel shows as loading. */
|
|
509
|
+
private fetching;
|
|
510
|
+
/** Whether the suggestions are loading: `loading`, or a request to `source` out. */
|
|
511
|
+
private get busy();
|
|
512
|
+
/** The request to `source` still out, which a later one aborts. */
|
|
513
|
+
private request;
|
|
514
|
+
/** The text `source` was last asked about, so focus alone does not ask again. */
|
|
515
|
+
private asked;
|
|
516
|
+
/** The source the suggestions drawn now came from. */
|
|
517
|
+
private used;
|
|
518
|
+
/** The options this element drew from what `source` gave, which the next answer replaces. */
|
|
519
|
+
private sourced;
|
|
520
|
+
/** The request waiting out `filterDelay`, if any. */
|
|
521
|
+
private readonly asking;
|
|
522
|
+
/**
|
|
523
|
+
* A new source, or none: what the old one gave goes, and the new one is
|
|
524
|
+
* asked where the reader is in the field.
|
|
525
|
+
*/
|
|
526
|
+
private resetSource;
|
|
527
|
+
/** Asks `source` once typing rests for `filterDelay`, or at once without one. */
|
|
528
|
+
private askSoon;
|
|
529
|
+
/**
|
|
530
|
+
* Asks `source` for suggestions for the term at the caret, and draws what
|
|
531
|
+
* it gives.
|
|
532
|
+
*
|
|
533
|
+
* **A later request aborts this one, and an answer that arrives late is
|
|
534
|
+
* dropped**, whether or not the source heeds the signal: the panel shows
|
|
535
|
+
* what the text says now.
|
|
536
|
+
*/
|
|
537
|
+
private ask;
|
|
538
|
+
/**
|
|
539
|
+
* Draws what `source` gave as options in the `suggestions` slot, in place of
|
|
540
|
+
* the ones it gave before, and leaves the ones you slotted alone. The
|
|
541
|
+
* options are this tier's `Option`, which a page on the headless tier
|
|
542
|
+
* imports; one not registered is reported.
|
|
543
|
+
*/
|
|
544
|
+
private drawSourced;
|
|
545
|
+
/**
|
|
546
|
+
* A suggestion `source` gave, chosen: its value written over the term at the
|
|
547
|
+
* caret, and a finished `field:value` read into a chip, as Enter reads it.
|
|
548
|
+
*
|
|
549
|
+
* **After every listener has had its turn**, as a chip under `chips` is
|
|
550
|
+
* drawn, so a `preventDefault()` on the `helix-select` is read and leaves the
|
|
551
|
+
* text to you.
|
|
552
|
+
*/
|
|
553
|
+
private readonly handleSourcedSelect;
|
|
468
554
|
/** Moves the highlight, wrapping, and opens the panel if it was closed. */
|
|
469
555
|
private move;
|
|
470
556
|
private readonly handleKeydown;
|
|
@@ -4,13 +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, countLabel, customElement, drawIcon, drawStatus, formatWords, HelixFacetEvent, HelixFormElement, holdCaret, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, reportRuntime, restoreValue, StatusController, scrollItemIntoView, words, wordsLocale, } from '@fusion.dev/helix-core';
|
|
7
|
+
import { AriaForwardController, countLabel, customElement, DelayController, drawIcon, drawStatus, formatWords, HelixFacetEvent, HelixFormElement, holdCaret, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, reportRuntime, restoreValue, StatusController, scrollItemIntoView, words, wordsLocale, } 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
|
import { HelixHeadlessFilterField } from '../field/filter-field.js';
|
|
12
12
|
import { readFilterText } from '../grammar.js';
|
|
13
|
-
import { currentTerm, suggests, tokenEntry } from '../suggest.js';
|
|
13
|
+
import { completeTerm, currentField, currentTerm, suggests, tokenEntry, } from '../suggest.js';
|
|
14
14
|
const picks = (element) => typeof element.apply === 'function' &&
|
|
15
15
|
Array.isArray(element.fields);
|
|
16
16
|
const asksToGo = (node) => typeof node?.dismiss === 'function';
|
|
@@ -25,6 +25,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
25
25
|
this.chips = false;
|
|
26
26
|
this.clearable = false;
|
|
27
27
|
this.clearLabel = 'Clear';
|
|
28
|
+
this.filterDelay = 0;
|
|
28
29
|
this.filterSuggestions = false;
|
|
29
30
|
this.loading = false;
|
|
30
31
|
this.loadingText = 'Loading';
|
|
@@ -59,6 +60,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
59
60
|
restoreValue(control, this.value);
|
|
60
61
|
this.dismissed = false;
|
|
61
62
|
this.settleOpen();
|
|
63
|
+
this.askSoon();
|
|
62
64
|
if (event.inputType === 'insertFromPaste') {
|
|
63
65
|
this.readFilters();
|
|
64
66
|
}
|
|
@@ -84,13 +86,27 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
84
86
|
return;
|
|
85
87
|
}
|
|
86
88
|
const field = chip.getAttribute('name') ?? '';
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
+
const declared = field === '' ? undefined : this.fields.find((one) => (one.name ?? '') === field);
|
|
90
|
+
if (!declared) {
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
event.preventDefault();
|
|
92
94
|
event.stopPropagation();
|
|
93
|
-
menu.
|
|
95
|
+
const menu = this.menuFor(field);
|
|
96
|
+
if (menu) {
|
|
97
|
+
menu.apply(field, []);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { label, operators } = declared.vocabulary;
|
|
101
|
+
const usual = operators.find((operator) => operator.default) ?? operators[0];
|
|
102
|
+
this.announceFacet({
|
|
103
|
+
arity: usual?.arity ?? 'many',
|
|
104
|
+
field,
|
|
105
|
+
label,
|
|
106
|
+
operator: usual?.name ?? '',
|
|
107
|
+
operatorLabel: '',
|
|
108
|
+
values: [],
|
|
109
|
+
});
|
|
94
110
|
};
|
|
95
111
|
this.handleClear = () => {
|
|
96
112
|
if (this.value !== '' && this.requestValue('')) {
|
|
@@ -102,6 +118,36 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
102
118
|
control.focus();
|
|
103
119
|
}
|
|
104
120
|
};
|
|
121
|
+
this.fetching = false;
|
|
122
|
+
this.sourced = [];
|
|
123
|
+
this.asking = new DelayController(this, { renders: false });
|
|
124
|
+
this.handleSourcedSelect = (event) => {
|
|
125
|
+
const option = event.composedPath()[0];
|
|
126
|
+
if (!this.sourced.includes(option)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
queueMicrotask(() => {
|
|
130
|
+
if (event.defaultPrevented || !this.sourced.includes(option)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const value = option.value;
|
|
134
|
+
const written = completeTerm(this.value, this.control?.selectionStart ?? this.value.length, value);
|
|
135
|
+
if (!this.requestValue(written.text)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const control = this.control;
|
|
139
|
+
if (control) {
|
|
140
|
+
restoreValue(control, this.value);
|
|
141
|
+
control.setSelectionRange(written.caret, written.caret);
|
|
142
|
+
}
|
|
143
|
+
this.activeAt = -1;
|
|
144
|
+
this.dismissed = false;
|
|
145
|
+
if (currentField(written.text, written.caret - 1) !== '') {
|
|
146
|
+
this.readFilters();
|
|
147
|
+
}
|
|
148
|
+
this.ask();
|
|
149
|
+
});
|
|
150
|
+
};
|
|
105
151
|
this.handleKeydown = (event) => {
|
|
106
152
|
if (event.isComposing) {
|
|
107
153
|
return;
|
|
@@ -109,6 +155,9 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
109
155
|
if (this.tokenKeydown(event, this.value)) {
|
|
110
156
|
return;
|
|
111
157
|
}
|
|
158
|
+
if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
|
|
159
|
+
this.asking.flush();
|
|
160
|
+
}
|
|
112
161
|
if (event.key === 'Enter' && !(this.open && this.suggestions[this.activeAt])) {
|
|
113
162
|
if (this.readFilters() > 0) {
|
|
114
163
|
event.preventDefault();
|
|
@@ -172,6 +221,9 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
172
221
|
};
|
|
173
222
|
this.handleFocusIn = () => {
|
|
174
223
|
this.settleOpen();
|
|
224
|
+
if (this.source && this.asked !== this.value) {
|
|
225
|
+
this.ask();
|
|
226
|
+
}
|
|
175
227
|
};
|
|
176
228
|
this.handleFocusOut = (event) => {
|
|
177
229
|
const next = event.relatedTarget;
|
|
@@ -220,7 +272,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
220
272
|
}
|
|
221
273
|
}
|
|
222
274
|
sayCount() {
|
|
223
|
-
const count = this.open && this.suggesting ? (this.
|
|
275
|
+
const count = this.open && this.suggesting ? (this.busy ? -2 : this.suggestions.length) : -1;
|
|
224
276
|
if (count === this.saidCount) {
|
|
225
277
|
return;
|
|
226
278
|
}
|
|
@@ -360,6 +412,109 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
360
412
|
const focused = this.matches(':focus-within') || this.contains(document.activeElement);
|
|
361
413
|
this.requestOpen(this.suggesting && !this.dismissed && !this.isDisabled && focused);
|
|
362
414
|
}
|
|
415
|
+
get busy() {
|
|
416
|
+
return this.loading || this.fetching;
|
|
417
|
+
}
|
|
418
|
+
resetSource() {
|
|
419
|
+
if (this.source === this.used) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.used = this.source;
|
|
423
|
+
this.request?.abort();
|
|
424
|
+
this.request = undefined;
|
|
425
|
+
this.fetching = false;
|
|
426
|
+
this.asked = undefined;
|
|
427
|
+
if (this.sourced.length > 0) {
|
|
428
|
+
this.drawSourced([]);
|
|
429
|
+
}
|
|
430
|
+
if (this.source && (this.matches(':focus-within') || this.contains(document.activeElement))) {
|
|
431
|
+
this.ask();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
askSoon() {
|
|
435
|
+
if (!this.source) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.asking.cancel();
|
|
439
|
+
if (this.filterDelay > 0) {
|
|
440
|
+
this.asking.start(this.filterDelay, () => this.ask());
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
this.ask();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
ask() {
|
|
447
|
+
this.asking.cancel();
|
|
448
|
+
const source = this.source;
|
|
449
|
+
if (!source) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.used = source;
|
|
453
|
+
this.request?.abort();
|
|
454
|
+
const request = new AbortController();
|
|
455
|
+
this.request = request;
|
|
456
|
+
const text = this.value;
|
|
457
|
+
const caret = this.control?.selectionStart ?? text.length;
|
|
458
|
+
this.asked = text;
|
|
459
|
+
const take = (suggestions) => {
|
|
460
|
+
if (this.request !== request) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.request = undefined;
|
|
464
|
+
this.fetching = false;
|
|
465
|
+
this.drawSourced(Array.isArray(suggestions) ? suggestions : []);
|
|
466
|
+
};
|
|
467
|
+
let answer;
|
|
468
|
+
try {
|
|
469
|
+
answer = source({
|
|
470
|
+
field: currentField(text, caret),
|
|
471
|
+
signal: request.signal,
|
|
472
|
+
term: currentTerm(text, caret),
|
|
473
|
+
text,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
take([]);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (typeof answer?.then !== 'function') {
|
|
481
|
+
take(answer);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.fetching = true;
|
|
485
|
+
answer.then(take, () => take([]));
|
|
486
|
+
}
|
|
487
|
+
drawSourced(suggestions) {
|
|
488
|
+
for (const option of this.sourced) {
|
|
489
|
+
option.remove();
|
|
490
|
+
}
|
|
491
|
+
const tag = `${this.constructor.tier}option`;
|
|
492
|
+
if (suggestions.length > 0 && !customElements.get(tag)) {
|
|
493
|
+
const entry = tag.startsWith('helix-headless-')
|
|
494
|
+
? '@fusion.dev/helix-headless'
|
|
495
|
+
: '@fusion.dev/helix';
|
|
496
|
+
reportRuntime({
|
|
497
|
+
kind: 'unregistered-part',
|
|
498
|
+
message: `<${tag}> is not registered, so the suggestions \`source\` gave cannot be chosen. Import '${entry}/option'.`,
|
|
499
|
+
tag: this.localName,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
this.sourced = suggestions.map((suggestion) => {
|
|
503
|
+
const { disabled, label, value } = typeof suggestion === 'string'
|
|
504
|
+
? { disabled: false, label: undefined, value: suggestion }
|
|
505
|
+
: suggestion;
|
|
506
|
+
const option = document.createElement(tag);
|
|
507
|
+
option.slot = 'suggestions';
|
|
508
|
+
option.setAttribute('value', value);
|
|
509
|
+
option.textContent = label ?? value;
|
|
510
|
+
if (disabled) {
|
|
511
|
+
option.setAttribute('disabled', '');
|
|
512
|
+
}
|
|
513
|
+
return option;
|
|
514
|
+
});
|
|
515
|
+
this.append(...this.sourced);
|
|
516
|
+
this.handleSuggestions();
|
|
517
|
+
}
|
|
363
518
|
move(by) {
|
|
364
519
|
const options = this.suggestions;
|
|
365
520
|
if (options.length === 0) {
|
|
@@ -376,12 +531,12 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
376
531
|
refreshSuggesting() {
|
|
377
532
|
this.narrow();
|
|
378
533
|
const options = this.suggestions;
|
|
379
|
-
this.suggesting = options.length > 0 || this.
|
|
534
|
+
this.suggesting = options.length > 0 || this.busy || this.unmatched;
|
|
380
535
|
this.activeAt = this.activeAt < options.length ? this.activeAt : -1;
|
|
381
536
|
}
|
|
382
537
|
get unmatched() {
|
|
383
538
|
const said = this.noResultsText !== '' || this.knownChildren.some((child) => child.slot === 'no-results');
|
|
384
|
-
return said && !this.
|
|
539
|
+
return said && !this.busy && this.options.length > 0 && this.suggestions.length === 0;
|
|
385
540
|
}
|
|
386
541
|
tokenElements() {
|
|
387
542
|
return (this.tokens?.assignedElements({ flatten: true }) ?? []);
|
|
@@ -399,11 +554,16 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
399
554
|
super.connectedCallback();
|
|
400
555
|
this.addEventListener('helix-dismiss', this.handleDismiss, { capture: true });
|
|
401
556
|
this.addEventListener(HelixFacetEvent.eventName, this.handleFacet);
|
|
557
|
+
this.addEventListener('helix-select', this.handleSourcedSelect);
|
|
402
558
|
}
|
|
403
559
|
disconnectedCallback() {
|
|
404
560
|
super.disconnectedCallback();
|
|
405
561
|
this.removeEventListener('helix-dismiss', this.handleDismiss, { capture: true });
|
|
406
562
|
this.removeEventListener(HelixFacetEvent.eventName, this.handleFacet);
|
|
563
|
+
this.removeEventListener('helix-select', this.handleSourcedSelect);
|
|
564
|
+
this.request?.abort();
|
|
565
|
+
this.request = undefined;
|
|
566
|
+
this.fetching = false;
|
|
407
567
|
}
|
|
408
568
|
willUpdate(changed) {
|
|
409
569
|
super.willUpdate(changed);
|
|
@@ -420,7 +580,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
420
580
|
}
|
|
421
581
|
this.handleTokens();
|
|
422
582
|
this.refreshSuggesting();
|
|
423
|
-
this.toggleState('loading', this.
|
|
583
|
+
this.toggleState('loading', this.busy);
|
|
424
584
|
this.toggleState('no-results', this.unmatched);
|
|
425
585
|
}
|
|
426
586
|
firstUpdated(changed) {
|
|
@@ -455,6 +615,9 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
455
615
|
}
|
|
456
616
|
updated(changed) {
|
|
457
617
|
super.updated(changed);
|
|
618
|
+
if (changed.has('source')) {
|
|
619
|
+
queueMicrotask(() => this.resetSource());
|
|
620
|
+
}
|
|
458
621
|
this.releaseCaret?.();
|
|
459
622
|
this.releaseCaret = undefined;
|
|
460
623
|
this.internals.setFormValue(this.formValue(), this.value);
|
|
@@ -543,7 +706,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
|
|
|
543
706
|
<slot name="suggestions" @slotchange=${this.handleSuggestions}></slot>
|
|
544
707
|
<!-- Hidden from the tree, which a listbox keeps for its options: the
|
|
545
708
|
live region says these. -->
|
|
546
|
-
<div part="loading" aria-hidden="true" ?hidden=${!this.
|
|
709
|
+
<div part="loading" aria-hidden="true" ?hidden=${!this.busy}>
|
|
547
710
|
<slot name="loading">${this.loadingText}</slot>
|
|
548
711
|
</div>
|
|
549
712
|
<div part="empty" aria-hidden="true" ?hidden=${!this.unmatched}>
|
|
@@ -564,6 +727,9 @@ __decorate([
|
|
|
564
727
|
words,
|
|
565
728
|
property({ attribute: 'clear-label' })
|
|
566
729
|
], HelixHeadlessFilterInput.prototype, "clearLabel", void 0);
|
|
730
|
+
__decorate([
|
|
731
|
+
property({ attribute: 'filter-delay', type: Number })
|
|
732
|
+
], HelixHeadlessFilterInput.prototype, "filterDelay", void 0);
|
|
567
733
|
__decorate([
|
|
568
734
|
property({ attribute: 'filter-suggestions', type: Boolean })
|
|
569
735
|
], HelixHeadlessFilterInput.prototype, "filterSuggestions", void 0);
|
|
@@ -580,6 +746,9 @@ __decorate([
|
|
|
580
746
|
__decorate([
|
|
581
747
|
property()
|
|
582
748
|
], HelixHeadlessFilterInput.prototype, "placeholder", void 0);
|
|
749
|
+
__decorate([
|
|
750
|
+
property({ attribute: false })
|
|
751
|
+
], HelixHeadlessFilterInput.prototype, "source", void 0);
|
|
583
752
|
__decorate([
|
|
584
753
|
property({ attribute: 'suggestion-label' })
|
|
585
754
|
], HelixHeadlessFilterInput.prototype, "suggestionLabel", void 0);
|
|
@@ -599,6 +768,9 @@ __decorate([
|
|
|
599
768
|
__decorate([
|
|
600
769
|
state()
|
|
601
770
|
], HelixHeadlessFilterInput.prototype, "leading", void 0);
|
|
771
|
+
__decorate([
|
|
772
|
+
state()
|
|
773
|
+
], HelixHeadlessFilterInput.prototype, "fetching", void 0);
|
|
602
774
|
HelixHeadlessFilterInput = __decorate([
|
|
603
775
|
customElement('helix-headless-filter-input')
|
|
604
776
|
], HelixHeadlessFilterInput);
|
|
@@ -7,6 +7,53 @@
|
|
|
7
7
|
* and after its colon where it has one, so `status:ope` narrows by `ope`.
|
|
8
8
|
*/
|
|
9
9
|
export declare const currentTerm: (text: string, caret?: number) => string;
|
|
10
|
+
/**
|
|
11
|
+
* The field the term at the caret is a value of: the word before its colon,
|
|
12
|
+
* without a leading `-`, so `-status:ope` is a value of `status`. Empty where
|
|
13
|
+
* the term has no colon, which is a key being typed.
|
|
14
|
+
*/
|
|
15
|
+
export declare const currentField: (text: string, caret?: number) => string;
|
|
16
|
+
/**
|
|
17
|
+
* The text with a suggestion written over the term at the caret, and where
|
|
18
|
+
* the caret goes after it.
|
|
19
|
+
*
|
|
20
|
+
* The word's field and its colon stay, and a leading `-`, so a value chosen
|
|
21
|
+
* for `-status:op` gives `-status:open`. A space follows, ready for the next
|
|
22
|
+
* term, unless the suggestion ends with a colon, which is a key whose value
|
|
23
|
+
* comes next.
|
|
24
|
+
*/
|
|
25
|
+
export declare const completeTerm: (text: string, caret: number, value: string) => {
|
|
26
|
+
caret: number;
|
|
27
|
+
text: string;
|
|
28
|
+
};
|
|
29
|
+
/** One suggestion a `source` gives: its words, or its value with the words a reader sees. */
|
|
30
|
+
export type FilterSuggestion = string | {
|
|
31
|
+
/** Shown, and skipped by the arrow keys. */
|
|
32
|
+
disabled?: boolean | undefined;
|
|
33
|
+
/** What a reader sees. The value where unset. */
|
|
34
|
+
label?: string | undefined;
|
|
35
|
+
/** What is written over the term when the suggestion is chosen. */
|
|
36
|
+
value: string;
|
|
37
|
+
};
|
|
38
|
+
/** What a `source` is asked for. */
|
|
39
|
+
export interface FilterSourceRequest {
|
|
40
|
+
/**
|
|
41
|
+
* The field the term is a value of, where the caret is after `field:`, as
|
|
42
|
+
* `currentField` reads it. Empty while a key is being typed.
|
|
43
|
+
*/
|
|
44
|
+
field: string;
|
|
45
|
+
/** Aborted once a later keystroke asks again, or the field leaves the page. */
|
|
46
|
+
signal: AbortSignal;
|
|
47
|
+
/** The term at the caret, after its colon, as `currentTerm` reads it. */
|
|
48
|
+
term: string;
|
|
49
|
+
/** The whole text. */
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Where the suggestions come from: given the term at the caret, the
|
|
54
|
+
* suggestions for it, or a promise of them.
|
|
55
|
+
*/
|
|
56
|
+
export type FilterSource = (request: FilterSourceRequest) => readonly FilterSuggestion[] | PromiseLike<readonly FilterSuggestion[]>;
|
|
10
57
|
/** Whether a suggestion's words hold the term, ignoring case and accents. */
|
|
11
58
|
export declare const suggests: (words: string, term: string) => boolean;
|
|
12
59
|
/** What a token says about the filter it shows, as `tokenEntry` reads it. */
|
|
@@ -4,6 +4,23 @@ export const currentTerm = (text, caret = text.length) => {
|
|
|
4
4
|
const colon = word.indexOf(':');
|
|
5
5
|
return (colon === -1 ? word : word.slice(colon + 1)).replace(/^-/u, '');
|
|
6
6
|
};
|
|
7
|
+
export const currentField = (text, caret = text.length) => {
|
|
8
|
+
const before = text.slice(0, caret);
|
|
9
|
+
const word = before.slice(before.search(/\S*$/u));
|
|
10
|
+
const colon = word.indexOf(':');
|
|
11
|
+
return colon === -1 ? '' : word.slice(0, colon).replace(/^-/u, '');
|
|
12
|
+
};
|
|
13
|
+
export const completeTerm = (text, caret, value) => {
|
|
14
|
+
const before = text.slice(0, caret);
|
|
15
|
+
const start = before.search(/\S*$/u);
|
|
16
|
+
const word = before.slice(start);
|
|
17
|
+
const colon = word.indexOf(':');
|
|
18
|
+
const kept = colon === -1 ? (word.startsWith('-') ? '-' : '') : word.slice(0, colon + 1);
|
|
19
|
+
const after = text.slice(caret).replace(/^\S*/u, '');
|
|
20
|
+
const space = value.endsWith(':') ? '' : ' ';
|
|
21
|
+
const written = `${text.slice(0, start)}${kept}${value}${space}`;
|
|
22
|
+
return { caret: written.length, text: `${written}${space ? after.replace(/^\s+/u, '') : after}` };
|
|
23
|
+
};
|
|
7
24
|
export const suggests = (words, term) => {
|
|
8
25
|
const plain = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
9
26
|
return term === '' || plain(words).includes(plain(term));
|