@truenas/ui-components 0.3.5 → 0.3.7

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.
@@ -4375,20 +4375,37 @@ let nextId = 0;
4375
4375
  * committed to a chip on Enter (or a configurable separator key); Backspace on
4376
4376
  * an empty field removes the last chip.
4377
4377
  *
4378
- * It is a `ControlValueAccessor` over `string[]`, so it drops into a reactive
4379
- * or template-driven form (`[formControl]`, `[(ngModel)]`) and slots into a
4380
- * `tn-form-field` as a real projected control — the field's required/error
4381
- * inference reads this control directly.
4378
+ * It is a `ControlValueAccessor` over `T[]` (defaulting to `string[]`), so it
4379
+ * drops into a reactive or template-driven form (`[formControl]`, `[(ngModel)]`)
4380
+ * and slots into a `tn-form-field` as a real projected control — the field's
4381
+ * required/error inference reads this control directly.
4382
4382
  *
4383
- * Suggestions are optional: pass `[suggestions]` for a static list, or drive
4384
- * them asynchronously by listening to `(searchChange)` and updating
4385
- * `[suggestions]` as results arrive. The dropdown is portaled through a CDK
4386
- * overlay so it escapes any ancestor `overflow: hidden` (cards, side panels).
4383
+ * **String mode (default).** Pass `[suggestions]` (a `string[]`) for typeahead;
4384
+ * the typed/picked string is the value. Set `allowCustomValue=false` to restrict
4385
+ * commits to the suggestion list.
4386
+ *
4387
+ * **Value mode.** Pass `[options]` (`{ label, value }[]`) to display labels while
4388
+ * committing values — the model becomes `T[]`. A written value resolves to its
4389
+ * option's label (falling back to `String(value)` until the option is
4390
+ * available). Provide `[compareWith]` when the values are objects. Committing a
4391
+ * typed string matches an option by label (case-insensitive); free text that
4392
+ * matches no option is only accepted when `allowCustomValue` is `true` (which is
4393
+ * only sound for string-valued inputs).
4394
+ *
4395
+ * Either source can be driven asynchronously by listening to `(searchChange)`
4396
+ * and updating `[suggestions]`/`[options]` as results arrive. The dropdown is
4397
+ * portaled through a CDK overlay so it escapes any ancestor `overflow: hidden`.
4387
4398
  *
4388
4399
  * @example
4389
4400
  * ```html
4401
+ * <!-- string mode -->
4390
4402
  * <tn-form-field label="Tags">
4391
- * <tn-chip-input [formControl]="tags" placeholder="Add a tag…" />
4403
+ * <tn-chip-input [formControl]="tags" [suggestions]="tagSuggestions" />
4404
+ * </tn-form-field>
4405
+ *
4406
+ * <!-- value mode: shows names, commits ids -->
4407
+ * <tn-form-field label="Groups">
4408
+ * <tn-chip-input [formControl]="groupIds" [options]="groupOptions" [allowCustomValue]="false" />
4392
4409
  * </tn-form-field>
4393
4410
  * ```
4394
4411
  */
@@ -4409,21 +4426,43 @@ class TnChipInputComponent {
4409
4426
  separatorKeys = input(['Enter', ','], ...(ngDevMode ? [{ debugName: "separatorKeys" }] : []));
4410
4427
  /** Commit a pending (non-empty) text value as a chip when the field loses focus. */
4411
4428
  addOnBlur = input(false, ...(ngDevMode ? [{ debugName: "addOnBlur" }] : []));
4429
+ /**
4430
+ * Whether free text not matching any option/suggestion may be committed.
4431
+ * Defaults to `true` — any typed value becomes a chip. Set `false` to restrict
4432
+ * the field to its list (a "pick from the list" control): a commit only
4433
+ * succeeds when the text matches an option/suggestion label (case-insensitive,
4434
+ * committing the canonical entry); unmatched text is discarded. Mirrors
4435
+ * `tn-autocomplete`'s `allowCustomValue`. In value mode, leave this `false` —
4436
+ * fabricating a typed string as a non-string value is unsound.
4437
+ */
4438
+ allowCustomValue = input(true, ...(ngDevMode ? [{ debugName: "allowCustomValue" }] : []));
4412
4439
  /**
4413
4440
  * Allow the same value to be added more than once. Off by default.
4414
- * Duplicate detection is exact-match (case-sensitive), so with this off
4415
- * `Angular` and `angular` are still distinct values — only suggestion
4416
- * *filtering* is case-insensitive.
4441
+ * Duplicate detection uses `compareWith` (or identity); string-mode matching
4442
+ * is exact (case-sensitive), so `Angular` and `angular` are distinct — only
4443
+ * the *filtering* of suggestions is case-insensitive.
4417
4444
  */
4418
4445
  allowDuplicates = input(false, ...(ngDevMode ? [{ debugName: "allowDuplicates" }] : []));
4419
4446
  /** Hard cap on the number of chips; `undefined` means no limit. */
4420
4447
  maxChips = input(undefined, ...(ngDevMode ? [{ debugName: "maxChips" }] : []));
4421
4448
  /**
4422
- * Optional suggestion list. When non-empty, a dropdown offers entries that
4423
- * match the typed text and are not already selected. For async sources,
4424
- * update this in response to `(searchChange)`.
4449
+ * String-mode suggestion list. When non-empty, a dropdown offers entries that
4450
+ * match the typed text and are not already selected. Ignored when `options`
4451
+ * is provided. For async sources, update this in response to `(searchChange)`.
4425
4452
  */
4426
4453
  suggestions = input([], ...(ngDevMode ? [{ debugName: "suggestions" }] : []));
4454
+ /**
4455
+ * Value-mode option list (`{ label, value }`). When non-empty, chips display
4456
+ * the resolved `label` while the form model holds `value`s. Takes precedence
4457
+ * over `suggestions`. For async sources, update in response to `(searchChange)`.
4458
+ */
4459
+ options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
4460
+ /**
4461
+ * Comparator for value equality — used for de-duplication, display resolution
4462
+ * and the selected-set. Defaults to identity (`===`), correct for primitives;
4463
+ * provide this when values are objects (e.g. `(a, b) => a?.id === b?.id`).
4464
+ */
4465
+ compareWith = input(undefined, ...(ngDevMode ? [{ debugName: "compareWith" }] : []));
4427
4466
  /** Accessible name for the text field. Leave unset inside a `tn-form-field`. */
4428
4467
  ariaLabel = input(undefined, ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4429
4468
  /**
@@ -4464,15 +4503,25 @@ class TnChipInputComponent {
4464
4503
  const max = this.maxChips();
4465
4504
  return max === undefined || this.values().length < max;
4466
4505
  }, ...(ngDevMode ? [{ debugName: "canAddMore" }] : []));
4467
- /** Suggestions that match the typed text and are not already selected. */
4506
+ /**
4507
+ * Unified option list. Value-mode `options` win; otherwise string-mode
4508
+ * `suggestions` are lifted into `{ label: s, value: s }`.
4509
+ */
4510
+ optionList = computed(() => {
4511
+ const opts = this.options();
4512
+ if (opts.length) {
4513
+ return opts;
4514
+ }
4515
+ return this.suggestions().map((suggestion) => ({ label: suggestion, value: suggestion }));
4516
+ }, ...(ngDevMode ? [{ debugName: "optionList" }] : []));
4517
+ /** Options matching the typed text and not already selected. */
4468
4518
  filteredSuggestions = computed(() => {
4469
- const selected = new Set(this.values());
4470
4519
  const term = this.inputValue().trim().toLowerCase();
4471
- return this.suggestions().filter((suggestion) => {
4472
- if (selected.has(suggestion)) {
4520
+ return this.optionList().filter((option) => {
4521
+ if (this.valuesIncludes(option.value)) {
4473
4522
  return false;
4474
4523
  }
4475
- return term === '' || suggestion.toLowerCase().includes(term);
4524
+ return term === '' || option.label.toLowerCase().includes(term);
4476
4525
  });
4477
4526
  }, ...(ngDevMode ? [{ debugName: "filteredSuggestions" }] : []));
4478
4527
  onChange = () => { };
@@ -4482,10 +4531,10 @@ class TnChipInputComponent {
4482
4531
  constructor() {
4483
4532
  // Async suggestions: when the user types, onInput runs syncDropdown()
4484
4533
  // against the still-stale list and leaves the panel closed; results land a
4485
- // tick later via [suggestions]. Re-open the panel once fresh matches arrive
4486
- // while the field is focused and actively searching. This only ever opens
4487
- // (never closes), so it doesn't fight Escape, blur, or the post-commit close
4488
- // — those stay shut until the suggestion set next changes.
4534
+ // tick later via [suggestions]/[options]. Re-open the panel once fresh
4535
+ // matches arrive while the field is focused and actively searching. This
4536
+ // only ever opens (never closes), so it doesn't fight Escape, blur, or the
4537
+ // post-commit close — those stay shut until the option set next changes.
4489
4538
  effect(() => {
4490
4539
  const hasMatches = this.filteredSuggestions().length > 0;
4491
4540
  untracked(() => {
@@ -4542,7 +4591,7 @@ class TnChipInputComponent {
4542
4591
  onBlur() {
4543
4592
  this.focused.set(false);
4544
4593
  if (this.addOnBlur()) {
4545
- this.addChip(this.inputValue());
4594
+ this.commitText(this.inputValue());
4546
4595
  }
4547
4596
  this.close();
4548
4597
  this.onTouched();
@@ -4584,10 +4633,10 @@ class TnChipInputComponent {
4584
4633
  event.preventDefault();
4585
4634
  const idx = this.highlightedIndex();
4586
4635
  if (this.isOpen() && idx >= 0 && idx < suggestions.length) {
4587
- this.addChip(suggestions[idx]);
4636
+ this.commitValue(suggestions[idx].value);
4588
4637
  }
4589
4638
  else {
4590
- this.addChip(this.inputValue());
4639
+ this.commitText(this.inputValue());
4591
4640
  }
4592
4641
  return;
4593
4642
  }
@@ -4598,8 +4647,8 @@ class TnChipInputComponent {
4598
4647
  this.removeChip(this.values().length - 1);
4599
4648
  }
4600
4649
  }
4601
- onSuggestionClick(suggestion) {
4602
- this.addChip(suggestion);
4650
+ onSuggestionClick(option) {
4651
+ this.commitValue(option.value);
4603
4652
  this.inputEl().nativeElement.focus();
4604
4653
  }
4605
4654
  /** Prevents the option `mousedown` from blurring the input before the click lands. */
@@ -4611,7 +4660,7 @@ class TnChipInputComponent {
4611
4660
  return;
4612
4661
  }
4613
4662
  const removed = this.values()[index];
4614
- if (removed === undefined) {
4663
+ if (index < 0 || index >= this.values().length) {
4615
4664
  return;
4616
4665
  }
4617
4666
  this.values.update((values) => values.filter((_, i) => i !== index));
@@ -4624,6 +4673,11 @@ class TnChipInputComponent {
4624
4673
  this.inputEl().nativeElement.focus();
4625
4674
  this.syncDropdown();
4626
4675
  }
4676
+ /** The label shown on a chip for a committed value. */
4677
+ displayLabel(value) {
4678
+ const match = this.optionList().find((option) => this.valueMatches(option.value, value));
4679
+ return match ? match.label : String(value);
4680
+ }
4627
4681
  /** Scopes a per-chip test id beneath the component's base. */
4628
4682
  chipTestId(value) {
4629
4683
  const base = this.testId();
@@ -4636,12 +4690,29 @@ class TnChipInputComponent {
4636
4690
  isCommitKey(event) {
4637
4691
  return event.key === 'Enter' || this.separatorKeys().includes(event.key);
4638
4692
  }
4639
- addChip(raw) {
4640
- const value = (raw ?? '').trim();
4641
- if (!value || this.isDisabled() || !this.canAddMore()) {
4693
+ /** Commits typed text: resolve it to an option's value, else accept as custom. */
4694
+ commitText(raw) {
4695
+ const text = (raw ?? '').trim();
4696
+ if (!text) {
4697
+ return;
4698
+ }
4699
+ const match = this.optionList().find((option) => option.label.toLowerCase() === text.toLowerCase());
4700
+ if (match) {
4701
+ this.commitValue(match.value);
4702
+ return;
4703
+ }
4704
+ if (this.allowCustomValue()) {
4705
+ this.commitValue(text);
4706
+ return;
4707
+ }
4708
+ this.clearInput();
4709
+ }
4710
+ /** Commits a resolved value, honouring duplicate and cap rules. */
4711
+ commitValue(value) {
4712
+ if (this.isDisabled() || !this.canAddMore()) {
4642
4713
  return;
4643
4714
  }
4644
- if (!this.allowDuplicates() && this.values().includes(value)) {
4715
+ if (!this.allowDuplicates() && this.valuesIncludes(value)) {
4645
4716
  this.clearInput();
4646
4717
  return;
4647
4718
  }
@@ -4651,6 +4722,13 @@ class TnChipInputComponent {
4651
4722
  this.chipAdded.emit(value);
4652
4723
  this.clearInput();
4653
4724
  }
4725
+ valuesIncludes(value) {
4726
+ return this.values().some((existing) => this.valueMatches(existing, value));
4727
+ }
4728
+ valueMatches(a, b) {
4729
+ const comparator = this.compareWith();
4730
+ return comparator ? comparator(a, b) : a === b;
4731
+ }
4654
4732
  clearInput() {
4655
4733
  this.inputValue.set('');
4656
4734
  this.inputEl().nativeElement.value = '';
@@ -4659,7 +4737,7 @@ class TnChipInputComponent {
4659
4737
  /**
4660
4738
  * Opens the dropdown when there is something to show, closes it otherwise.
4661
4739
  * Stays closed once the chip cap is reached — suggesting rows that
4662
- * `addChip()` would reject is misleading.
4740
+ * `commitValue()` would reject is misleading.
4663
4741
  */
4664
4742
  syncDropdown() {
4665
4743
  if (this.filteredSuggestions().length > 0 && this.canAddMore() && !this.isDisabled()) {
@@ -4719,13 +4797,13 @@ class TnChipInputComponent {
4719
4797
  this.overlayRef = undefined;
4720
4798
  }
4721
4799
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4722
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange" }, providers: [
4800
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange" }, providers: [
4723
4801
  {
4724
4802
  provide: NG_VALUE_ACCESSOR,
4725
4803
  useExisting: forwardRef(() => TnChipInputComponent),
4726
4804
  multi: true,
4727
4805
  },
4728
- ], viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, isSignal: true }, { propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"value\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"testId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : 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 />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track suggestion) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"chipTestId(suggestion)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"], dependencies: [{ kind: "component", type: TnChipComponent, selector: "tn-chip", inputs: ["label", "icon", "closable", "disabled", "color", "testId"], outputs: ["onClose", "onClick"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
4806
+ ], viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, isSignal: true }, { propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"testId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : 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 />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"chipTestId(suggestion.value)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion.label }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"], dependencies: [{ kind: "component", type: TnChipComponent, selector: "tn-chip", inputs: ["label", "icon", "closable", "disabled", "color", "testId"], outputs: ["onClose", "onClick"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
4729
4807
  }
4730
4808
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, decorators: [{
4731
4809
  type: Component,
@@ -4735,8 +4813,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
4735
4813
  useExisting: forwardRef(() => TnChipInputComponent),
4736
4814
  multi: true,
4737
4815
  },
4738
- ], template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"value\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"testId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : 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 />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track suggestion) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"chipTestId(suggestion)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"] }]
4739
- }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], maxChips: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxChips", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], chipAdded: [{ type: i0.Output, args: ["chipAdded"] }], chipRemoved: [{ type: i0.Output, args: ["chipRemoved"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], container: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
4816
+ ], template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"testId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : 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 />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"chipTestId(suggestion.value)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"highlightedIndex() === $index\"\n (mousedown)=\"onSuggestionMousedown($event)\"\n (click)=\"onSuggestionClick(suggestion)\"\n >\n {{ suggestion.label }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"] }]
4817
+ }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], maxChips: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxChips", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], chipAdded: [{ type: i0.Output, args: ["chipAdded"] }], chipRemoved: [{ type: i0.Output, args: ["chipRemoved"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], container: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
4740
4818
 
4741
4819
  /**
4742
4820
  * Harness for interacting with `tn-chip-input` in tests. Reads the committed