@truenas/ui-components 0.2.3 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  import { Overlay, OverlayPositionBuilder, OverlayModule } from '@angular/cdk/overlay';
2
2
  import { TemplatePortal, ComponentPortal, PortalModule } from '@angular/cdk/portal';
3
3
  import * as i0 from '@angular/core';
4
- import { InjectionToken, inject, Renderer2, ElementRef, input, effect, Directive, ViewContainerRef, output, viewChild, computed, signal, forwardRef, Component, model, afterNextRender, ChangeDetectionStrategy, Injectable, isDevMode, ViewEncapsulation, contentChildren, HostListener, linkedSignal, contentChild, ChangeDetectorRef, DestroyRef, TemplateRef, isSignal, untracked, IterableDiffers, Pipe, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
4
+ import { input, computed, ViewEncapsulation, ChangeDetectionStrategy, Component, InjectionToken, inject, Renderer2, ElementRef, effect, Directive, ViewContainerRef, output, viewChild, signal, untracked, isDevMode, forwardRef, model, afterNextRender, Injectable, contentChildren, HostListener, linkedSignal, contentChild, ChangeDetectorRef, DestroyRef, TemplateRef, isSignal, IterableDiffers, Pipe, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
5
5
  import * as i1$3 from '@angular/forms';
6
6
  import { NG_VALUE_ACCESSOR, FormsModule, NgControl, Validators } from '@angular/forms';
7
7
  import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';
@@ -29,6 +29,51 @@ import { map } from 'rxjs/operators';
29
29
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
30
30
  import { ScrollingModule } from '@angular/cdk/scrolling';
31
31
 
32
+ class TnSpinnerComponent {
33
+ mode = input('indeterminate', ...(ngDevMode ? [{ debugName: "mode" }] : []));
34
+ value = input(0, ...(ngDevMode ? [{ debugName: "value" }] : []));
35
+ diameter = input(40, ...(ngDevMode ? [{ debugName: "diameter" }] : []));
36
+ strokeWidth = input(4, ...(ngDevMode ? [{ debugName: "strokeWidth" }] : []));
37
+ ariaLabel = input(null, ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
38
+ ariaLabelledby = input(null, ...(ngDevMode ? [{ debugName: "ariaLabelledby" }] : []));
39
+ radius = computed(() => {
40
+ return (this.diameter() - this.strokeWidth()) / 2;
41
+ }, ...(ngDevMode ? [{ debugName: "radius" }] : []));
42
+ circumference = computed(() => {
43
+ return 2 * Math.PI * this.radius();
44
+ }, ...(ngDevMode ? [{ debugName: "circumference" }] : []));
45
+ strokeDasharray = computed(() => {
46
+ return `${this.circumference()} ${this.circumference()}`;
47
+ }, ...(ngDevMode ? [{ debugName: "strokeDasharray" }] : []));
48
+ strokeDashoffset = computed(() => {
49
+ if (this.mode() === 'indeterminate') {
50
+ return 0;
51
+ }
52
+ const progress = Math.max(0, Math.min(100, this.value()));
53
+ return this.circumference() - (progress / 100) * this.circumference();
54
+ }, ...(ngDevMode ? [{ debugName: "strokeDashoffset" }] : []));
55
+ viewBox = computed(() => {
56
+ const size = this.diameter();
57
+ return `0 0 ${size} ${size}`;
58
+ }, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
59
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSpinnerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
60
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: TnSpinnerComponent, isStandalone: true, selector: "tn-spinner", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, diameter: { classPropertyName: "diameter", publicName: "diameter", isSignal: true, isRequired: false, transformFunction: null }, strokeWidth: { classPropertyName: "strokeWidth", publicName: "strokeWidth", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "progressbar" }, properties: { "class.tn-spinner-indeterminate": "mode() === \"indeterminate\"", "class.tn-spinner-determinate": "mode() === \"determinate\"", "attr.aria-valuenow": "mode() === \"determinate\" ? value() : null", "attr.aria-valuemin": "mode() === \"determinate\" ? 0 : null", "attr.aria-valuemax": "mode() === \"determinate\" ? 100 : null", "attr.aria-label": "ariaLabel() || null", "attr.aria-labelledby": "ariaLabelledby() || null" }, classAttribute: "tn-spinner" }, ngImport: i0, template: "<svg\n class=\"tn-spinner-svg\"\n [attr.width]=\"diameter()\"\n [attr.height]=\"diameter()\"\n [attr.viewBox]=\"viewBox()\">\n <circle\n class=\"tn-spinner-circle\"\n fill=\"none\"\n [attr.cx]=\"diameter() / 2\"\n [attr.cy]=\"diameter() / 2\"\n [attr.r]=\"radius()\"\n [attr.stroke-width]=\"strokeWidth()\"\n [attr.stroke-dasharray]=\"strokeDasharray()\"\n [attr.stroke-dashoffset]=\"strokeDashoffset()\" />\n</svg>", styles: [".tn-spinner{display:inline-block;vertical-align:middle}.tn-spinner-svg{animation:tn-spinner-rotate 2s linear infinite;transform-origin:center}.tn-spinner-circle{stroke:var(--tn-primary, #007bff);stroke-linecap:round;transition:stroke-dashoffset .35s cubic-bezier(.4,0,.2,1)}.tn-spinner-indeterminate .tn-spinner-circle{animation:tn-spinner-dash 1.4s ease-in-out infinite}.tn-spinner-determinate .tn-spinner-svg{animation:none;transform:rotate(-90deg)}@keyframes tn-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes tn-spinner-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:100,200;stroke-dashoffset:-15}to{stroke-dasharray:100,200;stroke-dashoffset:-125}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
61
+ }
62
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSpinnerComponent, decorators: [{
63
+ type: Component,
64
+ args: [{ selector: 'tn-spinner', standalone: true, imports: [], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: {
65
+ 'class': 'tn-spinner',
66
+ '[class.tn-spinner-indeterminate]': 'mode() === "indeterminate"',
67
+ '[class.tn-spinner-determinate]': 'mode() === "determinate"',
68
+ '[attr.aria-valuenow]': 'mode() === "determinate" ? value() : null',
69
+ '[attr.aria-valuemin]': 'mode() === "determinate" ? 0 : null',
70
+ '[attr.aria-valuemax]': 'mode() === "determinate" ? 100 : null',
71
+ 'role': 'progressbar',
72
+ '[attr.aria-label]': 'ariaLabel() || null',
73
+ '[attr.aria-labelledby]': 'ariaLabelledby() || null'
74
+ }, template: "<svg\n class=\"tn-spinner-svg\"\n [attr.width]=\"diameter()\"\n [attr.height]=\"diameter()\"\n [attr.viewBox]=\"viewBox()\">\n <circle\n class=\"tn-spinner-circle\"\n fill=\"none\"\n [attr.cx]=\"diameter() / 2\"\n [attr.cy]=\"diameter() / 2\"\n [attr.r]=\"radius()\"\n [attr.stroke-width]=\"strokeWidth()\"\n [attr.stroke-dasharray]=\"strokeDasharray()\"\n [attr.stroke-dashoffset]=\"strokeDashoffset()\" />\n</svg>", styles: [".tn-spinner{display:inline-block;vertical-align:middle}.tn-spinner-svg{animation:tn-spinner-rotate 2s linear infinite;transform-origin:center}.tn-spinner-circle{stroke:var(--tn-primary, #007bff);stroke-linecap:round;transition:stroke-dashoffset .35s cubic-bezier(.4,0,.2,1)}.tn-spinner-indeterminate .tn-spinner-circle{animation:tn-spinner-dash 1.4s ease-in-out infinite}.tn-spinner-determinate .tn-spinner-svg{animation:none;transform:rotate(-90deg)}@keyframes tn-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes tn-spinner-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:100,200;stroke-dashoffset:-15}to{stroke-dasharray:100,200;stroke-dashoffset:-125}}\n"] }]
75
+ }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], diameter: [{ type: i0.Input, args: [{ isSignal: true, alias: "diameter", required: false }] }], strokeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "strokeWidth", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabelledby", required: false }] }] } });
76
+
32
77
  /**
33
78
  * Normalize one segment to a kebab-case token: split camelCase, lower-case,
34
79
  * collapse any run of non-alphanumeric characters to a single hyphen, and trim
@@ -233,6 +278,21 @@ class TnAutocompleteComponent {
233
278
  disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
234
279
  /** Require the user to select from the dropdown — reverts on blur if no match */
235
280
  requireSelection = input(false, ...(ngDevMode ? [{ debugName: "requireSelection" }] : []));
281
+ /**
282
+ * Commit free text as the value: on blur or Enter, an unmatched search term
283
+ * becomes the control value instead of being discarded. For string-valued
284
+ * autocompletes (the typed text IS the value) — e.g. a device path picker
285
+ * where known devices are suggested but any path is acceptable. Mutually
286
+ * exclusive with `requireSelection`, which reverts unmatched text.
287
+ */
288
+ allowCustomValue = input(false, ...(ngDevMode ? [{ debugName: "allowCustomValue" }] : []));
289
+ /**
290
+ * Show a loading row in the dropdown panel while options are being fetched.
291
+ * Pair with `searchChange`/`loadMore` for server-driven options.
292
+ */
293
+ loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
294
+ /** Text shown next to the spinner while `loading` is set. */
295
+ loadingText = input('Loading...', ...(ngDevMode ? [{ debugName: "loadingText" }] : []));
236
296
  /** Custom filter function. Defaults to case-insensitive includes on displayWith text */
237
297
  filterFn = input(undefined, ...(ngDevMode ? [{ debugName: "filterFn" }] : []));
238
298
  /** Text shown when no options match the search */
@@ -256,6 +316,29 @@ class TnAutocompleteComponent {
256
316
  testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
257
317
  /** Emits when an option is selected */
258
318
  optionSelected = output();
319
+ /**
320
+ * Emits the search term as the user types (not on programmatic writes or
321
+ * option selection). Drive server-side filtering from this: fetch matches
322
+ * and update `options`. The component still applies its client-side filter
323
+ * on top — for pre-filtered server results, pass `[filterFn]` that returns
324
+ * `true`. Emissions are not debounced; debounce in the consumer if the
325
+ * lookup is expensive.
326
+ */
327
+ searchChange = output();
328
+ /**
329
+ * Emits when the open dropdown is scrolled near its bottom — append the
330
+ * next page to `options` (and use `loading` while it fetches). Suppressed
331
+ * until the `options` COUNT changes so a slow consumer is not spammed —
332
+ * which also means replacing the array with a same-length page (e.g. a
333
+ * fixed-size window) does not re-arm the emitter; pagination must append.
334
+ */
335
+ loadMore = output();
336
+ /**
337
+ * Emits every time the panel opens (focus, click, typing, ArrowDown). For
338
+ * click-to-suggest pickers, prime the first page from here when `options`
339
+ * is still empty — `searchChange` alone never fires until the user types.
340
+ */
341
+ opened = output();
259
342
  /** Reference to the input element */
260
343
  inputEl = viewChild('inputEl', ...(ngDevMode ? [{ debugName: "inputEl" }] : []));
261
344
  /** Template for the dropdown panel, portaled into a CDK overlay on open. */
@@ -299,8 +382,55 @@ class TnAutocompleteComponent {
299
382
  onTouched = () => { };
300
383
  /** Live overlay holding the dropdown panel, or undefined when closed. */
301
384
  overlayRef;
385
+ /** Embedded view of the portaled panel — re-rendered before underfill measurement. */
386
+ panelViewRef;
302
387
  /** Subscriptions tied to the current overlay; torn down on close. */
303
388
  overlaySubs = [];
389
+ /** Set after a loadMore emission; cleared when the options count changes. */
390
+ loadMorePending = false;
391
+ /** Options count at the last effect run — length changes re-arm `loadMore`. */
392
+ lastOptionsCount = null;
393
+ /** Scroll distance (px) from the panel bottom that triggers `loadMore`. */
394
+ static loadMoreThresholdPx = 48;
395
+ constructor() {
396
+ // A changed options COUNT means the consumer answered the last loadMore
397
+ // (or a new result set landed) — re-arm the emitter and request another
398
+ // page if the rows still don't fill the panel. Re-arming on length (not
399
+ // identity) is what makes auto-fill loop-safe: an exhausted source that
400
+ // answers loadMore with the same rows leaves the count unchanged, so no
401
+ // further request is made. Also triggered by the panel opening (the
402
+ // already-loaded first page may be too short) and by loading() clearing —
403
+ // a consumer may update options while still loading and only clear the
404
+ // flag in a later tick, and without the loading() dependency that page's
405
+ // underfill check would be skipped (checkUnderfill bails while loading)
406
+ // and never re-run.
407
+ effect(() => {
408
+ const count = this.options().length;
409
+ this.isOpen();
410
+ this.loading();
411
+ untracked(() => {
412
+ if (count !== this.lastOptionsCount) {
413
+ this.lastOptionsCount = count;
414
+ this.loadMorePending = false;
415
+ }
416
+ this.checkUnderfill();
417
+ });
418
+ });
419
+ // Async option/loading changes resize the open panel without any input
420
+ // event; nudge CDK so the anchored position tracks the new height.
421
+ effect(() => {
422
+ this.filteredOptions();
423
+ this.loading();
424
+ this.overlayRef?.updatePosition();
425
+ });
426
+ if (isDevMode()) {
427
+ effect(() => {
428
+ if (this.requireSelection() && this.allowCustomValue()) {
429
+ console.warn('[tn-autocomplete] requireSelection and allowCustomValue are mutually exclusive; allowCustomValue wins.');
430
+ }
431
+ });
432
+ }
433
+ }
304
434
  ngOnDestroy() {
305
435
  // If the component is destroyed while the dropdown is open (e.g. router
306
436
  // navigates away), close() never runs — clean up the overlay directly.
@@ -330,6 +460,9 @@ class TnAutocompleteComponent {
330
460
  const value = event.target.value;
331
461
  this.searchTerm.set(value);
332
462
  this.highlightedIndex.set(-1);
463
+ // A new term is a new pagination context — don't hold back its first page.
464
+ this.loadMorePending = false;
465
+ this.searchChange.emit(value);
333
466
  if (!this.isOpen()) {
334
467
  this.open();
335
468
  }
@@ -345,6 +478,12 @@ class TnAutocompleteComponent {
345
478
  }
346
479
  }
347
480
  onBlur() {
481
+ if (this.allowCustomValue()) {
482
+ this.commitCustomValue();
483
+ this.close();
484
+ this.onTouched();
485
+ return;
486
+ }
348
487
  if (this.requireSelection()) {
349
488
  const term = this.searchTerm();
350
489
  const display = this.displayWith();
@@ -390,20 +529,97 @@ class TnAutocompleteComponent {
390
529
  this.scrollToHighlighted();
391
530
  }
392
531
  break;
393
- case 'Enter':
532
+ case 'Enter': {
394
533
  event.preventDefault();
395
534
  const idx = this.highlightedIndex();
396
535
  if (this.isOpen() && idx >= 0 && idx < options.length) {
397
536
  this.selectOption(options[idx]);
398
537
  }
538
+ else if (this.allowCustomValue()) {
539
+ this.commitCustomValue();
540
+ this.close();
541
+ }
399
542
  break;
400
- case 'Escape':
543
+ }
544
+ case 'Escape': {
401
545
  event.preventDefault();
546
+ if (this.allowCustomValue()) {
547
+ // Escape means "cancel the draft": revert to the committed value's
548
+ // text so the upcoming blur doesn't commit the abandoned term.
549
+ const current = this.selectedValue();
550
+ this.searchTerm.set(current !== null && current !== undefined ? this.displayWith()(current) : '');
551
+ }
402
552
  this.close();
403
553
  break;
554
+ }
555
+ }
556
+ }
557
+ /**
558
+ * `loadMore` normally fires from a scroll event, which never happens when
559
+ * the current page is too short to overflow the panel — pagination would
560
+ * dead-end with data still available. When new rows land (or the panel
561
+ * opens), emit once more if the panel has no scrollbar.
562
+ */
563
+ checkUnderfill() {
564
+ if (!this.isOpen() || this.loading() || this.loadMorePending) {
565
+ return;
566
+ }
567
+ // No rows to measure, or rendering is capped by maxResults — more data
568
+ // could never fill the panel, so requesting it would loop until the
569
+ // source is exhausted.
570
+ const filteredCount = this.filteredOptions().length;
571
+ if (filteredCount === 0 || filteredCount >= this.maxResults()) {
572
+ return;
573
+ }
574
+ // The portaled rows may not have been change-detected yet when this runs
575
+ // (effect timing vs. embedded-view refresh) — render them first so the
576
+ // measurement below never sees a stale, shorter panel.
577
+ this.panelViewRef?.detectChanges();
578
+ const panel = this.overlayRef?.overlayElement
579
+ ?.querySelector('.tn-autocomplete__dropdown');
580
+ if (panel && panel.scrollHeight <= panel.clientHeight) {
581
+ this.loadMorePending = true;
582
+ this.loadMore.emit();
583
+ }
584
+ }
585
+ onPanelScroll(event) {
586
+ // While loading, the rendered rows belong to the previous page/term —
587
+ // scrolling them must not request a page of data that hasn't landed yet.
588
+ if (this.loadMorePending || this.loading()) {
589
+ return;
590
+ }
591
+ const el = event.target;
592
+ const nearBottom = el.scrollTop + el.clientHeight
593
+ >= el.scrollHeight - TnAutocompleteComponent.loadMoreThresholdPx;
594
+ if (nearBottom) {
595
+ this.loadMorePending = true;
596
+ this.loadMore.emit();
404
597
  }
405
598
  }
406
599
  // ── Internal ──
600
+ /**
601
+ * Commit the current search term as the value (allowCustomValue mode). A
602
+ * display match (case-insensitive, same as the requireSelection path)
603
+ * commits the matching option instead, so picking an existing entry by
604
+ * typing its full text behaves like clicking it.
605
+ */
606
+ commitCustomValue() {
607
+ const term = this.searchTerm();
608
+ const display = this.displayWith();
609
+ const lowerTerm = term.toLowerCase();
610
+ const match = this.options().find((opt) => display(opt).toLowerCase() === lowerTerm);
611
+ if (match) {
612
+ this.selectOption(match);
613
+ return;
614
+ }
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.');
618
+ }
619
+ const value = term === '' ? null : term;
620
+ this.selectedValue.set(value);
621
+ this.onChange(value);
622
+ }
407
623
  selectOption(option) {
408
624
  this.selectedValue.set(option);
409
625
  this.searchTerm.set(this.displayWith()(option));
@@ -417,6 +633,7 @@ class TnAutocompleteComponent {
417
633
  }
418
634
  this.isOpen.set(true);
419
635
  this.attachOverlay();
636
+ this.opened.emit();
420
637
  }
421
638
  close() {
422
639
  this.isOpen.set(false);
@@ -450,7 +667,7 @@ class TnAutocompleteComponent {
450
667
  hasBackdrop: false,
451
668
  width: anchor.offsetWidth,
452
669
  });
453
- this.overlayRef.attach(new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef));
670
+ this.panelViewRef = this.overlayRef.attach(new TemplatePortal(this.dropdownTemplate(), this.viewContainerRef));
454
671
  // Non-intercepting click-outside: the pointer event still reaches whatever
455
672
  // the user clicked; we just notice and close. Ignore targets inside the
456
673
  // host (the input itself) so we don't fight the focus/typing handlers.
@@ -467,6 +684,7 @@ class TnAutocompleteComponent {
467
684
  this.overlaySubs = [];
468
685
  this.overlayRef?.dispose();
469
686
  this.overlayRef = undefined;
687
+ this.panelViewRef = undefined;
470
688
  }
471
689
  scrollToHighlighted() {
472
690
  const idx = this.highlightedIndex();
@@ -479,24 +697,24 @@ class TnAutocompleteComponent {
479
697
  }
480
698
  }
481
699
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
482
- 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 }, 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" }, providers: [
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: [
483
701
  {
484
702
  provide: NG_VALUE_ACCESSOR,
485
703
  useExisting: forwardRef(() => TnAutocompleteComponent),
486
704
  multi: true,
487
705
  },
488
- ], 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 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 role=\"listbox\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n [attr.id]=\"uid + '-dropdown'\">\n\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 } @else {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\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}}\n"], dependencies: [{ kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
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"] }] });
489
707
  }
490
708
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, decorators: [{
491
709
  type: Component,
492
- args: [{ selector: 'tn-autocomplete', standalone: true, imports: [TnTestIdDirective], providers: [
710
+ args: [{ selector: 'tn-autocomplete', standalone: true, imports: [TnSpinnerComponent, TnTestIdDirective], providers: [
493
711
  {
494
712
  provide: NG_VALUE_ACCESSOR,
495
713
  useExisting: forwardRef(() => TnAutocompleteComponent),
496
714
  multi: true,
497
715
  },
498
- ], template: "<div class=\"tn-autocomplete\">\n <input\n #inputEl\n type=\"text\"\n class=\"tn-autocomplete__input\"\n role=\"combobox\"\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 role=\"listbox\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\"\n [attr.id]=\"uid + '-dropdown'\">\n\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 } @else {\n <div class=\"tn-autocomplete__no-results\">\n {{ noResultsText() }}\n </div>\n }\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}}\n"] }]
499
- }], 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 }] }], 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"] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
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 }] }] } });
500
718
 
501
719
  /**
502
720
  * Harness for interacting with tn-autocomplete in tests.
@@ -722,6 +940,23 @@ class TnAutocompleteHarness extends ComponentHarness {
722
940
  const input = await this._input();
723
941
  return input.blur();
724
942
  }
943
+ /**
944
+ * Checks whether the dropdown panel is showing the loading row.
945
+ *
946
+ * @returns Promise resolving to true if the loading indicator is visible.
947
+ *
948
+ * @example
949
+ * ```typescript
950
+ * const ac = await loader.getHarness(TnAutocompleteHarness);
951
+ * await ac.focus();
952
+ * expect(await ac.isLoading()).toBe(true);
953
+ * ```
954
+ */
955
+ async isLoading() {
956
+ const loading = await this.documentRootLocatorFactory()
957
+ .locatorForOptional('.tn-autocomplete__loading')();
958
+ return loading !== null;
959
+ }
725
960
  }
726
961
 
727
962
  class TnDrawerComponent {
@@ -6448,7 +6683,18 @@ class TnFormFieldComponent {
6448
6683
  required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
6449
6684
  testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
6450
6685
  subscriptSizing = input('dynamic', ...(ngDevMode ? [{ debugName: "subscriptSizing" }] : []));
6451
- /** Optional tooltip shown via a help icon next to the label. */
6686
+ /**
6687
+ * Optional tooltip shown via a help icon.
6688
+ *
6689
+ * With a `label`, the icon renders next to the label in the label row. Without
6690
+ * one, the icon renders inline after the projected control instead — for
6691
+ * controls that carry their own label (e.g. `tn-checkbox`), where a detached
6692
+ * icon row above the control would look orphaned.
6693
+ *
6694
+ * Inline mode targets compact, self-labeled controls: the wrapper becomes a
6695
+ * flex row, so a full-width control (e.g. a label-less `tn-input`) would
6696
+ * shrink toward its content width. Give such fields a `label` instead.
6697
+ */
6452
6698
  tooltip = input('', ...(ngDevMode ? [{ debugName: "tooltip" }] : []));
6453
6699
  /** Placement of the tooltip relative to its help icon. */
6454
6700
  tooltipPosition = input('above', ...(ngDevMode ? [{ debugName: "tooltipPosition" }] : []));
@@ -6481,10 +6727,24 @@ class TnFormFieldComponent {
6481
6727
  /**
6482
6728
  * Whether the required indicator renders: forced via the `required` input, or
6483
6729
  * inferred from the projected control's validators (mirrors Angular Material's
6484
- * `hasValidator(Validators.required)` approach reference equality, so composed
6485
- * or custom required-like validators need the explicit input).
6730
+ * `hasValidator(Validators.required)` approach, extended to `requiredTrue` for
6731
+ * boolean controls — reference equality, so composed or custom required-like
6732
+ * validators need the explicit input).
6486
6733
  */
6487
6734
  showRequired = computed(() => this.required() || this.controlState().required, ...(ngDevMode ? [{ debugName: "showRequired" }] : []));
6735
+ /**
6736
+ * Whether the tooltip icon renders inline after the projected control rather
6737
+ * than in the label row — true when a tooltip is set but no label is.
6738
+ */
6739
+ showInlineTooltip = computed(() => !!this.tooltip() && !this.label(), ...(ngDevMode ? [{ debugName: "showInlineTooltip" }] : []));
6740
+ /**
6741
+ * Whether the required indicator renders inline after the projected control —
6742
+ * with no label there is no label row to host the asterisk, so a required
6743
+ * self-labeled control (e.g. a consent `tn-checkbox`) still gets one.
6744
+ */
6745
+ showInlineRequired = computed(() => !this.label() && this.showRequired(), ...(ngDevMode ? [{ debugName: "showInlineRequired" }] : []));
6746
+ /** Whether the wrapper hosts any inline extras and lays out as a flex row. */
6747
+ showInlineExtras = computed(() => this.showInlineTooltip() || this.showInlineRequired(), ...(ngDevMode ? [{ debugName: "showInlineExtras" }] : []));
6488
6748
  hasError = computed(() => {
6489
6749
  const state = this.controlState();
6490
6750
  return state.invalid && state.interacted;
@@ -6517,7 +6777,8 @@ class TnFormFieldComponent {
6517
6777
  invalid: !!control.invalid,
6518
6778
  interacted: !!(control.dirty || control.touched),
6519
6779
  errors: control.errors ?? null,
6520
- required: !!control.control?.hasValidator(Validators.required),
6780
+ required: !!(control.control?.hasValidator(Validators.required)
6781
+ || control.control?.hasValidator(Validators.requiredTrue)),
6521
6782
  });
6522
6783
  }
6523
6784
  }
@@ -6589,11 +6850,11 @@ class TnFormFieldComponent {
6589
6850
  return this.subscriptSizing() === 'fixed' || this.showError() || this.showHint();
6590
6851
  }, ...(ngDevMode ? [{ debugName: "showSubscript" }] : []));
6591
6852
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFormFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6592
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnFormFieldComponent, isStandalone: true, selector: "tn-form-field", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, subscriptSizing: { classPropertyName: "subscriptSizing", publicName: "subscriptSizing", isSignal: true, isRequired: false, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: false, transformFunction: null }, tooltipPosition: { classPropertyName: "tooltipPosition", publicName: "tooltipPosition", isSignal: true, isRequired: false, transformFunction: null }, errorMessages: { classPropertyName: "errorMessages", publicName: "errorMessages", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "control", first: true, predicate: NgControl, descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-form-field\" tnTestIdType=\"form-field\" [tnTestId]=\"testId()\">\n <!-- Label -->\n @if (label() || tooltip()) {\n <div class=\"tn-form-field-label-row\">\n @if (label()) {\n <label class=\"tn-form-field-label\" [class.required]=\"showRequired()\">\n {{ label() }}\n @if (showRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n </label>\n }\n @if (tooltip()) {\n <button\n type=\"button\"\n class=\"tn-form-field-tooltip\"\n [attr.aria-label]=\"tooltip()\"\n [tnTooltip]=\"tooltip()\"\n [tnTooltipPosition]=\"tooltipPosition()\">\n <tn-icon name=\"help-circle\" library=\"mdi\" size=\"sm\" />\n </button>\n }\n </div>\n }\n\n <!-- Form Control Content -->\n <div class=\"tn-form-field-wrapper\">\n <ng-content />\n </div>\n\n <!-- Hint or Error Message -->\n @if (showSubscript()) {\n <div class=\"tn-form-field-subscript\" [class.tn-form-field-subscript-dynamic]=\"subscriptSizing() === 'dynamic'\">\n @if (showError()) {\n <div\n class=\"tn-form-field-error\"\n role=\"alert\"\n aria-live=\"polite\">\n {{ errorMessage() }}\n </div>\n }\n @if (showHint()) {\n <div class=\"tn-form-field-hint\">\n {{ hint() }}\n </div>\n }\n </div>\n }\n</div>\n", styles: [".tn-form-field{display:block;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-form-field-label-row{display:flex;align-items:center;gap:.375rem;margin-bottom:.5rem}.tn-form-field-label{display:block;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-form-field-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-form-field-tooltip{display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var(--tn-fg2, #6c757d);cursor:help;line-height:0}.tn-form-field-tooltip:hover,.tn-form-field-tooltip:focus-visible{color:var(--tn-primary, #007bff)}.tn-form-field-tooltip:focus-visible{outline:2px solid var(--tn-primary, #007bff);outline-offset:2px;border-radius:50%}.tn-form-field-wrapper{position:relative;width:100%;overflow:visible}.tn-form-field-wrapper :ng-deep .tn-select-container,.tn-form-field-wrapper :ng-deep .tn-input-container{margin-bottom:0}.tn-form-field-wrapper :ng-deep .tn-select-label,.tn-form-field-wrapper :ng-deep .tn-input-label{display:none}.tn-form-field-wrapper :ng-deep .tn-select-error,.tn-form-field-wrapper :ng-deep .tn-input-error{display:none}.tn-form-field-wrapper :ng-deep .tn-select-dropdown{z-index:1000}.tn-form-field-subscript{min-height:1.25rem;margin-top:.25rem;font-size:.75rem;line-height:1.4}.tn-form-field-subscript-dynamic{min-height:0}.tn-form-field-error{color:var(--tn-error, #dc3545);margin:0}.tn-form-field-hint{color:var(--tn-fg2, #6c757d);margin:0}.tn-form-field-wrapper:has(:focus-visible) .tn-form-field-label{color:var(--tn-primary, #007bff)}.tn-form-field-wrapper:has(.error) .tn-form-field-label{color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-form-field-label{transition:none}}@media(prefers-contrast:high){.tn-form-field-label,.tn-form-field-error{font-weight:600}}\n"], dependencies: [{ kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "directive", type: TnTooltipDirective, selector: "[tnTooltip]", inputs: ["tnTooltip", "tnTooltipPosition", "tnTooltipDisabled", "tnTooltipShowDelay", "tnTooltipHideDelay", "tnTooltipClass"] }] });
6853
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnFormFieldComponent, isStandalone: true, selector: "tn-form-field", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, subscriptSizing: { classPropertyName: "subscriptSizing", publicName: "subscriptSizing", isSignal: true, isRequired: false, transformFunction: null }, tooltip: { classPropertyName: "tooltip", publicName: "tooltip", isSignal: true, isRequired: false, transformFunction: null }, tooltipPosition: { classPropertyName: "tooltipPosition", publicName: "tooltipPosition", isSignal: true, isRequired: false, transformFunction: null }, errorMessages: { classPropertyName: "errorMessages", publicName: "errorMessages", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "control", first: true, predicate: NgControl, descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-form-field\" tnTestIdType=\"form-field\" [tnTestId]=\"testId()\">\n <!-- Label -->\n @if (label()) {\n <div class=\"tn-form-field-label-row\">\n <label class=\"tn-form-field-label\" [class.required]=\"showRequired()\">\n {{ label() }}\n @if (showRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n </label>\n @if (tooltip()) {\n <ng-container [ngTemplateOutlet]=\"tooltipButton\" />\n }\n </div>\n }\n\n <!-- Form Control Content -->\n <div\n class=\"tn-form-field-wrapper\"\n [class.tn-form-field-wrapper--inline]=\"showInlineExtras()\">\n <ng-content />\n @if (showInlineRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n @if (showInlineTooltip()) {\n <ng-container [ngTemplateOutlet]=\"tooltipButton\" />\n }\n </div>\n\n <!-- Hint or Error Message -->\n @if (showSubscript()) {\n <div class=\"tn-form-field-subscript\" [class.tn-form-field-subscript-dynamic]=\"subscriptSizing() === 'dynamic'\">\n @if (showError()) {\n <div\n class=\"tn-form-field-error\"\n role=\"alert\"\n aria-live=\"polite\">\n {{ errorMessage() }}\n </div>\n }\n @if (showHint()) {\n <div class=\"tn-form-field-hint\">\n {{ hint() }}\n </div>\n }\n </div>\n }\n</div>\n\n<ng-template #tooltipButton>\n <button\n type=\"button\"\n class=\"tn-form-field-tooltip\"\n [attr.aria-label]=\"tooltip()\"\n [tnTooltip]=\"tooltip()\"\n [tnTooltipPosition]=\"tooltipPosition()\">\n <tn-icon name=\"help-circle\" library=\"mdi\" size=\"sm\" />\n </button>\n</ng-template>\n", styles: [".tn-form-field{display:block;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-form-field-label-row{display:flex;align-items:center;gap:.375rem;margin-bottom:.5rem}.tn-form-field-label{display:block;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-form-field-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-form-field-tooltip{display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var(--tn-fg2, #6c757d);cursor:help;line-height:0}.tn-form-field-tooltip:hover,.tn-form-field-tooltip:focus-visible{color:var(--tn-primary, #007bff)}.tn-form-field-tooltip:focus-visible{outline:2px solid var(--tn-primary, #007bff);outline-offset:2px;border-radius:50%}.tn-form-field-wrapper{position:relative;width:100%;overflow:visible}.tn-form-field-wrapper :ng-deep .tn-select-container,.tn-form-field-wrapper :ng-deep .tn-input-container{margin-bottom:0}.tn-form-field-wrapper :ng-deep .tn-select-label,.tn-form-field-wrapper :ng-deep .tn-input-label{display:none}.tn-form-field-wrapper :ng-deep .tn-select-error,.tn-form-field-wrapper :ng-deep .tn-input-error{display:none}.tn-form-field-wrapper :ng-deep .tn-select-dropdown{z-index:1000}.tn-form-field-wrapper--inline{align-items:center;display:flex;gap:.375rem}.tn-form-field-wrapper--inline .required-asterisk{color:var(--tn-error, #dc3545)}.tn-form-field-subscript{min-height:1.25rem;margin-top:.25rem;font-size:.75rem;line-height:1.4}.tn-form-field-subscript-dynamic{min-height:0}.tn-form-field-error{color:var(--tn-error, #dc3545);margin:0}.tn-form-field-hint{color:var(--tn-fg2, #6c757d);margin:0}.tn-form-field-wrapper:has(:focus-visible) .tn-form-field-label{color:var(--tn-primary, #007bff)}.tn-form-field-wrapper:has(.error) .tn-form-field-label{color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-form-field-label{transition:none}}@media(prefers-contrast:high){.tn-form-field-label,.tn-form-field-error{font-weight:600}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "directive", type: TnTooltipDirective, selector: "[tnTooltip]", inputs: ["tnTooltip", "tnTooltipPosition", "tnTooltipDisabled", "tnTooltipShowDelay", "tnTooltipHideDelay", "tnTooltipClass"] }] });
6593
6854
  }
6594
6855
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFormFieldComponent, decorators: [{
6595
6856
  type: Component,
6596
- args: [{ selector: 'tn-form-field', standalone: true, imports: [TnTestIdDirective, TnIconComponent, TnTooltipDirective], template: "<div class=\"tn-form-field\" tnTestIdType=\"form-field\" [tnTestId]=\"testId()\">\n <!-- Label -->\n @if (label() || tooltip()) {\n <div class=\"tn-form-field-label-row\">\n @if (label()) {\n <label class=\"tn-form-field-label\" [class.required]=\"showRequired()\">\n {{ label() }}\n @if (showRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n </label>\n }\n @if (tooltip()) {\n <button\n type=\"button\"\n class=\"tn-form-field-tooltip\"\n [attr.aria-label]=\"tooltip()\"\n [tnTooltip]=\"tooltip()\"\n [tnTooltipPosition]=\"tooltipPosition()\">\n <tn-icon name=\"help-circle\" library=\"mdi\" size=\"sm\" />\n </button>\n }\n </div>\n }\n\n <!-- Form Control Content -->\n <div class=\"tn-form-field-wrapper\">\n <ng-content />\n </div>\n\n <!-- Hint or Error Message -->\n @if (showSubscript()) {\n <div class=\"tn-form-field-subscript\" [class.tn-form-field-subscript-dynamic]=\"subscriptSizing() === 'dynamic'\">\n @if (showError()) {\n <div\n class=\"tn-form-field-error\"\n role=\"alert\"\n aria-live=\"polite\">\n {{ errorMessage() }}\n </div>\n }\n @if (showHint()) {\n <div class=\"tn-form-field-hint\">\n {{ hint() }}\n </div>\n }\n </div>\n }\n</div>\n", styles: [".tn-form-field{display:block;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-form-field-label-row{display:flex;align-items:center;gap:.375rem;margin-bottom:.5rem}.tn-form-field-label{display:block;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-form-field-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-form-field-tooltip{display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var(--tn-fg2, #6c757d);cursor:help;line-height:0}.tn-form-field-tooltip:hover,.tn-form-field-tooltip:focus-visible{color:var(--tn-primary, #007bff)}.tn-form-field-tooltip:focus-visible{outline:2px solid var(--tn-primary, #007bff);outline-offset:2px;border-radius:50%}.tn-form-field-wrapper{position:relative;width:100%;overflow:visible}.tn-form-field-wrapper :ng-deep .tn-select-container,.tn-form-field-wrapper :ng-deep .tn-input-container{margin-bottom:0}.tn-form-field-wrapper :ng-deep .tn-select-label,.tn-form-field-wrapper :ng-deep .tn-input-label{display:none}.tn-form-field-wrapper :ng-deep .tn-select-error,.tn-form-field-wrapper :ng-deep .tn-input-error{display:none}.tn-form-field-wrapper :ng-deep .tn-select-dropdown{z-index:1000}.tn-form-field-subscript{min-height:1.25rem;margin-top:.25rem;font-size:.75rem;line-height:1.4}.tn-form-field-subscript-dynamic{min-height:0}.tn-form-field-error{color:var(--tn-error, #dc3545);margin:0}.tn-form-field-hint{color:var(--tn-fg2, #6c757d);margin:0}.tn-form-field-wrapper:has(:focus-visible) .tn-form-field-label{color:var(--tn-primary, #007bff)}.tn-form-field-wrapper:has(.error) .tn-form-field-label{color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-form-field-label{transition:none}}@media(prefers-contrast:high){.tn-form-field-label,.tn-form-field-error{font-weight:600}}\n"] }]
6857
+ args: [{ selector: 'tn-form-field', standalone: true, imports: [NgTemplateOutlet, TnTestIdDirective, TnIconComponent, TnTooltipDirective], template: "<div class=\"tn-form-field\" tnTestIdType=\"form-field\" [tnTestId]=\"testId()\">\n <!-- Label -->\n @if (label()) {\n <div class=\"tn-form-field-label-row\">\n <label class=\"tn-form-field-label\" [class.required]=\"showRequired()\">\n {{ label() }}\n @if (showRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n </label>\n @if (tooltip()) {\n <ng-container [ngTemplateOutlet]=\"tooltipButton\" />\n }\n </div>\n }\n\n <!-- Form Control Content -->\n <div\n class=\"tn-form-field-wrapper\"\n [class.tn-form-field-wrapper--inline]=\"showInlineExtras()\">\n <ng-content />\n @if (showInlineRequired()) {\n <span class=\"required-asterisk\" aria-label=\"required\">*</span>\n }\n @if (showInlineTooltip()) {\n <ng-container [ngTemplateOutlet]=\"tooltipButton\" />\n }\n </div>\n\n <!-- Hint or Error Message -->\n @if (showSubscript()) {\n <div class=\"tn-form-field-subscript\" [class.tn-form-field-subscript-dynamic]=\"subscriptSizing() === 'dynamic'\">\n @if (showError()) {\n <div\n class=\"tn-form-field-error\"\n role=\"alert\"\n aria-live=\"polite\">\n {{ errorMessage() }}\n </div>\n }\n @if (showHint()) {\n <div class=\"tn-form-field-hint\">\n {{ hint() }}\n </div>\n }\n </div>\n }\n</div>\n\n<ng-template #tooltipButton>\n <button\n type=\"button\"\n class=\"tn-form-field-tooltip\"\n [attr.aria-label]=\"tooltip()\"\n [tnTooltip]=\"tooltip()\"\n [tnTooltipPosition]=\"tooltipPosition()\">\n <tn-icon name=\"help-circle\" library=\"mdi\" size=\"sm\" />\n </button>\n</ng-template>\n", styles: [".tn-form-field{display:block;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-form-field-label-row{display:flex;align-items:center;gap:.375rem;margin-bottom:.5rem}.tn-form-field-label{display:block;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-form-field-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-form-field-tooltip{display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:var(--tn-fg2, #6c757d);cursor:help;line-height:0}.tn-form-field-tooltip:hover,.tn-form-field-tooltip:focus-visible{color:var(--tn-primary, #007bff)}.tn-form-field-tooltip:focus-visible{outline:2px solid var(--tn-primary, #007bff);outline-offset:2px;border-radius:50%}.tn-form-field-wrapper{position:relative;width:100%;overflow:visible}.tn-form-field-wrapper :ng-deep .tn-select-container,.tn-form-field-wrapper :ng-deep .tn-input-container{margin-bottom:0}.tn-form-field-wrapper :ng-deep .tn-select-label,.tn-form-field-wrapper :ng-deep .tn-input-label{display:none}.tn-form-field-wrapper :ng-deep .tn-select-error,.tn-form-field-wrapper :ng-deep .tn-input-error{display:none}.tn-form-field-wrapper :ng-deep .tn-select-dropdown{z-index:1000}.tn-form-field-wrapper--inline{align-items:center;display:flex;gap:.375rem}.tn-form-field-wrapper--inline .required-asterisk{color:var(--tn-error, #dc3545)}.tn-form-field-subscript{min-height:1.25rem;margin-top:.25rem;font-size:.75rem;line-height:1.4}.tn-form-field-subscript-dynamic{min-height:0}.tn-form-field-error{color:var(--tn-error, #dc3545);margin:0}.tn-form-field-hint{color:var(--tn-fg2, #6c757d);margin:0}.tn-form-field-wrapper:has(:focus-visible) .tn-form-field-label{color:var(--tn-primary, #007bff)}.tn-form-field-wrapper:has(.error) .tn-form-field-label{color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-form-field-label{transition:none}}@media(prefers-contrast:high){.tn-form-field-label,.tn-form-field-error{font-weight:600}}\n"] }]
6597
6858
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], subscriptSizing: [{ type: i0.Input, args: [{ isSignal: true, alias: "subscriptSizing", required: false }] }], tooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltip", required: false }] }], tooltipPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "tooltipPosition", required: false }] }], errorMessages: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorMessages", required: false }] }], control: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgControl), { isSignal: true }] }] } });
6598
6859
 
6599
6860
  /**
@@ -6749,6 +7010,22 @@ class TnFormFieldHarness extends ComponentHarness {
6749
7010
  const tooltip = await this._tooltip();
6750
7011
  return tooltip ? tooltip.getAttribute('aria-label') : null;
6751
7012
  }
7013
+ /**
7014
+ * Checks whether the tooltip help icon renders inline after the projected
7015
+ * control (label-less mode) rather than in the label row.
7016
+ *
7017
+ * @returns Promise resolving to true if the inline tooltip trigger is present.
7018
+ *
7019
+ * @example
7020
+ * ```typescript
7021
+ * const field = await loader.getHarness(TnFormFieldHarness.with({ testId: 'enable-fxp' }));
7022
+ * expect(await field.isTooltipInline()).toBe(true);
7023
+ * ```
7024
+ */
7025
+ async isTooltipInline() {
7026
+ const inline = await this.locatorForOptional('.tn-form-field-wrapper .tn-form-field-tooltip')();
7027
+ return inline !== null;
7028
+ }
6752
7029
  /**
6753
7030
  * Checks whether the form field is marked as required.
6754
7031
  *
@@ -6762,10 +7039,12 @@ class TnFormFieldHarness extends ComponentHarness {
6762
7039
  */
6763
7040
  async isRequired() {
6764
7041
  const label = await this._label();
6765
- if (!label) {
6766
- return false;
7042
+ if (label) {
7043
+ return label.hasClass('required');
6767
7044
  }
6768
- return label.hasClass('required');
7045
+ // Label-less mode renders the asterisk inline after the projected control.
7046
+ const inlineAsterisk = await this.locatorForOptional('.tn-form-field-wrapper .required-asterisk')();
7047
+ return inlineAsterisk !== null;
6769
7048
  }
6770
7049
  /**
6771
7050
  * Gets the test ID attribute value.
@@ -8335,51 +8614,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
8335
8614
  }, template: "@if (icon()) {\n <div class=\"tn-empty__icon\">\n <tn-icon\n aria-hidden=\"true\"\n [name]=\"icon()!\"\n [library]=\"iconLibrary()\"\n [size]=\"iconSizePreset()\"\n [customSize]=\"iconSize()\"\n />\n </div>\n}\n\n<div class=\"tn-empty__title\">\n {{ title() }}\n</div>\n\n@if (description()) {\n <div class=\"tn-empty__description\">\n {{ description() }}\n </div>\n}\n\n@if (hasAction()) {\n <div class=\"tn-empty__action\">\n <tn-button\n color=\"primary\"\n variant=\"outline\"\n [label]=\"actionText()!\"\n (onClick)=\"onAction.emit()\"\n />\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:8px;padding:48px 24px;border-radius:8px}:host(.tn-empty--bordered){border:1px solid var(--tn-lines)}:host(.tn-empty--compact){padding:24px 16px}:host(.tn-empty--compact) .tn-empty__title{font-size:1rem}:host(.tn-empty--compact) .tn-empty__description{font-size:.8125rem}:host(.tn-empty--compact) .tn-empty__icon{margin-bottom:4px}:host(.tn-empty--compact) .tn-empty__action{margin-top:4px}.tn-empty__icon{color:var(--tn-fg2, #6b7280);margin-bottom:8px}.tn-empty__title{font-size:1.125rem;font-weight:600;color:var(--tn-fg1, #e5e7eb);line-height:1.4}.tn-empty__description{font-size:1rem;color:var(--tn-fg2, #6b7280);line-height:1.5;max-width:420px}.tn-empty__action{margin-top:8px}\n"] }]
8336
8615
  }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], iconLibrary: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconLibrary", required: false }] }], iconSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconSize", required: false }] }], actionText: [{ type: i0.Input, args: [{ isSignal: true, alias: "actionText", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], onAction: [{ type: i0.Output, args: ["onAction"] }] } });
8337
8616
 
8338
- class TnSpinnerComponent {
8339
- mode = input('indeterminate', ...(ngDevMode ? [{ debugName: "mode" }] : []));
8340
- value = input(0, ...(ngDevMode ? [{ debugName: "value" }] : []));
8341
- diameter = input(40, ...(ngDevMode ? [{ debugName: "diameter" }] : []));
8342
- strokeWidth = input(4, ...(ngDevMode ? [{ debugName: "strokeWidth" }] : []));
8343
- ariaLabel = input(null, ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
8344
- ariaLabelledby = input(null, ...(ngDevMode ? [{ debugName: "ariaLabelledby" }] : []));
8345
- radius = computed(() => {
8346
- return (this.diameter() - this.strokeWidth()) / 2;
8347
- }, ...(ngDevMode ? [{ debugName: "radius" }] : []));
8348
- circumference = computed(() => {
8349
- return 2 * Math.PI * this.radius();
8350
- }, ...(ngDevMode ? [{ debugName: "circumference" }] : []));
8351
- strokeDasharray = computed(() => {
8352
- return `${this.circumference()} ${this.circumference()}`;
8353
- }, ...(ngDevMode ? [{ debugName: "strokeDasharray" }] : []));
8354
- strokeDashoffset = computed(() => {
8355
- if (this.mode() === 'indeterminate') {
8356
- return 0;
8357
- }
8358
- const progress = Math.max(0, Math.min(100, this.value()));
8359
- return this.circumference() - (progress / 100) * this.circumference();
8360
- }, ...(ngDevMode ? [{ debugName: "strokeDashoffset" }] : []));
8361
- viewBox = computed(() => {
8362
- const size = this.diameter();
8363
- return `0 0 ${size} ${size}`;
8364
- }, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
8365
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSpinnerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8366
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: TnSpinnerComponent, isStandalone: true, selector: "tn-spinner", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, diameter: { classPropertyName: "diameter", publicName: "diameter", isSignal: true, isRequired: false, transformFunction: null }, strokeWidth: { classPropertyName: "strokeWidth", publicName: "strokeWidth", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "progressbar" }, properties: { "class.tn-spinner-indeterminate": "mode() === \"indeterminate\"", "class.tn-spinner-determinate": "mode() === \"determinate\"", "attr.aria-valuenow": "mode() === \"determinate\" ? value() : null", "attr.aria-valuemin": "mode() === \"determinate\" ? 0 : null", "attr.aria-valuemax": "mode() === \"determinate\" ? 100 : null", "attr.aria-label": "ariaLabel() || null", "attr.aria-labelledby": "ariaLabelledby() || null" }, classAttribute: "tn-spinner" }, ngImport: i0, template: "<svg\n class=\"tn-spinner-svg\"\n [attr.width]=\"diameter()\"\n [attr.height]=\"diameter()\"\n [attr.viewBox]=\"viewBox()\">\n <circle\n class=\"tn-spinner-circle\"\n fill=\"none\"\n [attr.cx]=\"diameter() / 2\"\n [attr.cy]=\"diameter() / 2\"\n [attr.r]=\"radius()\"\n [attr.stroke-width]=\"strokeWidth()\"\n [attr.stroke-dasharray]=\"strokeDasharray()\"\n [attr.stroke-dashoffset]=\"strokeDashoffset()\" />\n</svg>", styles: [".tn-spinner{display:inline-block;vertical-align:middle}.tn-spinner-svg{animation:tn-spinner-rotate 2s linear infinite;transform-origin:center}.tn-spinner-circle{stroke:var(--tn-primary, #007bff);stroke-linecap:round;transition:stroke-dashoffset .35s cubic-bezier(.4,0,.2,1)}.tn-spinner-indeterminate .tn-spinner-circle{animation:tn-spinner-dash 1.4s ease-in-out infinite}.tn-spinner-determinate .tn-spinner-svg{animation:none;transform:rotate(-90deg)}@keyframes tn-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes tn-spinner-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:100,200;stroke-dashoffset:-15}to{stroke-dasharray:100,200;stroke-dashoffset:-125}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
8367
- }
8368
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSpinnerComponent, decorators: [{
8369
- type: Component,
8370
- args: [{ selector: 'tn-spinner', standalone: true, imports: [], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: {
8371
- 'class': 'tn-spinner',
8372
- '[class.tn-spinner-indeterminate]': 'mode() === "indeterminate"',
8373
- '[class.tn-spinner-determinate]': 'mode() === "determinate"',
8374
- '[attr.aria-valuenow]': 'mode() === "determinate" ? value() : null',
8375
- '[attr.aria-valuemin]': 'mode() === "determinate" ? 0 : null',
8376
- '[attr.aria-valuemax]': 'mode() === "determinate" ? 100 : null',
8377
- 'role': 'progressbar',
8378
- '[attr.aria-label]': 'ariaLabel() || null',
8379
- '[attr.aria-labelledby]': 'ariaLabelledby() || null'
8380
- }, template: "<svg\n class=\"tn-spinner-svg\"\n [attr.width]=\"diameter()\"\n [attr.height]=\"diameter()\"\n [attr.viewBox]=\"viewBox()\">\n <circle\n class=\"tn-spinner-circle\"\n fill=\"none\"\n [attr.cx]=\"diameter() / 2\"\n [attr.cy]=\"diameter() / 2\"\n [attr.r]=\"radius()\"\n [attr.stroke-width]=\"strokeWidth()\"\n [attr.stroke-dasharray]=\"strokeDasharray()\"\n [attr.stroke-dashoffset]=\"strokeDashoffset()\" />\n</svg>", styles: [".tn-spinner{display:inline-block;vertical-align:middle}.tn-spinner-svg{animation:tn-spinner-rotate 2s linear infinite;transform-origin:center}.tn-spinner-circle{stroke:var(--tn-primary, #007bff);stroke-linecap:round;transition:stroke-dashoffset .35s cubic-bezier(.4,0,.2,1)}.tn-spinner-indeterminate .tn-spinner-circle{animation:tn-spinner-dash 1.4s ease-in-out infinite}.tn-spinner-determinate .tn-spinner-svg{animation:none;transform:rotate(-90deg)}@keyframes tn-spinner-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes tn-spinner-dash{0%{stroke-dasharray:1,200;stroke-dashoffset:0}50%{stroke-dasharray:100,200;stroke-dashoffset:-15}to{stroke-dasharray:100,200;stroke-dashoffset:-125}}\n"] }]
8381
- }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], diameter: [{ type: i0.Input, args: [{ isSignal: true, alias: "diameter", required: false }] }], strokeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "strokeWidth", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabelledby", required: false }] }] } });
8382
-
8383
8617
  class TnHeaderCellDefDirective {
8384
8618
  template = inject((TemplateRef));
8385
8619
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnHeaderCellDefDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });