@truenas/ui-components 0.3.4 → 0.3.5

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.
@@ -261,13 +261,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
261
261
  }]
262
262
  }], ctorParameters: () => [], propDecorators: { testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "tnTestId", required: false }] }], tnTestIdType: [{ type: i0.Input, args: [{ isSignal: true, alias: "tnTestIdType", required: false }] }] } });
263
263
 
264
- let nextId$1 = 0;
264
+ let nextId$2 = 0;
265
265
  class TnAutocompleteComponent {
266
266
  elementRef = inject(ElementRef);
267
267
  overlay = inject(Overlay);
268
268
  viewContainerRef = inject(ViewContainerRef);
269
269
  /** Unique instance ID for ARIA linkage */
270
- uid = `tn-autocomplete-${nextId$1++}`;
270
+ uid = `tn-autocomplete-${nextId$2++}`;
271
271
  /**
272
272
  * All available options. The `label` is displayed; the `value` is committed
273
273
  * to the form control. A written value is resolved back to its option's
@@ -3208,7 +3208,7 @@ function unitExponent(unit) {
3208
3208
  // Module-level counter for deterministic, unique instance ids (matches the
3209
3209
  // tn-autocomplete convention). Deterministic ids are SSR/hydration-safe and
3210
3210
  // stable across snapshots, unlike a Math.random() seed.
3211
- let nextId = 0;
3211
+ let nextId$1 = 0;
3212
3212
  class TnInputComponent {
3213
3213
  inputEl = viewChild.required('inputEl');
3214
3214
  inputType = input(InputType.PlainText, ...(ngDevMode ? [{ debugName: "inputType" }] : []));
@@ -3372,7 +3372,7 @@ class TnInputComponent {
3372
3372
  // Unique per instance: a hard-coded id produced duplicate ids when multiple
3373
3373
  // tn-inputs share a page (invalid HTML, and it breaks any future label `for=`,
3374
3374
  // aria-describedby, or getElementById targeting this input).
3375
- id = `tn-input-${nextId++}`;
3375
+ id = `tn-input-${nextId$1++}`;
3376
3376
  // Protected: the display string is owned by the component and driven through the
3377
3377
  // CVA flow (writeValue / onValueChange). External writes would bypass that flow.
3378
3378
  // A signal so the `[value]` binding reflects writeValue reactively — a plain field
@@ -4368,6 +4368,475 @@ class TnChipHarness extends ComponentHarness {
4368
4368
  }
4369
4369
  }
4370
4370
 
4371
+ let nextId = 0;
4372
+ /**
4373
+ * An editable, multi-value chip input — tokenized entry where typed text
4374
+ * becomes removable `tn-chip`s alongside an inline text field. Text is
4375
+ * committed to a chip on Enter (or a configurable separator key); Backspace on
4376
+ * an empty field removes the last chip.
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.
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).
4387
+ *
4388
+ * @example
4389
+ * ```html
4390
+ * <tn-form-field label="Tags">
4391
+ * <tn-chip-input [formControl]="tags" placeholder="Add a tag…" />
4392
+ * </tn-form-field>
4393
+ * ```
4394
+ */
4395
+ class TnChipInputComponent {
4396
+ overlay = inject(Overlay);
4397
+ viewContainerRef = inject(ViewContainerRef);
4398
+ /** Unique instance id for ARIA linkage between the input and its dropdown. */
4399
+ uid = `tn-chip-input-${nextId++}`;
4400
+ /** Placeholder shown in the text field when it is empty. */
4401
+ placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
4402
+ /** Disables the whole control — chips become non-removable and the field read-only. */
4403
+ disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
4404
+ /**
4405
+ * Keys that commit the current text as a chip, in addition to `Enter`.
4406
+ * Defaults to `Enter` plus comma. A separator key press never inserts its
4407
+ * own character.
4408
+ */
4409
+ separatorKeys = input(['Enter', ','], ...(ngDevMode ? [{ debugName: "separatorKeys" }] : []));
4410
+ /** Commit a pending (non-empty) text value as a chip when the field loses focus. */
4411
+ addOnBlur = input(false, ...(ngDevMode ? [{ debugName: "addOnBlur" }] : []));
4412
+ /**
4413
+ * 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.
4417
+ */
4418
+ allowDuplicates = input(false, ...(ngDevMode ? [{ debugName: "allowDuplicates" }] : []));
4419
+ /** Hard cap on the number of chips; `undefined` means no limit. */
4420
+ maxChips = input(undefined, ...(ngDevMode ? [{ debugName: "maxChips" }] : []));
4421
+ /**
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)`.
4425
+ */
4426
+ suggestions = input([], ...(ngDevMode ? [{ debugName: "suggestions" }] : []));
4427
+ /** Accessible name for the text field. Leave unset inside a `tn-form-field`. */
4428
+ ariaLabel = input(undefined, ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4429
+ /**
4430
+ * Semantic test-id base. The library prepends the `chip-input` element type
4431
+ * (e.g. `testId="tags"` → `chip-input-tags`); each chip and suggestion is
4432
+ * scoped beneath it.
4433
+ */
4434
+ testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
4435
+ /** Emits the committed value whenever a chip is added. */
4436
+ chipAdded = output();
4437
+ /** Emits the removed value whenever a chip is removed. */
4438
+ chipRemoved = output();
4439
+ /**
4440
+ * Emits the current text as the user types (not on programmatic writes or
4441
+ * chip commits). Drive server-side suggestion lookups from this; debounce in
4442
+ * the consumer if the lookup is expensive.
4443
+ */
4444
+ searchChange = output();
4445
+ container = viewChild.required('container');
4446
+ inputEl = viewChild.required('inputEl');
4447
+ dropdownTemplate = viewChild.required('dropdownTemplate');
4448
+ /** Committed chip values — the form model. */
4449
+ values = signal([], ...(ngDevMode ? [{ debugName: "values" }] : []));
4450
+ /** Current text in the field. */
4451
+ inputValue = signal('', ...(ngDevMode ? [{ debugName: "inputValue" }] : []));
4452
+ /** Whether the suggestion dropdown is open. */
4453
+ isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
4454
+ /** Index of the keyboard-highlighted suggestion, or -1. */
4455
+ highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : []));
4456
+ /** Whether the text field currently holds focus — gates async re-opening. */
4457
+ focused = signal(false, ...(ngDevMode ? [{ debugName: "focused" }] : []));
4458
+ /** CVA disabled state pushed by the form. */
4459
+ formDisabled = signal(false, ...(ngDevMode ? [{ debugName: "formDisabled" }] : []));
4460
+ /** Combined disabled state from the input and the form. */
4461
+ isDisabled = computed(() => this.disabled() || this.formDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
4462
+ /** Whether another chip may still be added under `maxChips`. */
4463
+ canAddMore = computed(() => {
4464
+ const max = this.maxChips();
4465
+ return max === undefined || this.values().length < max;
4466
+ }, ...(ngDevMode ? [{ debugName: "canAddMore" }] : []));
4467
+ /** Suggestions that match the typed text and are not already selected. */
4468
+ filteredSuggestions = computed(() => {
4469
+ const selected = new Set(this.values());
4470
+ const term = this.inputValue().trim().toLowerCase();
4471
+ return this.suggestions().filter((suggestion) => {
4472
+ if (selected.has(suggestion)) {
4473
+ return false;
4474
+ }
4475
+ return term === '' || suggestion.toLowerCase().includes(term);
4476
+ });
4477
+ }, ...(ngDevMode ? [{ debugName: "filteredSuggestions" }] : []));
4478
+ onChange = () => { };
4479
+ onTouched = () => { };
4480
+ overlayRef;
4481
+ overlaySubs = [];
4482
+ constructor() {
4483
+ // Async suggestions: when the user types, onInput runs syncDropdown()
4484
+ // 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.
4489
+ effect(() => {
4490
+ const hasMatches = this.filteredSuggestions().length > 0;
4491
+ untracked(() => {
4492
+ const activelySearching = this.focused()
4493
+ && this.inputValue().trim() !== ''
4494
+ && this.canAddMore()
4495
+ && !this.isDisabled();
4496
+ if (hasMatches && activelySearching) {
4497
+ this.open();
4498
+ }
4499
+ });
4500
+ });
4501
+ }
4502
+ ngOnDestroy() {
4503
+ this.detachOverlay();
4504
+ }
4505
+ // ── ControlValueAccessor ──
4506
+ writeValue(value) {
4507
+ // Reflect the model verbatim — deliberately NOT clamped to maxChips. A form
4508
+ // may legitimately seed more values than the cap; silently dropping them
4509
+ // would lose data. The cap only blocks further user-driven additions.
4510
+ this.values.set(Array.isArray(value) ? [...value] : []);
4511
+ }
4512
+ registerOnChange(fn) {
4513
+ this.onChange = fn;
4514
+ }
4515
+ registerOnTouched(fn) {
4516
+ this.onTouched = fn;
4517
+ }
4518
+ setDisabledState(isDisabled) {
4519
+ this.formDisabled.set(isDisabled);
4520
+ if (isDisabled) {
4521
+ this.close();
4522
+ }
4523
+ }
4524
+ // ── Template handlers ──
4525
+ /** Clicking anywhere in the container focuses the text field. */
4526
+ focusInput() {
4527
+ if (!this.isDisabled()) {
4528
+ this.inputEl().nativeElement.focus();
4529
+ }
4530
+ }
4531
+ onInput(event) {
4532
+ const value = event.target.value;
4533
+ this.inputValue.set(value);
4534
+ this.searchChange.emit(value);
4535
+ this.highlightedIndex.set(-1);
4536
+ this.syncDropdown();
4537
+ }
4538
+ onFocus() {
4539
+ this.focused.set(true);
4540
+ this.syncDropdown();
4541
+ }
4542
+ onBlur() {
4543
+ this.focused.set(false);
4544
+ if (this.addOnBlur()) {
4545
+ this.addChip(this.inputValue());
4546
+ }
4547
+ this.close();
4548
+ this.onTouched();
4549
+ }
4550
+ onKeydown(event) {
4551
+ // Mid-IME-composition (Japanese/Chinese/Korean), the Enter that confirms a
4552
+ // candidate also fires keydown with isComposing=true — committing here would
4553
+ // swallow the confirmation and chip a half-composed value. Let it through.
4554
+ if (event.isComposing) {
4555
+ return;
4556
+ }
4557
+ const suggestions = this.filteredSuggestions();
4558
+ if (event.key === 'ArrowDown') {
4559
+ if (suggestions.length && this.canAddMore()) {
4560
+ event.preventDefault();
4561
+ this.open();
4562
+ this.highlightedIndex.set((this.highlightedIndex() + 1) % suggestions.length);
4563
+ this.scrollToHighlighted();
4564
+ }
4565
+ return;
4566
+ }
4567
+ if (event.key === 'ArrowUp') {
4568
+ if (suggestions.length && this.isOpen()) {
4569
+ event.preventDefault();
4570
+ const next = this.highlightedIndex() - 1;
4571
+ this.highlightedIndex.set(next < 0 ? suggestions.length - 1 : next);
4572
+ this.scrollToHighlighted();
4573
+ }
4574
+ return;
4575
+ }
4576
+ if (event.key === 'Escape') {
4577
+ if (this.isOpen()) {
4578
+ event.preventDefault();
4579
+ this.close();
4580
+ }
4581
+ return;
4582
+ }
4583
+ if (this.isCommitKey(event)) {
4584
+ event.preventDefault();
4585
+ const idx = this.highlightedIndex();
4586
+ if (this.isOpen() && idx >= 0 && idx < suggestions.length) {
4587
+ this.addChip(suggestions[idx]);
4588
+ }
4589
+ else {
4590
+ this.addChip(this.inputValue());
4591
+ }
4592
+ return;
4593
+ }
4594
+ // Backspace on an empty field removes the last chip — the standard
4595
+ // chip-input affordance for quick correction.
4596
+ if (event.key === 'Backspace' && this.inputValue() === '' && this.values().length > 0) {
4597
+ event.preventDefault();
4598
+ this.removeChip(this.values().length - 1);
4599
+ }
4600
+ }
4601
+ onSuggestionClick(suggestion) {
4602
+ this.addChip(suggestion);
4603
+ this.inputEl().nativeElement.focus();
4604
+ }
4605
+ /** Prevents the option `mousedown` from blurring the input before the click lands. */
4606
+ onSuggestionMousedown(event) {
4607
+ event.preventDefault();
4608
+ }
4609
+ removeChip(index) {
4610
+ if (this.isDisabled()) {
4611
+ return;
4612
+ }
4613
+ const removed = this.values()[index];
4614
+ if (removed === undefined) {
4615
+ return;
4616
+ }
4617
+ this.values.update((values) => values.filter((_, i) => i !== index));
4618
+ this.onChange(this.values());
4619
+ this.onTouched();
4620
+ this.chipRemoved.emit(removed);
4621
+ // Removing via a chip's close button leaves focus on the (now-destroyed)
4622
+ // button; return it to the field so keyboard users stay oriented. The
4623
+ // Backspace path is already focused here, so this is a harmless no-op there.
4624
+ this.inputEl().nativeElement.focus();
4625
+ this.syncDropdown();
4626
+ }
4627
+ /** Scopes a per-chip test id beneath the component's base. */
4628
+ chipTestId(value) {
4629
+ const base = this.testId();
4630
+ if (base === undefined) {
4631
+ return undefined;
4632
+ }
4633
+ return [...(Array.isArray(base) ? base : [base]), value];
4634
+ }
4635
+ // ── Internal ──
4636
+ isCommitKey(event) {
4637
+ return event.key === 'Enter' || this.separatorKeys().includes(event.key);
4638
+ }
4639
+ addChip(raw) {
4640
+ const value = (raw ?? '').trim();
4641
+ if (!value || this.isDisabled() || !this.canAddMore()) {
4642
+ return;
4643
+ }
4644
+ if (!this.allowDuplicates() && this.values().includes(value)) {
4645
+ this.clearInput();
4646
+ return;
4647
+ }
4648
+ this.values.update((values) => [...values, value]);
4649
+ this.onChange(this.values());
4650
+ this.onTouched();
4651
+ this.chipAdded.emit(value);
4652
+ this.clearInput();
4653
+ }
4654
+ clearInput() {
4655
+ this.inputValue.set('');
4656
+ this.inputEl().nativeElement.value = '';
4657
+ this.close();
4658
+ }
4659
+ /**
4660
+ * Opens the dropdown when there is something to show, closes it otherwise.
4661
+ * Stays closed once the chip cap is reached — suggesting rows that
4662
+ * `addChip()` would reject is misleading.
4663
+ */
4664
+ syncDropdown() {
4665
+ if (this.filteredSuggestions().length > 0 && this.canAddMore() && !this.isDisabled()) {
4666
+ this.open();
4667
+ }
4668
+ else {
4669
+ this.close();
4670
+ }
4671
+ }
4672
+ /** Keeps the keyboard-highlighted suggestion visible within the scrolling panel. */
4673
+ scrollToHighlighted() {
4674
+ const idx = this.highlightedIndex();
4675
+ const options = this.overlayRef?.overlayElement
4676
+ ?.querySelectorAll('.tn-chip-input__option');
4677
+ options?.[idx]?.scrollIntoView({ block: 'nearest' });
4678
+ }
4679
+ open() {
4680
+ if (this.isOpen() || this.isDisabled()) {
4681
+ return;
4682
+ }
4683
+ this.isOpen.set(true);
4684
+ this.attachOverlay();
4685
+ }
4686
+ close() {
4687
+ this.isOpen.set(false);
4688
+ this.highlightedIndex.set(-1);
4689
+ this.detachOverlay();
4690
+ }
4691
+ attachOverlay() {
4692
+ const anchor = this.container().nativeElement;
4693
+ const positionStrategy = this.overlay
4694
+ .position()
4695
+ .flexibleConnectedTo(anchor)
4696
+ .withPositions([
4697
+ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
4698
+ { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 },
4699
+ ]);
4700
+ this.overlayRef = this.overlay.create({
4701
+ positionStrategy,
4702
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
4703
+ hasBackdrop: false,
4704
+ width: anchor.offsetWidth,
4705
+ });
4706
+ this.overlayRef.attach(new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef));
4707
+ this.overlaySubs.push(this.overlayRef.outsidePointerEvents().subscribe((event) => {
4708
+ const target = event.target;
4709
+ if (target && anchor.contains(target)) {
4710
+ return;
4711
+ }
4712
+ this.close();
4713
+ }));
4714
+ }
4715
+ detachOverlay() {
4716
+ this.overlaySubs.forEach((sub) => sub.unsubscribe());
4717
+ this.overlaySubs = [];
4718
+ this.overlayRef?.dispose();
4719
+ this.overlayRef = undefined;
4720
+ }
4721
+ 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: [
4723
+ {
4724
+ provide: NG_VALUE_ACCESSOR,
4725
+ useExisting: forwardRef(() => TnChipInputComponent),
4726
+ multi: true,
4727
+ },
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"] }] });
4729
+ }
4730
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, decorators: [{
4731
+ type: Component,
4732
+ args: [{ selector: 'tn-chip-input', standalone: true, imports: [TnChipComponent, TnTestIdDirective], providers: [
4733
+ {
4734
+ provide: NG_VALUE_ACCESSOR,
4735
+ useExisting: forwardRef(() => TnChipInputComponent),
4736
+ multi: true,
4737
+ },
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 }] }] } });
4740
+
4741
+ /**
4742
+ * Harness for interacting with `tn-chip-input` in tests. Reads the committed
4743
+ * chips and drives the text field — adding values (optionally via the
4744
+ * suggestion dropdown) and removing them.
4745
+ *
4746
+ * @example
4747
+ * ```typescript
4748
+ * const tags = await loader.getHarness(TnChipInputHarness.with({ testId: 'chip-input-tags' }));
4749
+ * await tags.addChip('production');
4750
+ * expect(await tags.getChips()).toEqual(['production']);
4751
+ * await tags.removeChip('production');
4752
+ * ```
4753
+ */
4754
+ class TnChipInputHarness extends ComponentHarness {
4755
+ static hostSelector = 'tn-chip-input';
4756
+ _input = this.locatorFor('.tn-chip-input__field');
4757
+ getChipHarnesses = this.locatorForAll(TnChipHarness);
4758
+ /** Suggestion options live in a CDK overlay on the document root. */
4759
+ documentRoot = this.documentRootLocatorFactory();
4760
+ static with(options = {}) {
4761
+ return new HarnessPredicate(TnChipInputHarness, options)
4762
+ .addOption('testId', options.testId, async (harness, testId) => (await (await harness._input()).getAttribute('data-testid')) === testId
4763
+ || (await (await harness._input()).getAttribute('data-test')) === testId);
4764
+ }
4765
+ /** Labels of the currently committed chips, in order. */
4766
+ async getChips() {
4767
+ const chips = await this.getChipHarnesses();
4768
+ const labels = [];
4769
+ for (const chip of chips) {
4770
+ labels.push(await chip.getLabel());
4771
+ }
4772
+ return labels;
4773
+ }
4774
+ /** Types `value` into the field and commits it with Enter. */
4775
+ async addChip(value) {
4776
+ const input = await this._input();
4777
+ await input.focus();
4778
+ await input.setInputValue(value);
4779
+ await input.dispatchEvent('input');
4780
+ await input.sendKeys(TestKey.ENTER);
4781
+ }
4782
+ /** Types `value` into the field without committing it. */
4783
+ async typeText(value) {
4784
+ const input = await this._input();
4785
+ await input.focus();
4786
+ await input.setInputValue(value);
4787
+ await input.dispatchEvent('input');
4788
+ }
4789
+ /** Whether the text field currently holds DOM focus. */
4790
+ async isInputFocused() {
4791
+ return (await this._input()).isFocused();
4792
+ }
4793
+ /** Focuses the text field (opens the dropdown when there are suggestions). */
4794
+ async focus() {
4795
+ await (await this._input()).focus();
4796
+ }
4797
+ /** Blurs the text field. */
4798
+ async blur() {
4799
+ await (await this._input()).blur();
4800
+ }
4801
+ /** Sends a key (or `TestKey`) to the focused field — e.g. Backspace, arrows, a separator. */
4802
+ async pressKey(key) {
4803
+ await (await this._input()).sendKeys(key);
4804
+ }
4805
+ /** Types `value`, then commits the matching suggestion from the dropdown. */
4806
+ async selectSuggestion(value) {
4807
+ const input = await this._input();
4808
+ await input.focus();
4809
+ await input.setInputValue(value);
4810
+ await input.dispatchEvent('input');
4811
+ const options = await this.documentRoot.locatorForAll('.tn-chip-input__option')();
4812
+ for (const option of options) {
4813
+ if ((await option.text()).trim() === value) {
4814
+ await option.click();
4815
+ return;
4816
+ }
4817
+ }
4818
+ throw new Error(`No suggestion matching "${value}" was found.`);
4819
+ }
4820
+ /** Removes the chip with the given label via its close button. */
4821
+ async removeChip(value) {
4822
+ const chip = await this.locatorFor(TnChipHarness.with({ label: value }))();
4823
+ await chip.close();
4824
+ }
4825
+ /** Visible suggestion option texts, or `[]` when the dropdown is closed. */
4826
+ async getSuggestions() {
4827
+ const options = await this.documentRoot.locatorForAll('.tn-chip-input__option')();
4828
+ const texts = [];
4829
+ for (const option of options) {
4830
+ texts.push((await option.text()).trim());
4831
+ }
4832
+ return texts;
4833
+ }
4834
+ async isDisabled() {
4835
+ const input = await this._input();
4836
+ return (await input.getProperty('disabled')) === true;
4837
+ }
4838
+ }
4839
+
4371
4840
  /**
4372
4841
  * Marks an `<ng-template>` whose content is rendered as the card's footer actions,
4373
4842
  * bottom-right in the footer after any declarative `secondaryAction`/`primaryAction`
@@ -16512,5 +16981,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
16512
16981
  * Generated bundle index. Do not edit.
16513
16982
  */
16514
16983
 
16515
- export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, StripMntPrefixPipe, THEME_MAP, THEME_STORAGE_KEY, TN_FORM_FIELD_ERRORS, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarComponent, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxHarness, TnCheckboxLabelDirective, TnChipComponent, TnChipHarness, TnConfirmDialogComponent, TnDateInputComponent, TnDateInputHarness, TnDateRangeInputComponent, TnDateRangeInputHarness, TnDetailRowDefDirective, TnDialog, TnDialogHarness, TnDialogShellComponent, TnDialogTesting, TnDividerComponent, TnDividerDirective, TnDrawerComponent, TnDrawerContainerComponent, TnDrawerContainerHarness, TnDrawerContentComponent, TnDrawerHarness, TnEmptyComponent, TnEmptyHarness, TnExpansionPanelComponent, TnExpansionPanelHarness, TnFilePickerComponent, TnFilePickerPopupComponent, TnFormFieldComponent, TnFormFieldHarness, TnFormSectionComponent, TnFormSectionHarness, TnHeaderCellDefDirective, TnIconButtonComponent, TnIconButtonHarness, TnIconComponent, TnIconHarness, TnIconRegistryService, TnIconTesting, TnInputComponent, TnInputDirective, TnInputHarness, TnKeyboardShortcutComponent, TnKeyboardShortcutService, TnListAvatarDirective, TnListComponent, TnListIconDirective, TnListItemComponent, TnListItemLineDirective, TnListItemPrimaryDirective, TnListItemSecondaryDirective, TnListItemTitleDirective, TnListItemTrailingDirective, TnListOptionComponent, TnListSubheaderComponent, TnMenuActivateHoverDirective, TnMenuComponent, TnMenuHarness, TnMenuItemComponent, TnMenuTesting, TnMenuTriggerDirective, TnMonthViewComponent, TnMultiYearViewComponent, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, TnRadioHarness, TnSelectComponent, TnSelectHarness, TnSelectionListComponent, TnSidePanelActionDirective, TnSidePanelComponent, TnSidePanelHarness, TnSidePanelHeaderActionDirective, TnSlideToggleComponent, TnSlideToggleHarness, TnSliderComponent, TnSliderThumbDirective, TnSliderWithLabelDirective, TnSpinnerComponent, TnSpriteLoaderService, TnStepComponent, TnStepperComponent, TnTabComponent, TnTabHarness, TnTabPanelComponent, TnTabPanelHarness, TnTableColumnDirective, TnTableComponent, TnTableHarness, TnTablePagerComponent, TnTablePagerHarness, TnTabsComponent, TnTabsHarness, TnTestIdDirective, TnTheme, TnThemeService, TnTimeInputComponent, TnToastComponent, TnToastMock, TnToastPosition, TnToastRef, TnToastService, TnToastTesting, TnToastType, TnTooltipComponent, TnTooltipDirective, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeNodeComponent, TnTreeNodeOutletDirective, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, composeTestId, createLucideLibrary, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, formatSize, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
16984
+ export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, StripMntPrefixPipe, THEME_MAP, THEME_STORAGE_KEY, TN_FORM_FIELD_ERRORS, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarComponent, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxHarness, TnCheckboxLabelDirective, TnChipComponent, TnChipHarness, TnChipInputComponent, TnChipInputHarness, TnConfirmDialogComponent, TnDateInputComponent, TnDateInputHarness, TnDateRangeInputComponent, TnDateRangeInputHarness, TnDetailRowDefDirective, TnDialog, TnDialogHarness, TnDialogShellComponent, TnDialogTesting, TnDividerComponent, TnDividerDirective, TnDrawerComponent, TnDrawerContainerComponent, TnDrawerContainerHarness, TnDrawerContentComponent, TnDrawerHarness, TnEmptyComponent, TnEmptyHarness, TnExpansionPanelComponent, TnExpansionPanelHarness, TnFilePickerComponent, TnFilePickerPopupComponent, TnFormFieldComponent, TnFormFieldHarness, TnFormSectionComponent, TnFormSectionHarness, TnHeaderCellDefDirective, TnIconButtonComponent, TnIconButtonHarness, TnIconComponent, TnIconHarness, TnIconRegistryService, TnIconTesting, TnInputComponent, TnInputDirective, TnInputHarness, TnKeyboardShortcutComponent, TnKeyboardShortcutService, TnListAvatarDirective, TnListComponent, TnListIconDirective, TnListItemComponent, TnListItemLineDirective, TnListItemPrimaryDirective, TnListItemSecondaryDirective, TnListItemTitleDirective, TnListItemTrailingDirective, TnListOptionComponent, TnListSubheaderComponent, TnMenuActivateHoverDirective, TnMenuComponent, TnMenuHarness, TnMenuItemComponent, TnMenuTesting, TnMenuTriggerDirective, TnMonthViewComponent, TnMultiYearViewComponent, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, TnRadioHarness, TnSelectComponent, TnSelectHarness, TnSelectionListComponent, TnSidePanelActionDirective, TnSidePanelComponent, TnSidePanelHarness, TnSidePanelHeaderActionDirective, TnSlideToggleComponent, TnSlideToggleHarness, TnSliderComponent, TnSliderThumbDirective, TnSliderWithLabelDirective, TnSpinnerComponent, TnSpriteLoaderService, TnStepComponent, TnStepperComponent, TnTabComponent, TnTabHarness, TnTabPanelComponent, TnTabPanelHarness, TnTableColumnDirective, TnTableComponent, TnTableHarness, TnTablePagerComponent, TnTablePagerHarness, TnTabsComponent, TnTabsHarness, TnTestIdDirective, TnTheme, TnThemeService, TnTimeInputComponent, TnToastComponent, TnToastMock, TnToastPosition, TnToastRef, TnToastService, TnToastTesting, TnToastType, TnTooltipComponent, TnTooltipDirective, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeNodeComponent, TnTreeNodeOutletDirective, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, composeTestId, createLucideLibrary, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, formatSize, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
16516
16985
  //# sourceMappingURL=truenas-ui-components.mjs.map