@emuanalytics/flow-rdf-ui 4.1.27 → 4.1.28

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.
@@ -15,6 +15,7 @@ import styles from './autocomplete.styles.mjs';
15
15
  import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.mjs';
16
16
  import { stopAnimations, animateTo } from '../../internal/animate.mjs';
17
17
  import { waitForEvent } from '../../internal/event.mjs';
18
+ import { scrollIntoView } from '../../internal/scroll.mjs';
18
19
 
19
20
  var __defProp = Object.defineProperty;
20
21
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -27,6 +28,7 @@ var __decorateClass = (decorators, target, key, kind) => {
27
28
  __defProp(target, key, result);
28
29
  return result;
29
30
  };
31
+ let nextAutocompleteId = 0;
30
32
  class FlowUiAutocomplete extends FlowRdfFormControl {
31
33
  constructor() {
32
34
  super(...arguments);
@@ -37,6 +39,19 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
37
39
  this.pendingChangeEvent = false;
38
40
  this.pendingInputEvent = false;
39
41
  this.hasOptions = false;
42
+ /** @internal True while the user is typing to filter (input text has diverged from the committed value). */
43
+ this.inputChanged = false;
44
+ /** @internal Stable base used to generate option DOM ids when the host has no `id` of its own. */
45
+ this.optionIdBase = "";
46
+ /** @internal Monotonic per-host suffix for option DOM ids, so ids never collide across re-renders. */
47
+ this.nextOptionId = 0;
48
+ /**
49
+ * @internal True while hide()'s close animation is in flight. `open` stays true across that
50
+ * animation (so the popup remains visible while it fades out), which would otherwise make a
51
+ * concurrent show() no-op at its `!this.open` guard. show() flips this to false to cancel the
52
+ * close and reopen; hide() bails after its animation if it finds the close was cancelled.
53
+ */
54
+ this.closing = false;
40
55
  this.formControlController = new FormControlController(this, {
41
56
  assumeInteractionOn: ["flow-ui-blur", "flow-ui-input"]
42
57
  });
@@ -82,6 +97,11 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
82
97
  this.hoist = boolean(this.hoist, true);
83
98
  this.clearOnFocus = boolean(this.clearOnFocus, true);
84
99
  }
100
+ disconnectedCallback() {
101
+ super.disconnectedCallback();
102
+ this.hasFocus = false;
103
+ this.removeListenersForDropdown();
104
+ }
85
105
  /** Gets the associated form, if one exists. */
86
106
  getForm() {
87
107
  return this.formControlController.getForm();
@@ -116,8 +136,31 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
116
136
  }
117
137
  /**
118
138
  * @internal
119
- * Un-highlights currently highlighted option
120
- * and highlights the given option
139
+ * Gives each slotted option a stable DOM `id` (`<host-id>-option-<n>`) used purely for
140
+ * ARIA wiring (`aria-activedescendant`). A consumer-provided `id` is preserved. This `id` is
141
+ * independent of the option's `value` (domain identity). See ADR-0001.
142
+ *
143
+ * The suffix comes from a monotonic per-host counter rather than the option's index: a filter
144
+ * re-render can retain some option elements while creating others, so an index-based suffix
145
+ * could hand a new element an id that a retained element already owns.
146
+ */
147
+ assignOptionIds(options) {
148
+ if (!this.optionIdBase) {
149
+ this.optionIdBase = this.id || `flow-ui-autocomplete-${++nextAutocompleteId}`;
150
+ }
151
+ options.forEach((option) => {
152
+ if (!option.id) {
153
+ option.id = `${this.optionIdBase}-option-${this.nextOptionId++}`;
154
+ }
155
+ });
156
+ }
157
+ /**
158
+ * @internal
159
+ * Un-highlights currently highlighted option and highlights the given option.
160
+ *
161
+ * DOM focus stays on the `<input>` (per the APG editable-combobox pattern); the highlighted
162
+ * ("current") option is conveyed to assistive tech via the input's `aria-activedescendant`,
163
+ * not by moving focus. See ADR-0001.
121
164
  */
122
165
  setCurrentOption(option) {
123
166
  if (this.currentOption) {
@@ -125,8 +168,19 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
125
168
  }
126
169
  this.currentOption = option;
127
170
  option.current = true;
128
- option.tabIndex = 0;
129
- option.focus();
171
+ this.setActiveDescendant(option.id);
172
+ if (this.listbox) {
173
+ scrollIntoView(option, this.listbox, "vertical", "auto");
174
+ }
175
+ }
176
+ /**
177
+ * @internal
178
+ * Sets the input's `aria-activedescendant` to the given option `id`, or clears it (to `''`,
179
+ * per the APG reference) when passed `null` or an empty id. Centralises the ARIA wiring so the
180
+ * highlight and close paths stay in sync. See ADR-0001.
181
+ */
182
+ setActiveDescendant(id) {
183
+ this.input?.setAttribute("aria-activedescendant", id || "");
130
184
  }
131
185
  /**
132
186
  * @internal
@@ -171,6 +225,10 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
171
225
  /** @internal Handle the `flow-ui-input` event from the input box. */
172
226
  handleInput(event) {
173
227
  event.stopPropagation();
228
+ this.inputChanged = true;
229
+ if (this.open) {
230
+ this.highlightCurrentOption();
231
+ }
174
232
  this.debouncedInput(this.input.value);
175
233
  }
176
234
  /** @internal Handle the `flow-ui-change` event from the input box */
@@ -226,12 +284,6 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
226
284
  const currentIndex = this.currentOption ? allOptions.indexOf(this.currentOption) : 0;
227
285
  let newIndex = Math.max(0, currentIndex);
228
286
  event.preventDefault();
229
- if (!this.open) {
230
- this.show();
231
- if (this.currentOption) {
232
- return;
233
- }
234
- }
235
287
  switch (event.key) {
236
288
  case "ArrowDown": {
237
289
  newIndex = currentIndex + 1;
@@ -271,25 +323,39 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
271
323
  removeListenersForDropdown() {
272
324
  document.removeEventListener("mousedown", this.handleDocumentEvent);
273
325
  document.removeEventListener("focusin", this.handleDocumentEvent);
274
- document.addEventListener("keydown", this.handleDocumentEvent);
326
+ document.removeEventListener("keydown", this.handleDocumentEvent);
275
327
  }
276
328
  /** @internal Handles changes in the default slot */
277
329
  handleDefaultSlotChange() {
278
330
  const options = this.getAllOptions();
279
331
  this.hasOptions = options.length > 0;
332
+ this.assignOptionIds(options);
280
333
  this.toggleListbox();
281
- const firstOption = this.getFirstOption();
282
- if (firstOption) {
283
- this.setCurrentOption(firstOption);
284
- }
285
334
  this.setSelectedOption(null);
286
335
  for (const option of options) {
287
336
  if (option.value === this.value) {
288
337
  this.setSelectedOption(option);
289
- this.setCurrentOption(option);
290
338
  break;
291
339
  }
292
340
  }
341
+ this.highlightCurrentOption();
342
+ }
343
+ /**
344
+ * @internal
345
+ * Establishes the highlighted ("current") option per the APG rules (ADR-0001):
346
+ * - while filtering (input text has changed): always the first option, so type-then-Enter
347
+ * selects the narrowed-down top option.
348
+ * - on open with unchanged input: the selected option if one matches, else the first.
349
+ *
350
+ * Called both on slot change (list re-rendered) and on open (listbox shown without a
351
+ * re-render, e.g. clicking back into the input), since the latter fires no slot change.
352
+ */
353
+ highlightCurrentOption() {
354
+ const firstOption = this.getFirstOption();
355
+ const optionToHighlight = this.inputChanged ? firstOption : this.selectedOption ?? firstOption;
356
+ if (optionToHighlight) {
357
+ this.setCurrentOption(optionToHighlight);
358
+ }
293
359
  }
294
360
  /** @internal Toggles listbox based on focus and options in the default slot. */
295
361
  toggleListbox() {
@@ -310,13 +376,17 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
310
376
  }
311
377
  async handleValueChange(oldValue, value) {
312
378
  if (value !== oldValue) {
379
+ this.inputChanged = false;
313
380
  await this.syncDisplayValue();
314
381
  const options = this.getAllOptions();
382
+ this.assignOptionIds(options);
315
383
  this.setSelectedOption(null);
316
384
  for (const option of options) {
317
385
  if (option.value === this.value) {
318
386
  this.setSelectedOption(option);
319
- this.setCurrentOption(option);
387
+ if (this.open) {
388
+ this.setCurrentOption(option);
389
+ }
320
390
  break;
321
391
  }
322
392
  }
@@ -358,25 +428,59 @@ class FlowUiAutocomplete extends FlowRdfFormControl {
358
428
  }
359
429
  /** Shows the listbox. */
360
430
  async show() {
431
+ if (this.open && !this.closing) {
432
+ return;
433
+ }
434
+ const wasClosing = this.closing;
435
+ this.closing = false;
361
436
  if (!this.open) {
362
437
  this.emit("flow-ui-show");
363
- await stopAnimations(this);
364
- this.open = true;
365
- const { keyframes, options } = getAnimation(this, "autocomplete.show", { dir: this.localize.dir() });
366
- await animateTo(this.popup.popup, keyframes, options);
438
+ }
439
+ if (this.popup?.popup) {
440
+ await stopAnimations(this.popup.popup);
441
+ }
442
+ this.open = true;
443
+ await this.updateComplete;
444
+ this.highlightCurrentOption();
445
+ const { keyframes, options } = getAnimation(this, "autocomplete.show", { dir: this.localize.dir() });
446
+ await animateTo(this.popup.popup, keyframes, options);
447
+ if (this.currentOption && this.listbox) {
448
+ scrollIntoView(this.currentOption, this.listbox, "vertical", "auto");
449
+ }
450
+ if (!wasClosing) {
367
451
  this.emit("flow-ui-after-show");
368
452
  }
369
453
  }
370
454
  /** Hides the listbox. */
371
455
  async hide() {
372
- if (this.open) {
373
- this.emit("flow-ui-hide");
374
- await stopAnimations(this);
375
- const { keyframes, options } = getAnimation(this, "autocomplete.hide", { dir: this.localize.dir() });
376
- await animateTo(this.popup.popup, keyframes, options);
377
- this.open = false;
378
- this.emit("flow-ui-after-hide");
456
+ if (!this.open || this.closing) {
457
+ return;
458
+ }
459
+ this.closing = true;
460
+ this.emit("flow-ui-hide");
461
+ if (this.currentOption) {
462
+ this.currentOption.current = false;
463
+ }
464
+ this.currentOption = null;
465
+ this.setActiveDescendant(null);
466
+ if (this.popup?.popup) {
467
+ await stopAnimations(this.popup.popup);
379
468
  }
469
+ const { keyframes, options } = getAnimation(this, "autocomplete.hide", { dir: this.localize.dir() });
470
+ await animateTo(this.popup.popup, keyframes, options);
471
+ if (!this.closing) {
472
+ return;
473
+ }
474
+ this.open = false;
475
+ this.closing = false;
476
+ this.inputChanged = false;
477
+ const strayOption = this.currentOption;
478
+ if (strayOption) {
479
+ strayOption.current = false;
480
+ }
481
+ this.currentOption = null;
482
+ this.setActiveDescendant(null);
483
+ this.emit("flow-ui-after-hide");
380
484
  }
381
485
  /** Gets the current value of the text input */
382
486
  getInputText() {
@@ -609,6 +713,9 @@ __decorateClass([
609
713
  __decorateClass([
610
714
  query(".autocomplete")
611
715
  ], FlowUiAutocomplete.prototype, "popup", 2);
716
+ __decorateClass([
717
+ query(".autocomplete__listbox")
718
+ ], FlowUiAutocomplete.prototype, "listbox", 2);
612
719
  __decorateClass([
613
720
  watch("debounceDelay")
614
721
  ], FlowUiAutocomplete.prototype, "updateDebounceDelay", 1);