@fusion.dev/helix-headless 0.177.1 → 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.
@@ -306,19 +306,6 @@ export declare class HelixHeadlessColorInput extends HelixHeadlessColorInput_bas
306
306
  */
307
307
  protected sync(): void;
308
308
  protected updated(changed: PropertyValues): void;
309
- /** Whether the trigger's press opened the panel that is open now. */
310
- private pressedOpen;
311
- /**
312
- * The trigger's press, which notes that it opened the panel, so Escape hands
313
- * focus back to the trigger.
314
- */
315
- private readonly handlePress;
316
- /**
317
- * Hands focus back to the control that opened the panel: the trigger after
318
- * its press, which is the APG date picker dialog's rule, and the text after
319
- * ArrowDown from it.
320
- */
321
- protected returnFocus(): void;
322
309
  /**
323
310
  * The picker the field opens, behind a seam the styled tier can move.
324
311
  *
@@ -55,12 +55,6 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
55
55
  this.handleSampleEnd = () => {
56
56
  this.requestOpen(true);
57
57
  };
58
- this.pressedOpen = false;
59
- this.handlePress = () => {
60
- const opening = !this.open;
61
- this.handleTriggerClick();
62
- this.pressedOpen = opening && this.open;
63
- };
64
58
  }
65
59
  static { this.words = 'helix-color-input'; }
66
60
  requestValue(next) {
@@ -123,18 +117,6 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
123
117
  if (changed.has('value') && parseColor(this.value)) {
124
118
  this.settled = this.value;
125
119
  }
126
- if (changed.has('open') && !this.open) {
127
- this.pressedOpen = false;
128
- }
129
- }
130
- returnFocus() {
131
- const trigger = this.pressedOpen ? this.partNamed('trigger') : null;
132
- this.pressedOpen = false;
133
- if (trigger) {
134
- trigger.focus();
135
- return;
136
- }
137
- super.returnFocus();
138
120
  }
139
121
  renderPicker() {
140
122
  return html `<${this.tag('color-picker')}
@@ -222,7 +204,7 @@ let HelixHeadlessColorInput = class HelixHeadlessColorInput extends MixinPickerF
222
204
  : 'swatch-value'
223
205
  : nothing}
224
206
  ?disabled=${this.isDisabled}
225
- @click=${this.handlePress}
207
+ @click=${this.handleTriggerClick}
226
208
  >
227
209
  <span
228
210
  part="preview"
@@ -486,7 +486,13 @@ export declare class HelixHeadlessDateInput extends HelixHeadlessDateInput_base
486
486
  unavailableMessage: string;
487
487
  /**
488
488
  * The message for text that is no date, where `bad-input-message` is not
489
- * set. `{mode}` is `single`, `range`, or `multiple`.
489
+ * set. `{mode}` is `single`, `range`, or `multiple`, and `{example}` is a
490
+ * date written as the field writes one, `August 24, 2026` in `en-US` and
491
+ * `24. August 2026` in `de-DE` at the default `date-style`.
492
+ *
493
+ * **The default names a date the field reads back.** It said "Enter a date
494
+ * as YYYY-MM-DD." to a reader whose field showed `August 4, 2026`, and a
495
+ * pattern token is words for a developer. Time Input says the same.
490
496
  */
491
497
  unreadableMessage: string;
492
498
  /**
@@ -12,6 +12,7 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { html } from 'lit/static-html.js';
13
13
  import '../../date-picker/index.js';
14
14
  import '../../tag/root/index.js';
15
+ const EXAMPLE = '2026-08-24';
15
16
  const dates = (value) => value
16
17
  .replace(/\s+[–-]\s+/g, ',')
17
18
  .split(',')
@@ -119,7 +120,7 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinSegmented
119
120
  }
120
121
  };
121
122
  this.unavailableMessage = 'Choose a date that is available.';
122
- this.unreadableMessage = '{mode, select, single {Enter a date as YYYY-MM-DD.} other {Enter dates as YYYY-MM-DD, separated by commas.}}';
123
+ this.unreadableMessage = '{mode, select, single {Enter a date such as {example}.} other {Enter dates such as {example}, separated by commas.}}';
123
124
  this.yearViewLabel = '{heading}. Choose a year';
124
125
  this.handleIconChange = () => {
125
126
  this.requestUpdate();
@@ -232,7 +233,10 @@ let HelixHeadlessDateInput = class HelixHeadlessDateInput extends MixinSegmented
232
233
  const held = dates(this.value);
233
234
  if (held.length > 0 && !held.every(isDateValue)) {
234
235
  return (this.badInputMessage ||
235
- formatWords(this, 'unreadableMessage', { mode: this.mode ?? 'single' }));
236
+ formatWords(this, 'unreadableMessage', {
237
+ example: this.written(EXAMPLE),
238
+ mode: this.mode ?? 'single',
239
+ }));
236
240
  }
237
241
  if (this.min !== '' && held.some((one) => one < this.min)) {
238
242
  return [
@@ -117,8 +117,12 @@ export declare class HelixHeadlessDatePicker extends HelixHeadlessDatePicker_bas
117
117
  * **Every sentence a control raises in the browser's bubble is a property**,
118
118
  * named for the validity flag it reports, so a form in another language
119
119
  * speaks it. Unset is the sentence registered for the page's language, then
120
- * the English. `{mode}` is `single`, `range` or `multiple`, so each can have
121
- * its own sentence.
120
+ * the English. `{example}` is a date written as the calendar names one,
121
+ * `August 24, 2026` in `en-US` and `24. August 2026` in `de-DE`, and `{mode}`
122
+ * is `single`, `range` or `multiple`, so each can have its own sentence.
123
+ *
124
+ * **The default names a date in words.** It said "Enter a date as
125
+ * YYYY-MM-DD.", and a pattern token is words for a developer.
122
126
  */
123
127
  badInputMessage: string;
124
128
  /**
@@ -9,6 +9,7 @@ import { caretLeft, caretRight } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
+ const EXAMPLE = '2026-08-24';
12
13
  const KEYS = new Set([
13
14
  'ArrowDown',
14
15
  'ArrowLeft',
@@ -25,7 +26,7 @@ const firstOf = (year, month) => addMonths(asDate('2000-01-01'), (year - 2000) *
25
26
  let HelixHeadlessDatePicker = class HelixHeadlessDatePicker extends MixinPickerFeedback(MixinControllerValue(HelixFormElement)) {
26
27
  constructor() {
27
28
  super(...arguments);
28
- this.badInputMessage = '{mode, select, single {Enter a date as YYYY-MM-DD.} other {Enter dates as YYYY-MM-DD, separated by commas.}}';
29
+ this.badInputMessage = '{mode, select, single {Enter a date such as {example}.} other {Enter dates such as {example}, separated by commas.}}';
29
30
  this.defaultMonth = '';
30
31
  this.disableDates = '';
31
32
  this.disableDays = '';
@@ -491,7 +492,10 @@ let HelixHeadlessDatePicker = class HelixHeadlessDatePicker extends MixinPickerF
491
492
  if (held.length > 0 && held.some((one) => asDate(one) === null)) {
492
493
  return [
493
494
  { badInput: true },
494
- formatWords(this, 'badInputMessage', { mode: this.mode ?? 'single' }),
495
+ formatWords(this, 'badInputMessage', {
496
+ example: this.spoken(EXAMPLE),
497
+ mode: this.mode ?? 'single',
498
+ }),
495
499
  ];
496
500
  }
497
501
  for (const one of held) {
@@ -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);