@fusion.dev/helix-headless 0.177.2 → 0.179.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.
@@ -256,6 +256,18 @@ export declare class HelixHeadlessButton extends HelixFormElement {
256
256
  */
257
257
  protected announcePress(click: Event): void;
258
258
  private readonly handleClick;
259
+ /**
260
+ * Presses the button, as `click()` presses a native one: the command is sent,
261
+ * the form submits or resets, and `helix-press` says `virtual`.
262
+ *
263
+ * **Passed to the inner control, because a click on the host reaches nothing
264
+ * that acts.** The control in the shadow root is what answers a click, so
265
+ * `click()` from a script, an automation or a test, dispatched on the host,
266
+ * submitted nothing and sent no `helix-press`. The control's own click reaches
267
+ * the host's listeners once, retargeted, as one from a native button does. A
268
+ * disabled button does nothing, and a pending one refuses the press.
269
+ */
270
+ click(): void;
259
271
  /** What a press does: sends the command, and submits or resets the form. */
260
272
  private act;
261
273
  /**
@@ -89,6 +89,15 @@ let HelixHeadlessButton = class HelixHeadlessButton extends HelixFormElement {
89
89
  this.recent = {};
90
90
  this.dispatchEvent(new HelixPressEvent({ pointerType }));
91
91
  }
92
+ click() {
93
+ const control = this.partNamed('button');
94
+ if (control) {
95
+ control.click();
96
+ }
97
+ else {
98
+ super.click();
99
+ }
100
+ }
92
101
  act() {
93
102
  this.linkCommand();
94
103
  this.sendCommand();
@@ -207,6 +207,12 @@ export declare class HelixHeadlessDrilldownItem extends HelixElement {
207
207
  * an icon of your own replaces it by being slotted.
208
208
  */
209
209
  protected renderMark(): TemplateResult;
210
+ /**
211
+ * Presses the row, as `click()` presses a native button: passed to the
212
+ * control inside, which is what answers a click, so a script's `click()`
213
+ * chooses the row, or follows its link, once.
214
+ */
215
+ click(): void;
210
216
  protected render(): TemplateResult | typeof nothing;
211
217
  }
212
218
  declare global {
@@ -104,6 +104,15 @@ let HelixHeadlessDrilldownItem = class HelixHeadlessDrilldownItem extends HelixE
104
104
  </svg></slot
105
105
  >`;
106
106
  }
107
+ click() {
108
+ const control = this.partNamed('item');
109
+ if (control) {
110
+ control.click();
111
+ }
112
+ else {
113
+ super.click();
114
+ }
115
+ }
107
116
  render() {
108
117
  if (this.showingPage || this.onPath) {
109
118
  return html `<slot name="page" @slotchange=${this.readPage}></slot>`;
@@ -137,7 +137,13 @@ registerElementEvents({
137
137
  'helix-graph-signal',
138
138
  'helix-graph-zoom',
139
139
  ],
140
- 'helix-headless-heatmap': ['change', 'helix-before-change', 'helix-resize'],
140
+ 'helix-headless-heatmap': [
141
+ 'change',
142
+ 'helix-before-change',
143
+ 'helix-reading-change',
144
+ 'helix-reading-press',
145
+ 'helix-resize',
146
+ ],
141
147
  'helix-headless-icon': ['error', 'load'],
142
148
  'helix-headless-icon-button': ['helix-press'],
143
149
  'helix-headless-image': ['error', 'load'],
@@ -3,3 +3,4 @@ export * from './field/filter-field.js';
3
3
  export * from './grammar.js';
4
4
  export * from './operator/filter-operator.js';
5
5
  export * from './root/filter-input-root.js';
6
+ export type { FilterSource, FilterSourceRequest, FilterSuggestion } from './suggest.js';
@@ -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 one imports
71
- * `@fusion.dev/helix/filter-input/menu` as well, and a picker written without it
72
- * is reported once the page has loaded, with the entry to add.
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 a choice; you decide what it does.
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 menu = field === '' ? null : this.menuFor(field);
88
- if (!menu) {
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.apply(field, []);
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.loading ? -2 : this.suggestions.length) : -1;
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.loading || this.unmatched;
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.loading && this.options.length > 0 && this.suggestions.length === 0;
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.loading);
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.loading}>
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));