@truenas/ui-components 0.2.6 → 0.3.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.
@@ -268,10 +268,20 @@ class TnAutocompleteComponent {
268
268
  viewContainerRef = inject(ViewContainerRef);
269
269
  /** Unique instance ID for ARIA linkage */
270
270
  uid = `tn-autocomplete-${nextId$1++}`;
271
- /** All available options */
271
+ /**
272
+ * All available options. The `label` is displayed; the `value` is committed
273
+ * to the form control. A written value is resolved back to its option's
274
+ * label for display — falling back to `String(value)` until the matching
275
+ * option is available, and upgraded once an async option load lands.
276
+ */
272
277
  options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
273
- /** Transform a value to its display string */
274
- displayWith = input((v) => String(v), ...(ngDevMode ? [{ debugName: "displayWith" }] : []));
278
+ /**
279
+ * Custom comparator for matching a written control value against option
280
+ * values during display resolution. Defaults to identity (`===`), which is
281
+ * right for primitive values; provide this when option values are objects.
282
+ * Mirrors `tn-select`'s `compareWith`.
283
+ */
284
+ compareWith = input(undefined, ...(ngDevMode ? [{ debugName: "compareWith" }] : []));
275
285
  /** Placeholder text for the input */
276
286
  placeholder = input('Type to search...', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
277
287
  /** Whether the input is disabled */
@@ -293,7 +303,7 @@ class TnAutocompleteComponent {
293
303
  loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
294
304
  /** Text shown next to the spinner while `loading` is set. */
295
305
  loadingText = input('Loading...', ...(ngDevMode ? [{ debugName: "loadingText" }] : []));
296
- /** Custom filter function. Defaults to case-insensitive includes on displayWith text */
306
+ /** Custom filter function. Defaults to case-insensitive includes on the option label */
297
307
  filterFn = input(undefined, ...(ngDevMode ? [{ debugName: "filterFn" }] : []));
298
308
  /** Text shown when no options match the search */
299
309
  noResultsText = input('No results found', ...(ngDevMode ? [{ debugName: "noResultsText" }] : []));
@@ -314,7 +324,7 @@ class TnAutocompleteComponent {
314
324
  panelMaxHeight = input('320px', ...(ngDevMode ? [{ debugName: "panelMaxHeight" }] : []));
315
325
  /** Test ID attribute */
316
326
  testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
317
- /** Emits when an option is selected */
327
+ /** Emits the full option (label + value) when one is selected */
318
328
  optionSelected = output();
319
329
  /**
320
330
  * Emits the search term as the user types (not on programmatic writes or
@@ -354,7 +364,7 @@ class TnAutocompleteComponent {
354
364
  isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
355
365
  /** Index of the currently highlighted option for keyboard nav */
356
366
  highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : []));
357
- /** The currently selected value */
367
+ /** The currently committed value (an option's `value`, or a custom-typed one) */
358
368
  selectedValue = signal(null, ...(ngDevMode ? [{ debugName: "selectedValue" }] : []));
359
369
  /** CVA disabled state from the form */
360
370
  formDisabled = signal(false, ...(ngDevMode ? [{ debugName: "formDisabled" }] : []));
@@ -365,7 +375,6 @@ class TnAutocompleteComponent {
365
375
  const term = this.searchTerm();
366
376
  const all = this.options();
367
377
  const customFilter = this.filterFn();
368
- const display = this.displayWith();
369
378
  const max = this.maxResults();
370
379
  if (!term) {
371
380
  return all.slice(0, max);
@@ -373,7 +382,7 @@ class TnAutocompleteComponent {
373
382
  const lowerTerm = term.toLowerCase();
374
383
  const filtered = customFilter
375
384
  ? all.filter((opt) => customFilter(opt, term))
376
- : all.filter((opt) => display(opt).toLowerCase().includes(lowerTerm));
385
+ : all.filter((opt) => opt.label.toLowerCase().includes(lowerTerm));
377
386
  return filtered.slice(0, max);
378
387
  }, ...(ngDevMode ? [{ debugName: "filteredOptions" }] : []));
379
388
  /** Whether there are any results to show */
@@ -392,7 +401,32 @@ class TnAutocompleteComponent {
392
401
  lastOptionsCount = null;
393
402
  /** Scroll distance (px) from the panel bottom that triggers `loadMore`. */
394
403
  static loadMoreThresholdPx = 48;
404
+ /** Guards the object-without-compareWith warning so it fires only once. */
405
+ warnedAboutObjectCompare = false;
395
406
  constructor() {
407
+ // A value written before its option loaded displays as the raw fallback —
408
+ // once options arrive (or are relabeled, e.g. a locale change), upgrade
409
+ // the text to the option's label. UPGRADE-ONLY: when no option matches
410
+ // (e.g. a server-search picker replaced `options` after a selection), the
411
+ // current text is left alone rather than downgraded back to the raw
412
+ // value. Skipped while the panel is open so active typing isn't clobbered.
413
+ effect(() => {
414
+ this.options();
415
+ this.compareWith();
416
+ untracked(() => {
417
+ if (this.isOpen()) {
418
+ return;
419
+ }
420
+ const value = this.selectedValue();
421
+ if (value === null || value === undefined) {
422
+ return;
423
+ }
424
+ const match = this.options().find((opt) => this.valueMatches(opt.value, value));
425
+ if (match) {
426
+ this.searchTerm.set(match.label);
427
+ }
428
+ });
429
+ });
396
430
  // A changed options COUNT means the consumer answered the last loadMore
397
431
  // (or a new result set landed) — re-arm the emitter and request another
398
432
  // page if the rows still don't fill the panel. Re-arming on length (not
@@ -440,7 +474,7 @@ class TnAutocompleteComponent {
440
474
  writeValue(value) {
441
475
  this.selectedValue.set(value);
442
476
  if (value !== null && value !== undefined) {
443
- this.searchTerm.set(this.displayWith()(value));
477
+ this.searchTerm.set(this.displayValue(value));
444
478
  }
445
479
  else {
446
480
  this.searchTerm.set('');
@@ -459,7 +493,6 @@ class TnAutocompleteComponent {
459
493
  onInput(event) {
460
494
  const value = event.target.value;
461
495
  this.searchTerm.set(value);
462
- this.highlightedIndex.set(-1);
463
496
  // A new term is a new pagination context — don't hold back its first page.
464
497
  this.loadMorePending = false;
465
498
  this.searchChange.emit(value);
@@ -471,6 +504,9 @@ class TnAutocompleteComponent {
471
504
  // anchored position so it stays attached to the input.
472
505
  this.overlayRef?.updatePosition();
473
506
  }
507
+ // Typing is a fresh search, not navigation — no row is pre-highlighted.
508
+ // Set after open() so it overrides the committed-option seed below.
509
+ this.highlightedIndex.set(-1);
474
510
  }
475
511
  onFocus() {
476
512
  if (!this.isDisabled()) {
@@ -486,8 +522,7 @@ class TnAutocompleteComponent {
486
522
  }
487
523
  if (this.requireSelection()) {
488
524
  const term = this.searchTerm();
489
- const display = this.displayWith();
490
- const match = this.options().find((opt) => display(opt).toLowerCase() === term.toLowerCase());
525
+ const match = this.options().find((opt) => !opt.disabled && opt.label.toLowerCase() === term.toLowerCase());
491
526
  if (match) {
492
527
  this.selectOption(match);
493
528
  }
@@ -495,7 +530,7 @@ class TnAutocompleteComponent {
495
530
  // Revert to last valid selection or clear
496
531
  const current = this.selectedValue();
497
532
  if (current !== null && current !== undefined) {
498
- this.searchTerm.set(display(current));
533
+ this.searchTerm.set(this.displayValue(current));
499
534
  }
500
535
  else {
501
536
  this.searchTerm.set('');
@@ -507,6 +542,9 @@ class TnAutocompleteComponent {
507
542
  this.onTouched();
508
543
  }
509
544
  onOptionClick(option) {
545
+ if (option.disabled) {
546
+ return;
547
+ }
510
548
  this.selectOption(option);
511
549
  }
512
550
  onKeydown(event) {
@@ -518,21 +556,27 @@ class TnAutocompleteComponent {
518
556
  this.open();
519
557
  }
520
558
  else {
521
- this.highlightedIndex.update((i) => i < options.length - 1 ? i + 1 : 0);
522
- this.scrollToHighlighted();
559
+ const next = this.nextEnabledIndex(this.highlightedIndex(), 1);
560
+ if (next >= 0) {
561
+ this.highlightedIndex.set(next);
562
+ this.scrollToHighlighted();
563
+ }
523
564
  }
524
565
  break;
525
566
  case 'ArrowUp':
526
567
  event.preventDefault();
527
568
  if (this.isOpen()) {
528
- this.highlightedIndex.update((i) => i > 0 ? i - 1 : options.length - 1);
529
- this.scrollToHighlighted();
569
+ const prev = this.nextEnabledIndex(this.highlightedIndex(), -1);
570
+ if (prev >= 0) {
571
+ this.highlightedIndex.set(prev);
572
+ this.scrollToHighlighted();
573
+ }
530
574
  }
531
575
  break;
532
576
  case 'Enter': {
533
577
  event.preventDefault();
534
578
  const idx = this.highlightedIndex();
535
- if (this.isOpen() && idx >= 0 && idx < options.length) {
579
+ if (this.isOpen() && idx >= 0 && idx < options.length && !options[idx].disabled) {
536
580
  this.selectOption(options[idx]);
537
581
  }
538
582
  else if (this.allowCustomValue()) {
@@ -547,7 +591,7 @@ class TnAutocompleteComponent {
547
591
  // Escape means "cancel the draft": revert to the committed value's
548
592
  // text so the upcoming blur doesn't commit the abandoned term.
549
593
  const current = this.selectedValue();
550
- this.searchTerm.set(current !== null && current !== undefined ? this.displayWith()(current) : '');
594
+ this.searchTerm.set(current !== null && current !== undefined ? this.displayValue(current) : '');
551
595
  }
552
596
  this.close();
553
597
  break;
@@ -596,7 +640,52 @@ class TnAutocompleteComponent {
596
640
  this.loadMore.emit();
597
641
  }
598
642
  }
643
+ /**
644
+ * Whether `option` carries the committed value — drives `aria-selected` so
645
+ * assistive tech announces the current choice independently of the keyboard
646
+ * cursor (which is conveyed via `aria-activedescendant`).
647
+ */
648
+ isOptionSelected(option) {
649
+ const value = this.selectedValue();
650
+ if (value === null || value === undefined) {
651
+ return false;
652
+ }
653
+ return this.valueMatches(option.value, value);
654
+ }
599
655
  // ── Internal ──
656
+ /**
657
+ * Index of the committed value's option in the visible list, or -1 when
658
+ * nothing is committed or the match is absent/disabled. Used to seed the
659
+ * keyboard cursor when the panel opens.
660
+ */
661
+ selectedOptionIndex() {
662
+ const value = this.selectedValue();
663
+ if (value === null || value === undefined) {
664
+ return -1;
665
+ }
666
+ return this.filteredOptions().findIndex((opt) => !opt.disabled && this.valueMatches(opt.value, value));
667
+ }
668
+ /**
669
+ * Next selectable option index from `from` in `direction` (+1 down, -1 up),
670
+ * skipping disabled rows and wrapping around. `from` of -1 ("nothing
671
+ * highlighted") lands on the first row going down, the last going up.
672
+ * Returns -1 when every visible option is disabled.
673
+ */
674
+ nextEnabledIndex(from, direction) {
675
+ const options = this.filteredOptions();
676
+ const count = options.length;
677
+ if (count === 0) {
678
+ return -1;
679
+ }
680
+ const start = from < 0 ? (direction === 1 ? -1 : 0) : from;
681
+ for (let step = 1; step <= count; step++) {
682
+ const idx = (((start + direction * step) % count) + count) % count;
683
+ if (!options[idx].disabled) {
684
+ return idx;
685
+ }
686
+ }
687
+ return -1;
688
+ }
600
689
  /**
601
690
  * Commit the current search term as the value (allowCustomValue mode). A
602
691
  * display match (case-insensitive, same as the requireSelection path)
@@ -605,25 +694,61 @@ class TnAutocompleteComponent {
605
694
  */
606
695
  commitCustomValue() {
607
696
  const term = this.searchTerm();
608
- const display = this.displayWith();
609
697
  const lowerTerm = term.toLowerCase();
610
- const match = this.options().find((opt) => display(opt).toLowerCase() === lowerTerm);
698
+ const match = this.options().find((opt) => !opt.disabled && opt.label.toLowerCase() === lowerTerm);
611
699
  if (match) {
612
700
  this.selectOption(match);
613
701
  return;
614
702
  }
615
- if (isDevMode() && term !== '' && this.options().some((opt) => typeof opt !== 'string')) {
616
- console.warn('[tn-autocomplete] allowCustomValue committed free text into a control whose options are '
617
- + 'not strings custom values are only sound for string-valued autocompletes.');
703
+ // Best-effort guard: silent when options haven't loaded yet (the common
704
+ // async + allowCustomValue case), where the value type can't be known.
705
+ if (isDevMode() && term !== '' && this.options().some((opt) => typeof opt.value !== 'string')) {
706
+ console.warn('[tn-autocomplete] allowCustomValue committed free text into a control whose option '
707
+ + 'values are not strings — custom values are only sound for string-valued autocompletes.');
618
708
  }
619
709
  const value = term === '' ? null : term;
620
710
  this.selectedValue.set(value);
621
711
  this.onChange(value);
622
712
  }
713
+ /**
714
+ * Resolves a committed value back to its option's display label, falling
715
+ * back to the raw value's string until the matching option is available.
716
+ */
717
+ displayValue(value) {
718
+ const match = this.options().find((opt) => this.valueMatches(opt.value, value));
719
+ return match ? match.label : String(value);
720
+ }
721
+ /**
722
+ * Compares option values, honoring `compareWith` when provided. Without one,
723
+ * falls back to identity (`===`) — which never matches structurally-equal
724
+ * objects from different references, so display resolution and selection
725
+ * silently fail. Warn once on that misuse (unconditional, like `tn-select`,
726
+ * so prod monitoring catches it) and return `false` to make it loud.
727
+ */
728
+ valueMatches(a, b) {
729
+ const comparator = this.compareWith();
730
+ if (comparator) {
731
+ return comparator(a, b);
732
+ }
733
+ if (a === b) {
734
+ return true;
735
+ }
736
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
737
+ if (!this.warnedAboutObjectCompare) {
738
+ this.warnedAboutObjectCompare = true;
739
+ console.warn('[tn-autocomplete] Comparing object option values without a `compareWith` input. ' +
740
+ 'Identity comparison will not match structurally-equal objects from different ' +
741
+ 'references, so the committed value will not resolve to its label. ' +
742
+ 'Provide `[compareWith]="(a, b) => a?.id === b?.id"` (or similar).');
743
+ }
744
+ return false;
745
+ }
746
+ return false;
747
+ }
623
748
  selectOption(option) {
624
- this.selectedValue.set(option);
625
- this.searchTerm.set(this.displayWith()(option));
626
- this.onChange(option);
749
+ this.selectedValue.set(option.value);
750
+ this.searchTerm.set(option.label);
751
+ this.onChange(option.value);
627
752
  this.optionSelected.emit(option);
628
753
  this.close();
629
754
  }
@@ -632,7 +757,13 @@ class TnAutocompleteComponent {
632
757
  return;
633
758
  }
634
759
  this.isOpen.set(true);
760
+ // Seed the keyboard cursor on the committed option so ArrowDown resumes
761
+ // from the current value (and it scrolls into view). -1 when nothing is
762
+ // committed or the value has no visible, enabled option — e.g. a custom
763
+ // free-text value, or a value whose option hasn't loaded yet.
764
+ this.highlightedIndex.set(this.selectedOptionIndex());
635
765
  this.attachOverlay();
766
+ this.scrollToHighlighted();
636
767
  this.opened.emit();
637
768
  }
638
769
  close() {
@@ -697,13 +828,13 @@ class TnAutocompleteComponent {
697
828
  }
698
829
  }
699
830
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
700
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, displayWith: { classPropertyName: "displayWith", publicName: "displayWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, maxResults: { classPropertyName: "maxResults", publicName: "maxResults", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", loadMore: "loadMore", opened: "opened" }, providers: [
831
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, maxResults: { classPropertyName: "maxResults", publicName: "maxResults", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", loadMore: "loadMore", opened: "opened" }, providers: [
701
832
  {
702
833
  provide: NG_VALUE_ACCESSOR,
703
834
  useExisting: forwardRef(() => TnAutocompleteComponent),
704
835
  multi: true,
705
836
  },
706
- ], viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"testId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n (scroll)=\"onPanelScroll($event)\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state. -->\n <div role=\"listbox\" [attr.id]=\"uid + '-dropdown'\" [attr.aria-busy]=\"loading() ? true : null\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ displayWith()(option) }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div class=\"tn-autocomplete__loading\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"loadingText()\" />\n <span>{{ loadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;padding:.25rem 0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover{background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"], dependencies: [{ kind: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
837
+ ], viewQueries: [{ propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"testId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n (scroll)=\"onPanelScroll($event)\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state. -->\n <div role=\"listbox\" [attr.id]=\"uid + '-dropdown'\" [attr.aria-busy]=\"loading() ? true : null\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div class=\"tn-autocomplete__loading\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"loadingText()\" />\n <span>{{ loadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;padding:.25rem 0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"], dependencies: [{ kind: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
707
838
  }
708
839
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, decorators: [{
709
840
  type: Component,
@@ -713,8 +844,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
713
844
  useExisting: forwardRef(() => TnAutocompleteComponent),
714
845
  multi: true,
715
846
  },
716
- ], template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"testId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n (scroll)=\"onPanelScroll($event)\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state. -->\n <div role=\"listbox\" [attr.id]=\"uid + '-dropdown'\" [attr.aria-busy]=\"loading() ? true : null\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ displayWith()(option) }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div class=\"tn-autocomplete__loading\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"loadingText()\" />\n <span>{{ loadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;padding:.25rem 0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover{background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"] }]
717
- }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], displayWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requireSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireSelection", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], filterFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFn", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], maxResults: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxResults", required: false }] }], panelMaxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelMaxHeight", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionSelected: [{ type: i0.Output, args: ["optionSelected"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], loadMore: [{ type: i0.Output, args: ["loadMore"] }], opened: [{ type: i0.Output, args: ["opened"] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
847
+ ], template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n tnTestIdType=\"autocomplete\"\n autocomplete=\"off\"\n [tnTestId]=\"testId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-controls]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-owns]=\"isOpen() ? uid + '-dropdown' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\" />\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. Attach/detach (driven by open()/close()) controls\n visibility; the input keeps DOM focus throughout. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n (scroll)=\"onPanelScroll($event)\">\n\n <!-- Per ARIA, a listbox may only contain option/group children, so the\n loading and no-results rows live OUTSIDE it as live-region siblings.\n The listbox itself stays rendered (possibly empty) the whole time the\n panel is open, so the input's aria-controls always points at a real\n element \u2014 including the loading-with-no-results-yet state. -->\n <div role=\"listbox\" [attr.id]=\"uid + '-dropdown'\" [attr.aria-busy]=\"loading() ? true : null\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div class=\"tn-autocomplete__loading\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"loadingText()\" />\n <span>{{ loadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-autocomplete{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-autocomplete__input{display:block;width:100%;min-height:2.5rem;padding:.5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;color:var(--tn-fg1, #212529);font-size:1rem;font-family:inherit;outline:none;box-sizing:border-box;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-autocomplete__input::placeholder{color:var(--tn-alt-fg1, #999)}.tn-autocomplete__input:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-autocomplete__input:focus-visible,.tn-autocomplete__input.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-autocomplete__input.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__dropdown{background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;padding:.25rem 0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"] }]
848
+ }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requireSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireSelection", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], filterFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFn", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], maxResults: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxResults", required: false }] }], panelMaxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelMaxHeight", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionSelected: [{ type: i0.Output, args: ["optionSelected"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], loadMore: [{ type: i0.Output, args: ["loadMore"] }], opened: [{ type: i0.Output, args: ["opened"] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
718
849
 
719
850
  /**
720
851
  * Harness for interacting with tn-autocomplete in tests.