@truenas/ui-components 0.7.4 → 0.7.6

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,9 +1,12 @@
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, computed, isSignal, isDevMode, effect, signal, input, ViewEncapsulation, ChangeDetectionStrategy, Component, Injector, Renderer2, ElementRef, Directive, ViewContainerRef, output, viewChild, untracked, forwardRef, DestroyRef, NgZone, afterNextRender, model, Injectable, contentChildren, Pipe, HostListener, linkedSignal, contentChild, TemplateRef, viewChildren, ChangeDetectorRef, afterEveryRender, IterableDiffers, booleanAttribute, LOCALE_ID, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
4
+ import { InjectionToken, inject, computed, isSignal, isDevMode, effect, signal, input, ViewEncapsulation, ChangeDetectionStrategy, Component, Injector, Renderer2, ElementRef, Directive, DestroyRef, untracked, ViewContainerRef, output, viewChild, forwardRef, NgZone, afterNextRender, model, Injectable, contentChildren, Pipe, HostListener, linkedSignal, contentChild, TemplateRef, viewChildren, ChangeDetectorRef, afterEveryRender, IterableDiffers, booleanAttribute, LOCALE_ID, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
5
5
  import * as i1$3 from '@angular/forms';
6
6
  import { NgControl, NG_VALUE_ACCESSOR, FormsModule, Validators } from '@angular/forms';
7
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
+ import { Subject, of, timer, firstValueFrom, merge, BehaviorSubject, animationFrameScheduler, asapScheduler, take as take$1 } from 'rxjs';
9
+ import { take, defaultIfEmpty, filter, debounce, distinctUntilChanged, tap, switchMap, map, catchError, debounceTime, auditTime } from 'rxjs/operators';
7
10
  import { ComponentHarness, HarnessPredicate, parallel, TestKey } from '@angular/cdk/testing';
8
11
  import * as i1 from '@angular/cdk/a11y';
9
12
  import { A11yModule, AriaDescriber, FocusMonitor, _IdGenerator } from '@angular/cdk/a11y';
@@ -13,20 +16,17 @@ import { mdiCheckCircle, mdiAlertCircle, mdiAlert, mdiInformation, mdiOpenInNew,
13
16
  import * as i1$1 from '@angular/platform-browser';
14
17
  import { DomSanitizer } from '@angular/platform-browser';
15
18
  import { HttpClient } from '@angular/common/http';
16
- import { firstValueFrom, merge, BehaviorSubject, animationFrameScheduler, asapScheduler, take, Subject } from 'rxjs';
17
19
  import { RouterLink } from '@angular/router';
18
20
  import { ScrollDispatcher, ViewportRuler, CdkVirtualScrollViewport, CdkVirtualScrollableWindow, CdkFixedSizeVirtualScroll, CdkVirtualForOf, ScrollingModule } from '@angular/cdk/scrolling';
19
21
  import { filesize } from 'filesize';
20
22
  import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
21
23
  import { SPACE, ENTER, END, HOME, DOWN_ARROW, UP_ARROW, RIGHT_ARROW, LEFT_ARROW } from '@angular/cdk/keycodes';
22
24
  import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
23
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
24
25
  import { trigger, state, transition, style, animate } from '@angular/animations';
25
26
  import { SelectionModel, DataSource } from '@angular/cdk/collections';
26
27
  import * as i2 from '@angular/cdk/tree';
27
28
  import { CdkTree, CdkTreeModule, CdkTreeNode, CDK_TREE_NODE_OUTLET_NODE, CdkNestedTreeNode, CdkTreeNodeOutlet, FlatTreeControl, NestedTreeControl, CdkTreeNodeOutletContext } from '@angular/cdk/tree';
28
29
  export { FlatTreeControl } from '@angular/cdk/tree';
29
- import { map, filter, debounceTime, distinctUntilChanged, auditTime } from 'rxjs/operators';
30
30
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
31
31
 
32
32
  /** DI token under which `tn-form-field` exposes its {@link TnFormFieldContext}. */
@@ -652,6 +652,354 @@ function injectTnLabels(token) {
652
652
  return isSignal(provided) ? provided : signal(provided).asReadonly();
653
653
  }
654
654
 
655
+ /**
656
+ * The stateful half of a server-driven dropdown: debounce, cancellation,
657
+ * paging, exhaustion, and error recovery, so a host component only has to
658
+ * render {@link TnOptionsDataSource.options} and forward its own events.
659
+ *
660
+ * Must be called in an injection context — it ties its subscription to the
661
+ * caller's {@link DestroyRef}.
662
+ *
663
+ * Notable behaviours, each of which was a bug in a hand-rolled copy of this:
664
+ *
665
+ * - **Errors never kill the stream.** `catchError` sits inside the `switchMap`,
666
+ * so a failed request reports and yields an empty page while the outer
667
+ * subscription survives; without that, one transient failure freezes the
668
+ * field for the life of the panel.
669
+ * - **A failed term is retryable.** `distinctUntilChanged` treats a repeat of a
670
+ * failed query as a change, so reopening a picker after an error refetches
671
+ * instead of being suppressed as a duplicate.
672
+ * - **A failed page does not advance the cursor**, so the next scroll re-requests
673
+ * the page that errored rather than stepping over those rows. The cursor
674
+ * tracks the *next* page to ask for rather than the last one loaded, so page 0
675
+ * rolls back like every other page instead of being silently skipped.
676
+ * - **A late page cannot contaminate a newer search.** Every response carries
677
+ * the generation it was requested in and is dropped if a search — or a
678
+ * {@link TnOptionsDataSource.refresh} — has since started. Cancellation is
679
+ * not enough on its own: `refresh` invalidates without issuing a request, so
680
+ * there is nothing for `switchMap` to unsubscribe the old one in favour of.
681
+ */
682
+ function createTnOptionsDataSource(config) {
683
+ const destroyRef = inject(DestroyRef);
684
+ const options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : []));
685
+ const loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
686
+ const exhausted = signal(false, ...(ngDevMode ? [{ debugName: "exhausted" }] : []));
687
+ /** Current query, replayed by `loadMore` so a page matches what is on screen. */
688
+ let query = '';
689
+ /**
690
+ * Latest term handed to {@link search}, whether or not its request has been
691
+ * dispatched yet — what {@link prime} re-asks with.
692
+ *
693
+ * `query` is only assigned once a request survives the debounce, so it lags
694
+ * the field by up to `debounceMs`. That gap matters because `prime` emits
695
+ * `immediate: true`: a prime landing inside a pending search's debounce
696
+ * window cancels that search outright, and priming with `query` would then
697
+ * fetch the *previous* term while the input holds the newer one — rows that
698
+ * do not match what is typed, with nothing left to re-query them. Priming
699
+ * with the pending term promotes the cancelled search instead of losing it.
700
+ */
701
+ let pendingQuery = '';
702
+ /**
703
+ * Index of the next page to request for `query`.
704
+ *
705
+ * Deliberately "next to request" rather than "last loaded": those two only
706
+ * differ on page 0, which is exactly where the difference bites. A fresh
707
+ * search resets the cursor *before* anything has landed, so a "last loaded"
708
+ * counter reads 0 both for "page 0 is on screen" and for "page 0 failed" —
709
+ * and the next `loadMore` would step over the first page of matches instead
710
+ * of retrying it.
711
+ */
712
+ let nextPage = 0;
713
+ /** Whether a query has run successfully — gates {@link prime}. */
714
+ let primed = false;
715
+ /**
716
+ * Whether the host has ever asked for a first page, i.e. whether its panel
717
+ * has been opened. Set by `prime` even on the calls it declines to issue,
718
+ * which is what tells the late-source effect below that a page is owed.
719
+ */
720
+ let primeRequested = false;
721
+ /**
722
+ * Set when a request fails, cleared when the next one starts. Read by the
723
+ * `distinctUntilChanged` comparator so the same term can be retried.
724
+ */
725
+ let lastRequestFailed = false;
726
+ /**
727
+ * Bumped by every fresh search. A `loadMore` response from an older
728
+ * generation is discarded rather than appended to a different result set.
729
+ */
730
+ let generation = 0;
731
+ /**
732
+ * Set by `refresh`, cleared when the request it queued starts. Read by the
733
+ * `distinctUntilChanged` comparator, the same way `lastRequestFailed` is: a
734
+ * refresh re-asks the term already on screen, which is exactly the shape that
735
+ * comparator exists to suppress.
736
+ */
737
+ let refreshRequested = false;
738
+ /**
739
+ * Whether a page-0 request is outstanding — i.e. whether the search pipeline
740
+ * owns {@link loading} right now.
741
+ *
742
+ * Read by {@link TnOptionsDataSource.loadMore}'s stale-generation branches.
743
+ * Those bail without touching the flag on the premise that a newer request
744
+ * owns it, which is true when the generation was bumped by the `tap` below
745
+ * (it sets `loading` in the same block) and false when `refresh` bumped it
746
+ * without issuing anything: blur a panel with a page in flight, refresh it,
747
+ * and `loading` would stay `true` for good — with `loadMore` a no-op behind
748
+ * it — until the next open happened to prime.
749
+ */
750
+ let searchInFlight = false;
751
+ /**
752
+ * How many `loadMore` pages are outstanding.
753
+ *
754
+ * The companion to {@link searchInFlight}: that one tracks only the search
755
+ * pipeline, so a page retired by an OLDER generation could release `loading`
756
+ * out from under a newer `loadMore` that had genuinely set it — issue page A,
757
+ * type (its search settles and clears the flag), scroll for page B, then let
758
+ * A finally arrive, and the spinner vanished for the rest of B's round trip.
759
+ */
760
+ let loadMoreInFlight = 0;
761
+ const requests$ = new Subject();
762
+ /**
763
+ * One page from `fetch`, normalized to exactly one emission.
764
+ *
765
+ * Every latch in here — `loading`, `searchInFlight`, `loadMoreInFlight`,
766
+ * `onSettled` — is released from the `next` handler, so a source that
767
+ * completes without emitting released none of them: `loading` stuck true, and
768
+ * `loadMore` bails on `loading()`, so paging was dead for the rest of the
769
+ * session. `defaultIfEmpty` makes an empty completion an empty page, which is
770
+ * what it means. `take(1)` is the other half: a source that emits more than
771
+ * once would otherwise append the same page twice through `loadMore` and
772
+ * decrement `loadMoreInFlight` below zero.
773
+ */
774
+ function fetchPage(fetch, term, page) {
775
+ return fetch(term, page).pipe(take(1), defaultIfEmpty([]));
776
+ }
777
+ requests$
778
+ .pipe(
779
+ // A request made with no source bound is not a request. Every latch
780
+ // below — `primed`, the cursor, `onSettled` — describes a round trip,
781
+ // and answering this with an empty success made all three fire for one
782
+ // that never happened: a `[dataSource]` that arrives after the user has
783
+ // typed once found `primed` already latched and never fetched at all,
784
+ // and a host with no source at all had its paging latch released on a
785
+ // timer, which is enough to page the same rows twice from one scroll.
786
+ //
787
+ // Dropped ahead of the duplicate-term guard on purpose: that operator
788
+ // remembers whatever passes THROUGH it, so a term filtered out later
789
+ // would still become the "previous" one and suppress the identical term
790
+ // once a source finally is bound.
791
+ filter(() => !!config.source()),
792
+ // `prime` must not sit behind the typing debounce — an empty first page
793
+ // should be on screen as the panel opens, not a quarter-second later.
794
+ debounce((request) => (request.immediate ? of(0) : timer(config.debounceMs()))), distinctUntilChanged((previous, current) => previous.query === current.query
795
+ && !lastRequestFailed
796
+ && !refreshRequested), tap((request) => {
797
+ query = request.query;
798
+ nextPage = 0;
799
+ lastRequestFailed = false;
800
+ refreshRequested = false;
801
+ generation++;
802
+ searchInFlight = true;
803
+ loading.set(true);
804
+ }), switchMap((request) => {
805
+ // Stamped with the generation it was asked for in, the way `loadMore`
806
+ // stamps its pages: `refresh` retires a request in flight without
807
+ // pushing anything through this subject, so `switchMap` alone never
808
+ // cancels it and its rows would otherwise land as if still current.
809
+ const requestedGeneration = generation;
810
+ const fetch = config.source();
811
+ if (!fetch) {
812
+ // The source went away inside the debounce window, past the filter
813
+ // above. Tagged rather than answered: `failed` would make the term
814
+ // look retryable-because-broken, and a plain success would latch the
815
+ // state of a fetch that never ran.
816
+ return of({
817
+ rows: [], failed: false, skipped: true, requestedGeneration,
818
+ });
819
+ }
820
+ return fetchPage(fetch, request.query, 0).pipe(map((rows) => ({
821
+ rows, failed: false, skipped: false, requestedGeneration,
822
+ })), catchError((error) => {
823
+ config.onError(error);
824
+ return of({
825
+ rows: [], failed: true, skipped: false, requestedGeneration,
826
+ });
827
+ }));
828
+ }), takeUntilDestroyed(destroyRef))
829
+ .subscribe(({ rows, failed, skipped, requestedGeneration, }) => {
830
+ // No other request can be outstanding — a newer one through this subject
831
+ // would have unsubscribed this response — so the flag is released either
832
+ // way, stale or not.
833
+ searchInFlight = false;
834
+ loading.set(false);
835
+ if (skipped) {
836
+ // Nothing was asked, so nothing may be latched. `onSettled` in
837
+ // particular is the host's paging latch: releasing it here hands a
838
+ // consumer a second `loadMore` for a page still in flight.
839
+ return;
840
+ }
841
+ if (requestedGeneration !== generation) {
842
+ // Fetched under a configuration `refresh` has since retired. Latching
843
+ // `primed` on it would answer the next `prime` from the invalidated
844
+ // pages for good, which is the exact failure `refresh` exists to
845
+ // prevent; `lastRequestFailed` is left alone for the same reason.
846
+ config.onSettled?.();
847
+ return;
848
+ }
849
+ lastRequestFailed = failed;
850
+ // An empty page from a failure is not evidence that the source is
851
+ // exhausted — leave paging open so a retry can still reach page 1.
852
+ exhausted.set(failed ? false : rows.length < config.pageSize());
853
+ // A failed page 0 leaves the cursor on 0, so the next `loadMore` retries
854
+ // it; `primed` likewise only latches on a request that arrived.
855
+ nextPage = failed ? 0 : 1;
856
+ primed = primed || !failed;
857
+ options.set(rows);
858
+ config.onSettled?.();
859
+ });
860
+ /**
861
+ * Issue page 0 for the current term, unless one has already been fetched
862
+ * successfully or there is no source to fetch from.
863
+ *
864
+ * `lastRequestFailed` is what makes a failed term retryable on the `search`
865
+ * path; without it here, one transient failure would empty a click-to-open
866
+ * picker for the life of the field — reopening the panel would issue no
867
+ * request at all.
868
+ */
869
+ function primeNow() {
870
+ if ((primed && !lastRequestFailed) || !config.source()) {
871
+ return;
872
+ }
873
+ // The latest term the field asked for, not `''` and not the last one
874
+ // dispatched: on the retry path the field may already hold the text whose
875
+ // lookup failed, and a term still inside the debounce window is cancelled
876
+ // by this emission — see `pendingQuery`.
877
+ requests$.next({ query: pendingQuery, immediate: true });
878
+ }
879
+ // A source that binds LATE — a resolver, or a sibling field's signal, both
880
+ // shapes the `| undefined` input type invites — issues nothing by itself:
881
+ // `source` is only read from inside the request pipeline, and the filter
882
+ // above dropped whatever was asked while it was undefined. A closed panel
883
+ // recovers on its next open, which primes. An OPEN one had nothing left to
884
+ // ask, and sat on "No results found" (or, for the chip input, an empty
885
+ // attached listbox) until the user typed or closed and reopened it.
886
+ //
887
+ // Gated on `primeRequested` so this cannot break the rule that a field
888
+ // nobody has opened issues no query: the transition only fetches for a host
889
+ // that already asked and was turned away.
890
+ //
891
+ // `untracked` because emitting runs the pipeline synchronously, which reads
892
+ // `source` and `debounceMs` — tracked here, those would re-run this effect
893
+ // on every debounce change and on its own writes.
894
+ //
895
+ // Latched on the false->true EDGE rather than on every run: this effect
896
+ // re-runs on any identity change of `source`, and `[dataSource]="(q, p) =>
897
+ // this.search(q, p)"` — the call site the docs warn against, but one that
898
+ // type-checks — hands it a new reference on every change detection. Priming
899
+ // unconditionally then wrote `loading`, which schedules another change
900
+ // detection, which changes the input again: a cancel-storm of requests for
901
+ // the whole first round trip, and indefinitely if the endpoint keeps failing.
902
+ let hadSource = !!untracked(() => config.source());
903
+ effect(() => {
904
+ const hasSource = !!config.source();
905
+ untracked(() => {
906
+ const bound = hasSource && !hadSource;
907
+ hadSource = hasSource;
908
+ if (bound && primeRequested) {
909
+ primeNow();
910
+ }
911
+ });
912
+ });
913
+ /**
914
+ * Release {@link loading} for a page whose result set has been retired,
915
+ * unless a search or a later `loadMore` is in flight and owns the flag.
916
+ */
917
+ function releaseStaleLoading() {
918
+ if (!searchInFlight && loadMoreInFlight === 0) {
919
+ loading.set(false);
920
+ }
921
+ }
922
+ return {
923
+ options: options.asReadonly(),
924
+ loading: loading.asReadonly(),
925
+ exhausted: exhausted.asReadonly(),
926
+ search(next) {
927
+ pendingQuery = next;
928
+ requests$.next({ query: next, immediate: false });
929
+ },
930
+ prime() {
931
+ // Recorded before the guards: a prime declined for want of a source is
932
+ // still the host saying it wants a first page, and the effect above is
933
+ // what honours it once one arrives.
934
+ primeRequested = true;
935
+ primeNow();
936
+ },
937
+ loadMore() {
938
+ const fetch = config.source();
939
+ if (!fetch || loading() || exhausted()) {
940
+ return;
941
+ }
942
+ const requestedPage = nextPage;
943
+ const requestedGeneration = generation;
944
+ loading.set(true);
945
+ loadMoreInFlight++;
946
+ fetchPage(fetch, query, requestedPage)
947
+ .pipe(takeUntilDestroyed(destroyRef))
948
+ .subscribe({
949
+ next: (rows) => {
950
+ loadMoreInFlight--;
951
+ if (requestedGeneration !== generation) {
952
+ // This page belongs to a result set that has been retired. A
953
+ // fresh SEARCH owns `loading` and will release it; a `refresh`
954
+ // issued nothing, so this is the only thing that can.
955
+ releaseStaleLoading();
956
+ return;
957
+ }
958
+ loading.set(false);
959
+ // Only a page that arrived counts as read — see the error branch.
960
+ nextPage = requestedPage + 1;
961
+ primed = true;
962
+ exhausted.set(rows.length < config.pageSize());
963
+ options.update((current) => {
964
+ const seen = new Set(current.map(config.identity));
965
+ return [...current, ...rows.filter((row) => !seen.has(config.identity(row)))];
966
+ });
967
+ config.onSettled?.();
968
+ },
969
+ error: (error) => {
970
+ loadMoreInFlight--;
971
+ if (requestedGeneration !== generation) {
972
+ releaseStaleLoading();
973
+ return;
974
+ }
975
+ loading.set(false);
976
+ // `nextPage` deliberately not advanced: the next scroll re-requests
977
+ // this page instead of skipping the rows it would have held.
978
+ config.onError(error);
979
+ config.onSettled?.();
980
+ },
981
+ });
982
+ },
983
+ refresh() {
984
+ primed = false;
985
+ nextPage = 0;
986
+ exhausted.set(false);
987
+ // Any page still in flight was asked for under the old configuration —
988
+ // bump the generation so it is dropped rather than appended to whatever
989
+ // the next request produces.
990
+ generation++;
991
+ // The refetch re-asks the term already on screen, which is exactly what
992
+ // the duplicate-term guard suppresses. Left armed until a request
993
+ // actually starts, so a host that refreshes while closed is still not
994
+ // suppressed when it primes on its next open.
995
+ refreshRequested = true;
996
+ // `options` is deliberately left alone: a panel that is open keeps
997
+ // showing the previous rows until the replacements land, rather than
998
+ // blinking empty for a round trip.
999
+ },
1000
+ };
1001
+ }
1002
+
655
1003
  let nextId$6 = 0;
656
1004
  /** English defaults used when no `TN_AUTOCOMPLETE_LABELS` provider is registered. */
657
1005
  const TN_AUTOCOMPLETE_DEFAULT_LABELS = {
@@ -678,6 +1026,12 @@ class TnAutocompleteComponent {
678
1026
  * to the form control. A written value is resolved back to its option's
679
1027
  * label for display — falling back to `String(value)` until the matching
680
1028
  * option is available, and upgraded once an async option load lands.
1029
+ *
1030
+ * With a `[dataSource]` bound these are not listed in the dropdown, which is
1031
+ * the source's, but they still NAME a value: pass the options a host already
1032
+ * holds labels for — the ids on the record being edited — and the field
1033
+ * reads them as names from the moment it renders, rather than as raw numbers
1034
+ * until someone opens it.
681
1035
  */
682
1036
  options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
683
1037
  /**
@@ -706,9 +1060,71 @@ class TnAutocompleteComponent {
706
1060
  * exclusive with `requireSelection`, which reverts unmatched text.
707
1061
  */
708
1062
  allowCustomValue = input(false, ...(ngDevMode ? [{ debugName: "allowCustomValue" }] : []));
1063
+ /**
1064
+ * Server-driven options: a function of `(query, page)` returning one page of
1065
+ * matches. Binding it hands the component the whole async lifecycle —
1066
+ * debounced search, request cancellation, the paging cursor, exhaustion,
1067
+ * error recovery and the loading state — so a consumer no longer writes a
1068
+ * `searchChange` → subject → `switchMap` → `options` pipeline of its own.
1069
+ *
1070
+ * While set, the source is the only origin of DROPDOWN rows and the
1071
+ * client-side filter is skipped, since the server already filtered — pass an
1072
+ * explicit `[filterFn]` to filter the fetched page on top. `[options]` stays
1073
+ * a source of LABELS, for values the host can already name before the first
1074
+ * page is fetched; see {@link options}.
1075
+ *
1076
+ * The first page is fetched when the panel first opens, not on init, so a
1077
+ * form full of pickers issues no queries until one is actually used. Binding
1078
+ * a source that is `undefined` at that moment is fine — the field fetches as
1079
+ * soon as one arrives, open panel included.
1080
+ *
1081
+ * Swapping one source function for ANOTHER is not a signal to refetch, since
1082
+ * a source is expected to be a stable function reading live configuration.
1083
+ * Call {@link refreshOptions} when that configuration moves.
1084
+ *
1085
+ * @example
1086
+ * ```html
1087
+ * <tn-autocomplete [dataSource]="userOptions" [pageSize]="50" />
1088
+ * ```
1089
+ * ```ts
1090
+ * userOptions: TnOptionsFetchFn<string> =
1091
+ * (query, page) => this.api.searchUsers(query, page * 50);
1092
+ * ```
1093
+ */
1094
+ dataSource = input(undefined, ...(ngDevMode ? [{ debugName: "dataSource" }] : []));
1095
+ /** Debounce applied to typing before `dataSource` is queried, in ms. */
1096
+ dataSourceDebounce = input(250, ...(ngDevMode ? [{ debugName: "dataSourceDebounce" }] : []));
1097
+ /**
1098
+ * Rows `dataSource` returns per page. Used to detect exhaustion — a short
1099
+ * page is the last one — so scrolling past the end stops querying rather
1100
+ * than issuing requests at a growing offset that can only return nothing.
1101
+ */
1102
+ pageSize = input(50, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
1103
+ /**
1104
+ * A row pinned above the results that performs an action instead of
1105
+ * selecting a value — "Add New…" and friends. It survives filtering and the
1106
+ * `maxResults` cap, and choosing it emits {@link actionSelected} and closes
1107
+ * the panel **without** touching the form control, so no placeholder
1108
+ * sentinel is ever committed to the value.
1109
+ *
1110
+ * **Must be a stable reference.** The row is recognized by identity, so the
1111
+ * obvious call site — `[actionOption]="{ label: 'Add ' + term, value: null }"`
1112
+ * — builds a new object on every change-detection pass, re-firing the input,
1113
+ * invalidating the filtered rows and marking the component dirty again. Hold
1114
+ * it in a field, or derive it with `computed()`.
1115
+ */
1116
+ actionOption = input(undefined, ...(ngDevMode ? [{ debugName: "actionOption" }] : []));
1117
+ /**
1118
+ * Keep the committed value listed even when the page on screen does not
1119
+ * contain it — a value sorted past the first page, or one just created that
1120
+ * the query does not match yet. Without it such a field renders blank for a
1121
+ * perfectly valid value. Only applies with `dataSource` bound.
1122
+ */
1123
+ keepSelectedOption = input(true, ...(ngDevMode ? [{ debugName: "keepSelectedOption" }] : []));
709
1124
  /**
710
1125
  * Show a loading row in the dropdown panel while options are being fetched.
711
- * Pair with `searchChange`/`loadMore` for server-driven options.
1126
+ * Pair with `searchChange`/`loadMore` for server-driven options. Redundant
1127
+ * with `dataSource`, which drives this state itself; the two are OR-ed.
712
1128
  */
713
1129
  loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
714
1130
  /** Text shown next to the spinner while `loading` is set. */
@@ -777,12 +1193,30 @@ class TnAutocompleteComponent {
777
1193
  * lookup is expensive.
778
1194
  */
779
1195
  searchChange = output();
1196
+ /**
1197
+ * Emits when {@link actionOption} is chosen. No value is committed and the
1198
+ * panel closes; the consumer runs whatever the row promises (typically
1199
+ * opening a create form) and can then write the new value to the control.
1200
+ */
1201
+ actionSelected = output();
1202
+ /**
1203
+ * Emits once per failed `dataSource` request. The component recovers on its
1204
+ * own — the stream stays alive and the failed term stays retryable — so this
1205
+ * is purely for the app to report the failure the way it reports others.
1206
+ */
1207
+ dataSourceError = output();
780
1208
  /**
781
1209
  * Emits when the open dropdown is scrolled near its bottom — append the
782
1210
  * next page to `options` (and use `loading` while it fetches). Suppressed
783
- * until the `options` COUNT changes so a slow consumer is not spammed —
784
- * which also means replacing the array with a same-length page (e.g. a
785
- * fixed-size window) does not re-arm the emitter; pagination must append.
1211
+ * until the row COUNT changes so a slow consumer is not spammed — which also
1212
+ * means replacing the array with a same-length page (e.g. a fixed-size
1213
+ * window) does not re-arm the emitter; pagination must append.
1214
+ *
1215
+ * With a `[dataSource]` bound the count read is the resolved one (the
1216
+ * fetched pages), and the latch is also released when a request settles, so
1217
+ * a page that FAILED does not leave scrolling dead. The output still fires
1218
+ * either way: it is for a host paging `options` itself, so do not wire both
1219
+ * — the source already pages, and the two together fetch each page twice.
786
1220
  */
787
1221
  loadMore = output();
788
1222
  /**
@@ -808,27 +1242,188 @@ class TnAutocompleteComponent {
808
1242
  highlightedIndex = signal(-1, ...(ngDevMode ? [{ debugName: "highlightedIndex" }] : []));
809
1243
  /** The currently committed value (an option's `value`, or a custom-typed one) */
810
1244
  selectedValue = signal(null, ...(ngDevMode ? [{ debugName: "selectedValue" }] : []));
1245
+ /**
1246
+ * Label of the option the value was chosen from, remembered so
1247
+ * `keepSelectedOption` can re-list it with its real text after a search
1248
+ * replaced the page it came from. Null for a programmatic write, whose
1249
+ * label is not known until an option carrying it arrives.
1250
+ */
1251
+ selectedLabel = signal(null, ...(ngDevMode ? [{ debugName: "selectedLabel" }] : []));
811
1252
  /** CVA disabled state from the form */
812
1253
  formDisabled = signal(false, ...(ngDevMode ? [{ debugName: "formDisabled" }] : []));
813
1254
  /** Combined disabled state */
814
1255
  isDisabled = computed(() => this.disabled() || this.formDisabled(), ...(ngDevMode ? [{ debugName: "isDisabled" }] : []));
815
- /** Filtered and capped options */
1256
+ /** Async engine backing `dataSource`; idle while no source is bound. */
1257
+ asyncOptions = createTnOptionsDataSource({
1258
+ source: this.dataSource,
1259
+ debounceMs: this.dataSourceDebounce,
1260
+ pageSize: this.pageSize,
1261
+ identity: (option) => option.value,
1262
+ onError: (error) => this.dataSourceError.emit(error),
1263
+ // Release the paging latch on every round trip. The count-change re-arm
1264
+ // below cannot do it for a page that FAILED — no rows arrive, so the count
1265
+ // never moves and every later scroll would be suppressed, leaving the
1266
+ // field unable to retry the page it just lost.
1267
+ onSettled: () => {
1268
+ this.loadMorePending = false;
1269
+ },
1270
+ });
1271
+ /**
1272
+ * The synthetic row that keeps a committed value listed while the pages
1273
+ * fetched so far do not contain it, or `undefined`.
1274
+ *
1275
+ * Held separately from {@link resolvedOptions} so {@link filteredOptions} can
1276
+ * recognize it and pin it past the `maxResults` cap, the way the action row
1277
+ * is pinned. Appended and then sliced with everything else, a full page
1278
+ * filled the cap and cut off the one row this exists to guarantee.
1279
+ */
1280
+ keptSelectedOption = computed(() => {
1281
+ if (!this.dataSource() || !this.keepSelectedOption()) {
1282
+ return undefined;
1283
+ }
1284
+ const value = this.selectedValue();
1285
+ if (value === null || value === undefined) {
1286
+ return undefined;
1287
+ }
1288
+ if (this.asyncOptions.options().some((option) => this.valueMatches(option.value, value))) {
1289
+ return undefined;
1290
+ }
1291
+ // The committed value is not on the current page — keep it listed so the
1292
+ // field shows its label rather than rendering blank. A host-pinned row can
1293
+ // name it even before the first page lands, which is the whole point of
1294
+ // `options` alongside a source.
1295
+ const pinned = this.pinnedOptions().find((option) => this.valueMatches(option.value, value));
1296
+ return { label: pinned?.label ?? this.selectedLabel() ?? String(value), value };
1297
+ }, ...(ngDevMode ? [{ debugName: "keptSelectedOption" }] : []));
1298
+ /**
1299
+ * Rows the host named itself, honoured even while a `dataSource` is bound.
1300
+ *
1301
+ * With a source the dropdown is the server's to fill — but its first page is
1302
+ * not fetched until the field is focused, so a value the host already holds
1303
+ * the label for renders as its raw `String(value)` until someone opens the
1304
+ * panel. An edit form bound to an id shows the number. `options` is how that
1305
+ * label is supplied, so it stays part of the LOOKUP even where it is not
1306
+ * part of the dropdown. `tn-chip-input` treats its own `options` the same
1307
+ * way, and the asymmetry was a bug rather than a design.
1308
+ *
1309
+ * Reached through {@link resolvableOptions} for display and
1310
+ * {@link matchableOptions} for matching, so `commitCustomValue` and the
1311
+ * `requireSelection` branch of `onBlur` recognise a pinned label too.
1312
+ * Painting from a list wider than the one it reads back is the bug this
1313
+ * shape exists to avoid.
1314
+ */
1315
+ pinnedOptions = computed(() => (this.dataSource() ? this.options() : []), ...(ngDevMode ? [{ debugName: "pinnedOptions" }] : []));
1316
+ /**
1317
+ * The rows to work from: the async engine's when `dataSource` is bound,
1318
+ * the `options` input otherwise. Everything that resolves a value to a
1319
+ * label, matches typed text, or counts rows reads this rather than
1320
+ * `options` — the action row is deliberately absent, since it carries no
1321
+ * selectable value.
1322
+ */
1323
+ resolvedOptions = computed(() => {
1324
+ if (!this.dataSource()) {
1325
+ return this.options();
1326
+ }
1327
+ const fetched = this.asyncOptions.options();
1328
+ const kept = this.keptSelectedOption();
1329
+ return kept ? [...fetched, kept] : fetched;
1330
+ }, ...(ngDevMode ? [{ debugName: "resolvedOptions" }] : []));
1331
+ /**
1332
+ * The rows a value may be resolved to a label FROM — the fetched pages plus
1333
+ * the host-pinned {@link pinnedOptions}, and never the synthetic kept row.
1334
+ * {@link matchableOptions} is the other direction.
1335
+ *
1336
+ * That row exists to keep a committed value listed, and its label is the
1337
+ * display text itself: `String(value)` for a value nothing has named yet.
1338
+ * Resolving against it therefore always "succeeds", which would report the
1339
+ * raw fallback as a resolved label and leave it on screen for good.
1340
+ */
1341
+ resolvableOptions = computed(() => {
1342
+ if (!this.dataSource()) {
1343
+ return this.options();
1344
+ }
1345
+ // Fetched rows first: every reader takes the FIRST match, so a value in
1346
+ // both lists resolves to the server's row rather than the host's, which
1347
+ // may be a stale name the record was saved with.
1348
+ const pinned = this.pinnedOptions();
1349
+ const fetched = this.asyncOptions.options();
1350
+ return pinned.length ? [...fetched, ...pinned] : fetched;
1351
+ }, ...(ngDevMode ? [{ debugName: "resolvableOptions" }] : []));
1352
+ /**
1353
+ * The rows typed text may be matched BACK to a value by: every label this
1354
+ * field puts in front of the user, so that anything it PAINTS it also READS.
1355
+ *
1356
+ * {@link resolvableOptions} for the labels a value can be resolved from,
1357
+ * plus the synthetic kept row — which the two lists on either side each drop
1358
+ * for a reason that does not apply here:
1359
+ *
1360
+ * - {@link resolvedOptions} omits the host-pinned rows, since the dropdown
1361
+ * under a source is the server's to fill. But a pinned row is exactly how a
1362
+ * host names a value the pages do not carry, so matching without it painted
1363
+ * `g-7` as `admins` and then committed the raw string `'admins'` over the
1364
+ * id it names. That is the asymmetry `tn-chip-input` closed by pointing
1365
+ * `commitText` at its `labelOptions`.
1366
+ * - {@link resolvableOptions} omits the kept row, because resolving a VALUE
1367
+ * against it always "succeeds" and would report its `String(value)`
1368
+ * fallback as a real label. Matching a LABEL against it is the opposite
1369
+ * case: the row is listed, the user can click it, and its label may be one
1370
+ * remembered from the option the value was picked from. Dropping it would
1371
+ * turn "type the text you can see, then blur" into a free-text commit of
1372
+ * that text over the value it stands for.
1373
+ *
1374
+ * Order is the usual precedence — the server's rows, then the host's, then
1375
+ * the synthetic one — and every reader takes the first match.
1376
+ */
1377
+ matchableOptions = computed(() => {
1378
+ const kept = this.keptSelectedOption();
1379
+ return kept ? [...this.resolvableOptions(), kept] : this.resolvableOptions();
1380
+ }, ...(ngDevMode ? [{ debugName: "matchableOptions" }] : []));
1381
+ /** Loading state: the `loading` input OR-ed with the async engine's. */
1382
+ resolvedLoading = computed(() => this.loading() || this.asyncOptions.loading(), ...(ngDevMode ? [{ debugName: "resolvedLoading" }] : []));
1383
+ /** Filtered and capped options, with {@link actionOption} pinned on top. */
816
1384
  filteredOptions = computed(() => {
817
1385
  const term = this.searchTerm();
818
- const all = this.options();
1386
+ const all = this.resolvedOptions();
819
1387
  const customFilter = this.filterFn();
820
1388
  const max = this.maxResults();
821
- if (!term) {
822
- return all.slice(0, max);
1389
+ const action = this.actionOption();
1390
+ // A server-driven source already applied the query; filtering again on the
1391
+ // label would hide rows matched on some other field, and would drop the
1392
+ // action row the moment the user typed.
1393
+ const isPreFiltered = !!this.dataSource() && !customFilter;
1394
+ let filtered;
1395
+ if (!term || isPreFiltered) {
1396
+ filtered = all;
1397
+ }
1398
+ else if (customFilter) {
1399
+ filtered = all.filter((opt) => customFilter(opt, term));
823
1400
  }
824
- const lowerTerm = term.toLowerCase();
825
- const filtered = customFilter
826
- ? all.filter((opt) => customFilter(opt, term))
827
- : all.filter((opt) => opt.label.toLowerCase().includes(lowerTerm));
828
- return filtered.slice(0, max);
1401
+ else {
1402
+ const lowerTerm = term.toLowerCase();
1403
+ filtered = all.filter((opt) => opt.label.toLowerCase().includes(lowerTerm));
1404
+ }
1405
+ // `maxResults` caps the rows the source returned, not the two rows that are
1406
+ // there by construction: the action row is pinned on top after the slice,
1407
+ // and the kept-selected row — always last, appended by `resolvedOptions` —
1408
+ // is held back across it.
1409
+ const kept = this.keptSelectedOption();
1410
+ const capped = kept && filtered.at(-1) === kept
1411
+ ? [...filtered.slice(0, -1).slice(0, max), kept]
1412
+ : filtered.slice(0, max);
1413
+ return action ? [action, ...capped] : capped;
829
1414
  }, ...(ngDevMode ? [{ debugName: "filteredOptions" }] : []));
830
- /** Whether there are any results to show */
1415
+ /** Whether there are any rows to render (the action row counts). */
831
1416
  hasResults = computed(() => this.filteredOptions().length > 0, ...(ngDevMode ? [{ debugName: "hasResults" }] : []));
1417
+ /**
1418
+ * Whether the query matched anything selectable. Distinct from
1419
+ * {@link hasResults} so a search that found nothing still says so, rather
1420
+ * than being masked by a pinned action row — or by the kept-selected row,
1421
+ * which is held on screen for the committed value regardless of the query.
1422
+ */
1423
+ hasMatches = computed(() => {
1424
+ const kept = this.keptSelectedOption();
1425
+ return this.filteredOptions().some((option) => !this.isActionOption(option) && option !== kept);
1426
+ }, ...(ngDevMode ? [{ debugName: "hasMatches" }] : []));
832
1427
  onChange = (_value) => { };
833
1428
  onTouched = () => { };
834
1429
  /** Live overlay holding the dropdown panel, or undefined when closed. */
@@ -843,29 +1438,63 @@ class TnAutocompleteComponent {
843
1438
  lastOptionsCount = null;
844
1439
  /** Scroll distance (px) from the panel bottom that triggers `loadMore`. */
845
1440
  static loadMoreThresholdPx = 48;
1441
+ /**
1442
+ * Cap on how many pages the underfill check may request without the user
1443
+ * scrolling, per search. The check re-arms whenever the option count
1444
+ * changes, which a `dataSource` answering synchronously does within the same
1445
+ * change-detection pass — so an inexhaustible source would spin the tab
1446
+ * rather than yield between rounds. Well beyond any real panel's capacity
1447
+ * (20 × a 50-row page), so it only ever trips on a misbehaving source.
1448
+ */
1449
+ static maxAutoFillPages = 20;
1450
+ /** Underfill-driven pages requested since the last search or panel open. */
1451
+ autoFillCount = 0;
846
1452
  /** Guards the object-without-compareWith warning so it fires only once. */
847
1453
  warnedAboutObjectCompare = false;
1454
+ /**
1455
+ * Whether the text on screen is the raw `String(value)` fallback rather than
1456
+ * an option's label — i.e. the value has never resolved and the user has not
1457
+ * typed over it.
1458
+ *
1459
+ * The upgrade effect otherwise leaves an open panel alone, on the grounds
1460
+ * that the text belongs to the user's search. With a `dataSource` that never
1461
+ * holds: rows only ever arrive while the panel is open, so a written value
1462
+ * would keep the fallback as its display text for good — and
1463
+ * `allowCustomValue` would then commit that text back over the real value on
1464
+ * blur. This says the text is nobody's draft, so upgrading it is safe.
1465
+ */
1466
+ displayIsFallback = false;
848
1467
  constructor() {
849
1468
  // A value written before its option loaded displays as the raw fallback —
850
1469
  // once options arrive (or are relabeled, e.g. a locale change), upgrade
851
1470
  // the text to the option's label. UPGRADE-ONLY: when no option matches
852
1471
  // (e.g. a server-search picker replaced `options` after a selection), the
853
1472
  // current text is left alone rather than downgraded back to the raw
854
- // value. Skipped while the panel is open so active typing isn't clobbered.
1473
+ // value. While the panel is open the text is the user's search and is left
1474
+ // alone — unless it is still the untouched fallback, which is the only
1475
+ // state a `dataSource` field's rows can ever reach it in.
1476
+ // `isOpen` is a dependency rather than an `untracked` read so that closing
1477
+ // the panel re-runs the resolution over whatever the search left behind.
855
1478
  effect(() => {
856
- this.options();
1479
+ // Track the list this resolves FROM. `resolvedOptions` is not the same
1480
+ // list: with `[keepSelectedOption]="false"` its kept-row branch returns
1481
+ // before reading `pinnedOptions`, so `options` would not be a dependency
1482
+ // and a late-arriving host label would never upgrade the raw fallback.
1483
+ this.resolvableOptions();
857
1484
  this.compareWith();
1485
+ this.isOpen();
858
1486
  untracked(() => {
859
- if (this.isOpen()) {
1487
+ if (this.isOpen() && !this.displayIsFallback) {
860
1488
  return;
861
1489
  }
862
1490
  const value = this.selectedValue();
863
1491
  if (value === null || value === undefined) {
864
1492
  return;
865
1493
  }
866
- const match = this.options().find((opt) => this.valueMatches(opt.value, value));
1494
+ const match = this.resolvableOptions().find((opt) => this.valueMatches(opt.value, value));
867
1495
  if (match) {
868
1496
  this.searchTerm.set(match.label);
1497
+ this.displayIsFallback = false;
869
1498
  }
870
1499
  });
871
1500
  });
@@ -881,9 +1510,9 @@ class TnAutocompleteComponent {
881
1510
  // underfill check would be skipped (checkUnderfill bails while loading)
882
1511
  // and never re-run.
883
1512
  effect(() => {
884
- const count = this.options().length;
1513
+ const count = this.resolvedOptions().length;
885
1514
  this.isOpen();
886
- this.loading();
1515
+ this.resolvedLoading();
887
1516
  untracked(() => {
888
1517
  if (count !== this.lastOptionsCount) {
889
1518
  this.lastOptionsCount = count;
@@ -896,7 +1525,7 @@ class TnAutocompleteComponent {
896
1525
  // event; nudge CDK so the anchored position tracks the new height.
897
1526
  effect(() => {
898
1527
  this.filteredOptions();
899
- this.loading();
1528
+ this.resolvedLoading();
900
1529
  this.overlayRef?.updatePosition();
901
1530
  });
902
1531
  if (isDevMode()) {
@@ -915,12 +1544,11 @@ class TnAutocompleteComponent {
915
1544
  // ── ControlValueAccessor ──
916
1545
  writeValue(value) {
917
1546
  this.selectedValue.set(value);
918
- if (value !== null && value !== undefined) {
919
- this.searchTerm.set(this.displayValue(value));
920
- }
921
- else {
922
- this.searchTerm.set('');
923
- }
1547
+ // A programmatic write says nothing about the option's label — drop the
1548
+ // remembered one so `keepSelectedOption` doesn't relabel the new value
1549
+ // with the previous selection's text.
1550
+ this.selectedLabel.set(null);
1551
+ this.setDisplayFromValue(value);
924
1552
  }
925
1553
  registerOnChange(fn) {
926
1554
  this.onChange = fn;
@@ -931,12 +1559,68 @@ class TnAutocompleteComponent {
931
1559
  setDisabledState(isDisabled) {
932
1560
  this.formDisabled.set(isDisabled);
933
1561
  }
1562
+ // ── Async options ──
1563
+ /**
1564
+ * Discard the pages fetched from `dataSource` and re-query the current term.
1565
+ *
1566
+ * For a caller whose `[dataSource]` is a fixed function reading live
1567
+ * configuration — the shape that keeps the source from being swapped out
1568
+ * from under a search in flight — this is how a change to that configuration
1569
+ * takes effect, rather than waiting for the next keystroke to notice.
1570
+ */
1571
+ refreshOptions() {
1572
+ // Both latches belong to the result set being thrown away; left set, they
1573
+ // would suppress the first `loadMore` of the one replacing it.
1574
+ this.loadMorePending = false;
1575
+ this.autoFillCount = 0;
1576
+ this.asyncOptions.refresh();
1577
+ if (this.isOpen()) {
1578
+ // Rows are on screen and clickable right now, so they have to be
1579
+ // replaced at once. A closed panel refetches when it next opens — the
1580
+ // `prime` there is no longer answered from the invalidated pages.
1581
+ this.asyncOptions.prime();
1582
+ }
1583
+ }
1584
+ /**
1585
+ * Commit an option programmatically, its label included.
1586
+ *
1587
+ * `writeValue` deliberately forgets the label — the forms layer hands over a
1588
+ * value and nothing else — so a value the host picked itself would render as
1589
+ * `String(value)` until a search happened to return the row carrying it.
1590
+ * This is the path for a caller that already holds the option: the
1591
+ * `actionOption` handoff, where `(actionSelected)` creates an entity and the
1592
+ * host hands the new row straight back.
1593
+ *
1594
+ * It COMMITS, form control included. Updating only the display left the
1595
+ * field showing the new label, marking that row selected, and the bound
1596
+ * control still holding the previous value — which is what a submit then
1597
+ * sent. (With `requireSelection` and `keepSelectedOption` together, blur
1598
+ * happened to find the label on the synthetic kept row and reconcile it
1599
+ * through `selectOption`; on the default path nothing ever did.)
1600
+ *
1601
+ * `optionSelected` deliberately does NOT fire: that output means the user
1602
+ * picked a row, and this is the host telling the component what the host
1603
+ * already decided — echoing it back to the caller that made the call would
1604
+ * be noise. A host keeping a side model in sync updates it where it calls
1605
+ * this, not from the output.
1606
+ */
1607
+ setSelectedOption(option) {
1608
+ this.selectedValue.set(option.value);
1609
+ this.selectedLabel.set(option.label);
1610
+ this.searchTerm.set(option.label);
1611
+ this.displayIsFallback = false;
1612
+ this.onChange(option.value);
1613
+ }
934
1614
  // ── Event handlers ──
935
1615
  onInput(event) {
936
1616
  const value = event.target.value;
937
1617
  this.searchTerm.set(value);
1618
+ // The text is the user's now, whatever it was before this keystroke.
1619
+ this.displayIsFallback = false;
938
1620
  // A new term is a new pagination context — don't hold back its first page.
939
1621
  this.loadMorePending = false;
1622
+ this.autoFillCount = 0;
1623
+ this.asyncOptions.search(value);
940
1624
  this.searchChange.emit(value);
941
1625
  if (!this.isOpen()) {
942
1626
  this.open();
@@ -964,18 +1648,20 @@ class TnAutocompleteComponent {
964
1648
  }
965
1649
  if (this.requireSelection()) {
966
1650
  const term = this.searchTerm();
967
- const match = this.options().find((opt) => !opt.disabled && opt.label.toLowerCase() === term.toLowerCase());
1651
+ // {@link matchableOptions}, not {@link resolvedOptions}: the labels the
1652
+ // field is willing to PAINT are the ones it has to read back. Matching
1653
+ // the narrower list left a host-pinned label unrecognised, so typing the
1654
+ // exact name the field itself displays reverted the term instead of
1655
+ // resolving it.
1656
+ const match = this.matchableOptions().find((opt) => !opt.disabled && opt.label.toLowerCase() === term.toLowerCase());
968
1657
  if (match) {
969
1658
  this.selectOption(match);
970
1659
  }
971
1660
  else {
972
1661
  // Revert to last valid selection or clear
973
1662
  const current = this.selectedValue();
974
- if (current !== null && current !== undefined) {
975
- this.searchTerm.set(this.displayValue(current));
976
- }
977
- else {
978
- this.searchTerm.set('');
1663
+ this.revertDisplayToValue(current);
1664
+ if (current === null || current === undefined) {
979
1665
  this.onChange(null);
980
1666
  }
981
1667
  }
@@ -987,8 +1673,27 @@ class TnAutocompleteComponent {
987
1673
  if (option.disabled) {
988
1674
  return;
989
1675
  }
1676
+ if (this.isActionOption(option)) {
1677
+ this.runAction();
1678
+ return;
1679
+ }
990
1680
  this.selectOption(option);
991
1681
  }
1682
+ /** Whether `option` is the pinned {@link actionOption} row rather than a value. */
1683
+ isActionOption(option) {
1684
+ return option === this.actionOption();
1685
+ }
1686
+ /**
1687
+ * The action row was chosen: close the panel and hand off, leaving the
1688
+ * committed value and the text exactly as they were. Restoring the text
1689
+ * matters because the user has usually typed a partial term to find the
1690
+ * row — that draft must not survive as a value.
1691
+ */
1692
+ runAction() {
1693
+ this.revertDisplayToValue(this.selectedValue());
1694
+ this.close();
1695
+ this.actionSelected.emit();
1696
+ }
992
1697
  onKeydown(event) {
993
1698
  const options = this.filteredOptions();
994
1699
  switch (event.key) {
@@ -1019,7 +1724,7 @@ class TnAutocompleteComponent {
1019
1724
  event.preventDefault();
1020
1725
  const idx = this.highlightedIndex();
1021
1726
  if (this.isOpen() && idx >= 0 && idx < options.length && !options[idx].disabled) {
1022
- this.selectOption(options[idx]);
1727
+ this.onOptionClick(options[idx]);
1023
1728
  }
1024
1729
  else if (this.allowCustomValue()) {
1025
1730
  this.commitCustomValue();
@@ -1032,8 +1737,7 @@ class TnAutocompleteComponent {
1032
1737
  if (this.allowCustomValue()) {
1033
1738
  // Escape means "cancel the draft": revert to the committed value's
1034
1739
  // text so the upcoming blur doesn't commit the abandoned term.
1035
- const current = this.selectedValue();
1036
- this.searchTerm.set(current !== null && current !== undefined ? this.displayValue(current) : '');
1740
+ this.revertDisplayToValue(this.selectedValue());
1037
1741
  }
1038
1742
  this.close();
1039
1743
  break;
@@ -1047,7 +1751,10 @@ class TnAutocompleteComponent {
1047
1751
  * opens), emit once more if the panel has no scrollbar.
1048
1752
  */
1049
1753
  checkUnderfill() {
1050
- if (!this.isOpen() || this.loading() || this.loadMorePending) {
1754
+ if (!this.isOpen() || this.resolvedLoading() || this.loadMorePending) {
1755
+ return;
1756
+ }
1757
+ if (this.autoFillCount >= TnAutocompleteComponent.maxAutoFillPages) {
1051
1758
  return;
1052
1759
  }
1053
1760
  // No rows to measure, or rendering is capped by maxResults — more data
@@ -1067,22 +1774,31 @@ class TnAutocompleteComponent {
1067
1774
  const panel = this.overlayRef?.overlayElement
1068
1775
  ?.querySelector('.tn-autocomplete__listbox');
1069
1776
  if (panel && panel.scrollHeight <= panel.clientHeight) {
1070
- this.loadMorePending = true;
1071
- this.loadMore.emit();
1777
+ this.autoFillCount++;
1778
+ this.requestMore();
1072
1779
  }
1073
1780
  }
1781
+ /**
1782
+ * Ask for another page: the internal engine when `dataSource` is bound, the
1783
+ * consumer via `loadMore` otherwise. Both are notified — an app can page a
1784
+ * `dataSource` and still observe the event.
1785
+ */
1786
+ requestMore() {
1787
+ this.loadMorePending = true;
1788
+ this.asyncOptions.loadMore();
1789
+ this.loadMore.emit();
1790
+ }
1074
1791
  onPanelScroll(event) {
1075
1792
  // While loading, the rendered rows belong to the previous page/term —
1076
1793
  // scrolling them must not request a page of data that hasn't landed yet.
1077
- if (this.loadMorePending || this.loading()) {
1794
+ if (this.loadMorePending || this.resolvedLoading()) {
1078
1795
  return;
1079
1796
  }
1080
1797
  const el = event.target;
1081
1798
  const nearBottom = el.scrollTop + el.clientHeight
1082
1799
  >= el.scrollHeight - TnAutocompleteComponent.loadMoreThresholdPx;
1083
1800
  if (nearBottom) {
1084
- this.loadMorePending = true;
1085
- this.loadMore.emit();
1801
+ this.requestMore();
1086
1802
  }
1087
1803
  }
1088
1804
  /**
@@ -1155,32 +1871,85 @@ class TnAutocompleteComponent {
1155
1871
  * display match (case-insensitive, same as the requireSelection path)
1156
1872
  * commits the matching option instead, so picking an existing entry by
1157
1873
  * typing its full text behaves like clicking it.
1874
+ *
1875
+ * Matched against {@link matchableOptions} so the host-pinned rows count:
1876
+ * with `[dataSource]` + `[options]` the field painted `g-7` as `admins` and
1877
+ * then, on a page that did not carry that row, committed the raw string
1878
+ * `'admins'` over the id it names — a label it would not read back. Same
1879
+ * asymmetry `tn-chip-input` closed by pointing `commitText` at its
1880
+ * `labelOptions`.
1158
1881
  */
1159
1882
  commitCustomValue() {
1160
1883
  const term = this.searchTerm();
1161
1884
  const lowerTerm = term.toLowerCase();
1162
- const match = this.options().find((opt) => !opt.disabled && opt.label.toLowerCase() === lowerTerm);
1885
+ const match = this.matchableOptions().find((opt) => !opt.disabled && opt.label.toLowerCase() === lowerTerm);
1163
1886
  if (match) {
1164
1887
  this.selectOption(match);
1165
1888
  return;
1166
1889
  }
1167
1890
  // Best-effort guard: silent when options haven't loaded yet (the common
1168
1891
  // async + allowCustomValue case), where the value type can't be known.
1169
- if (isDevMode() && term !== '' && this.options().some((opt) => typeof opt.value !== 'string')) {
1892
+ if (isDevMode() && term !== '' && this.matchableOptions().some((opt) => typeof opt.value !== 'string')) {
1170
1893
  console.warn('[tn-autocomplete] allowCustomValue committed free text into a control whose option '
1171
1894
  + 'values are not strings — custom values are only sound for string-valued autocompletes.');
1172
1895
  }
1173
1896
  const value = term === '' ? null : term;
1174
1897
  this.selectedValue.set(value);
1898
+ // The remembered label belongs to the option that WAS selected; a custom
1899
+ // value came from no option, so keeping it would let `setDisplayFromValue`
1900
+ // repaint the old name over the user's text — and the next blur would read
1901
+ // that name back and commit the option it belongs to. `writeValue` clears
1902
+ // it for the same reason.
1903
+ this.selectedLabel.set(null);
1175
1904
  this.onChange(value);
1176
1905
  }
1177
1906
  /**
1178
- * Resolves a committed value back to its option's display label, falling
1179
- * back to the raw value's string until the matching option is available.
1907
+ * Show `value`'s label and record whether that text is a real label or the
1908
+ * raw `String(value)` fallback, so {@link displayIsFallback} — and with it
1909
+ * the upgrade effect — can tell a resolved display from an unresolved one.
1910
+ * Every path that re-derives the text from the committed value goes through
1911
+ * here, so the two can never disagree.
1912
+ *
1913
+ * A label remembered from the option the value was picked from counts as
1914
+ * resolved: it is the same text the option would give, and the row carrying
1915
+ * it may well have been paged away since.
1180
1916
  */
1181
- displayValue(value) {
1182
- const match = this.options().find((opt) => this.valueMatches(opt.value, value));
1183
- return match ? match.label : String(value);
1917
+ setDisplayFromValue(value) {
1918
+ if (value === null || value === undefined) {
1919
+ this.searchTerm.set('');
1920
+ this.displayIsFallback = false;
1921
+ return;
1922
+ }
1923
+ const match = this.resolvableOptions().find((opt) => this.valueMatches(opt.value, value));
1924
+ const remembered = this.selectedLabel();
1925
+ this.searchTerm.set(match?.label ?? remembered ?? String(value));
1926
+ this.displayIsFallback = !match && remembered === null;
1927
+ }
1928
+ /**
1929
+ * Abandon the draft term: repaint `value`'s label AND tell the async engine
1930
+ * the term is gone.
1931
+ *
1932
+ * `asyncOptions.search()` is otherwise only reached from `onInput`, so every
1933
+ * path that rewrites the text WITHOUT the user typing — the `requireSelection`
1934
+ * revert on blur, the `allowCustomValue` Escape branch, and `runAction` —
1935
+ * moved the display while the engine kept the query, the rows and the
1936
+ * `primed` latch of the term walked away from. Reopening then showed that
1937
+ * term's results (or "No results found") against a field displaying something
1938
+ * else, and issued nothing to correct it: `open()` only calls `prime()`,
1939
+ * which is a documented no-op once primed.
1940
+ *
1941
+ * `tn-chip-input.clearInput()` closes the same gap the same way. The request
1942
+ * goes through the usual debounce, and the duplicate-term guard drops it
1943
+ * entirely when the reverted text is the term already loaded — which is the
1944
+ * common case of an Escape with no draft typed.
1945
+ *
1946
+ * `writeValue` deliberately does NOT come through here: a programmatic write
1947
+ * is not a user abandoning a search, and querying there would break the rule
1948
+ * that a field nobody has opened issues no request.
1949
+ */
1950
+ revertDisplayToValue(value) {
1951
+ this.setDisplayFromValue(value);
1952
+ this.asyncOptions.search(this.searchTerm());
1184
1953
  }
1185
1954
  /**
1186
1955
  * Compares option values, honoring `compareWith` when provided. Without one,
@@ -1211,7 +1980,9 @@ class TnAutocompleteComponent {
1211
1980
  }
1212
1981
  selectOption(option) {
1213
1982
  this.selectedValue.set(option.value);
1983
+ this.selectedLabel.set(option.label);
1214
1984
  this.searchTerm.set(option.label);
1985
+ this.displayIsFallback = false;
1215
1986
  this.onChange(option.value);
1216
1987
  this.optionSelected.emit(option);
1217
1988
  this.close();
@@ -1221,6 +1992,12 @@ class TnAutocompleteComponent {
1221
1992
  return;
1222
1993
  }
1223
1994
  this.isOpen.set(true);
1995
+ this.autoFillCount = 0;
1996
+ // Fetch the first page the first time the panel is shown. Deferring to
1997
+ // here (rather than init) means a form of pickers costs nothing until one
1998
+ // is used; `prime` is a no-op on every later open, so reopening does not
1999
+ // refetch what is already listed.
2000
+ this.asyncOptions.prime();
1224
2001
  // Seed the keyboard cursor on the committed option so ArrowDown resumes
1225
2002
  // from the current value (and it scrolls into view). -1 when nothing is
1226
2003
  // committed or the value has no visible, enabled option — e.g. a custom
@@ -1292,13 +2069,13 @@ class TnAutocompleteComponent {
1292
2069
  }
1293
2070
  }
1294
2071
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1295
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, noResultsText: { classPropertyName: "noResultsText", publicName: "noResultsText", isSignal: true, isRequired: false, transformFunction: null }, maxResults: { classPropertyName: "maxResults", publicName: "maxResults", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", loadMore: "loadMore", opened: "opened" }, providers: [
2072
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnAutocompleteComponent, isStandalone: true, selector: "tn-autocomplete", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requireSelection: { classPropertyName: "requireSelection", publicName: "requireSelection", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, actionOption: { classPropertyName: "actionOption", publicName: "actionOption", isSignal: true, isRequired: false, transformFunction: null }, keepSelectedOption: { classPropertyName: "keepSelectedOption", publicName: "keepSelectedOption", 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 }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { optionSelected: "optionSelected", searchChange: "searchChange", actionSelected: "actionSelected", dataSourceError: "dataSourceError", loadMore: "loadMore", opened: "opened" }, providers: [
1296
2073
  {
1297
2074
  provide: NG_VALUE_ACCESSOR,
1298
2075
  useExisting: forwardRef(() => TnAutocompleteComponent),
1299
2076
  multi: true,
1300
2077
  },
1301
- ], 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]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\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\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\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;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"], dependencies: [{ kind: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
2078
+ ], 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]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\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\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"resolvedLoading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [class.tn-autocomplete__option--action]=\"option === actionOption()\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (resolvedLoading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasMatches()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\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;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__option--action{border-bottom:1px solid var(--tn-lines, #dee2e6);font-weight:500}.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"] }] });
1302
2079
  }
1303
2080
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnAutocompleteComponent, decorators: [{
1304
2081
  type: Component,
@@ -1308,8 +2085,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
1308
2085
  useExisting: forwardRef(() => TnAutocompleteComponent),
1309
2086
  multi: true,
1310
2087
  },
1311
- ], 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]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\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\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasResults()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\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;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__no-results{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}@media(prefers-reduced-motion:reduce){.tn-autocomplete__input,.tn-autocomplete__option{transition:none}}.tn-autocomplete__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}\n"] }]
1312
- }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requireSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireSelection", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], filterFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterFn", required: false }] }], noResultsText: [{ type: i0.Input, args: [{ isSignal: true, alias: "noResultsText", required: false }] }], maxResults: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxResults", required: false }] }], panelMaxHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelMaxHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", 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 }] }] } });
2088
+ ], 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]=\"resolvedTestId()\"\n [class.open]=\"isOpen()\"\n [class.disabled]=\"isDisabled()\"\n [placeholder]=\"resolvedPlaceholder()\"\n [disabled]=\"isDisabled()\"\n [value]=\"searchTerm()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 <!-- The max-height is declared here, and inherits: it bounds the panel as a\n whole AND the listbox inside it, which are two elements now. -->\n <div\n class=\"tn-autocomplete__dropdown\"\n [style.--tn-autocomplete-panel-max-height]=\"panelMaxHeightValue()\">\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\n The listbox is also the scrollport, and has to be (#292): axe's\n `scrollable-region-focusable` exempts a combobox popup, but it tests\n the scroll container's OWN role, so the role-less wrapper that used to\n carry the overflow was reported even though every option inside is\n `tabindex=\"-1\"` on purpose. The paging handler moves with the\n overflow \u2014 a scroll event fires on the element that scrolls \u2014 and the\n status row below stays outside the scrolling area. -->\n <div\n class=\"tn-autocomplete__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-dropdown'\"\n [attr.aria-busy]=\"resolvedLoading() ? true : null\"\n (scroll)=\"onPanelScroll($event)\">\n @if (hasResults()) {\n @for (option of filteredOptions(); track $index) {\n <div\n class=\"tn-autocomplete__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [class.highlighted]=\"highlightedIndex() === $index\"\n [class.disabled]=\"option.disabled\"\n [class.tn-autocomplete__option--action]=\"option === actionOption()\"\n [attr.id]=\"uid + '-option-' + $index\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled ? true : null\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\"\n (keydown.enter)=\"onOptionClick(option)\">\n {{ option.label }}\n </div>\n }\n }\n </div>\n\n <div role=\"status\">\n @if (resolvedLoading()) {\n <div\n class=\"tn-autocomplete__loading\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('loading')\">\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n } @else if (!hasMatches()) {\n <div\n class=\"tn-autocomplete__no-results\"\n tnTestIdType=\"autocomplete\"\n [tnTestId]=\"statusTestIdParts('no-results')\">\n {{ resolvedNoResultsText() }}\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;padding:.25rem 0;display:flex;flex-direction:column;max-height:var(--tn-autocomplete-panel-max-height, 320px)}.tn-autocomplete__listbox{max-height:var(--tn-autocomplete-panel-max-height, 320px);overflow-y:auto;min-height:0}.tn-autocomplete__option{display:flex;align-items:center;padding:.5rem .75rem;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out}.tn-autocomplete__option:hover:not(.disabled){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-autocomplete__option.highlighted{background-color:var(--tn-alt-bg1, #f8f9fa)}.tn-autocomplete__option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-autocomplete__option--action{border-bottom:1px solid var(--tn-lines, #dee2e6);font-weight:500}.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"] }]
2089
+ }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requireSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "requireSelection", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], dataSourceDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSourceDebounce", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], actionOption: [{ type: i0.Input, args: [{ isSignal: true, alias: "actionOption", required: false }] }], keepSelectedOption: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepSelectedOption", 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 }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], optionSelected: [{ type: i0.Output, args: ["optionSelected"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], actionSelected: [{ type: i0.Output, args: ["actionSelected"] }], dataSourceError: [{ type: i0.Output, args: ["dataSourceError"] }], 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 }] }] } });
1313
2090
 
1314
2091
  /**
1315
2092
  * Harness for interacting with tn-autocomplete in tests.
@@ -7628,6 +8405,18 @@ class TnChipHarness extends ComponentHarness {
7628
8405
  }
7629
8406
  }
7630
8407
 
8408
+ /** English defaults used when no {@link TN_CHIP_INPUT_LABELS} provider is registered. */
8409
+ const TN_CHIP_INPUT_DEFAULT_LABELS = {
8410
+ loading: 'Loading...',
8411
+ };
8412
+ /**
8413
+ * DI token for app-wide default labels. Provide either a static object or a
8414
+ * `Signal<TnChipInputLabels>` — the latter lets every chip input react to
8415
+ * language changes when the consumer wires it up to an i18n service.
8416
+ *
8417
+ * Explicit input bindings on `<tn-chip-input>` still win over these defaults.
8418
+ */
8419
+ const TN_CHIP_INPUT_LABELS = new InjectionToken('TN_CHIP_INPUT_LABELS', { providedIn: 'root', factory: () => TN_CHIP_INPUT_DEFAULT_LABELS });
7631
8420
  let nextId$4 = 0;
7632
8421
  /**
7633
8422
  * An editable, multi-value chip input — tokenized entry where typed text
@@ -7715,8 +8504,45 @@ class TnChipInputComponent {
7715
8504
  * Value-mode option list (`{ label, value }`). When non-empty, chips display
7716
8505
  * the resolved `label` while the form model holds `value`s. Takes precedence
7717
8506
  * over `suggestions`. For async sources, update in response to `(searchChange)`.
8507
+ *
8508
+ * A bound `dataSource` supersedes these as the *suggestions*, but they are
8509
+ * still read when labelling a chip — see {@link labelOptions}.
7718
8510
  */
7719
8511
  options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
8512
+ /**
8513
+ * Server-driven suggestions: a function of `(query, page)` returning the
8514
+ * matches for `query`. Binding it hands the component the debounce,
8515
+ * request cancellation, loading state and error recovery that a consumer
8516
+ * otherwise writes by hand around `(searchChange)`, and it supersedes both
8517
+ * `suggestions` and `options` as the source of the dropdown's rows —
8518
+ * `options` is still consulted when labelling a chip, so a host can name a
8519
+ * value the fetched pages have not produced.
8520
+ *
8521
+ * The chip dropdown is not paged, so `page` is always 0 — the parameter is
8522
+ * there only so one source function can feed both this and `tn-autocomplete`.
8523
+ *
8524
+ * The first query runs when the field is first focused, not on init. Binding
8525
+ * a source that is `undefined` at that moment is fine — the field fetches as
8526
+ * soon as one arrives, open panel included.
8527
+ *
8528
+ * Swapping one source function for ANOTHER is not a signal to refetch: a
8529
+ * source is expected to be a stable function reading live configuration,
8530
+ * which is what keeps it from being replaced out from under a request in
8531
+ * flight. Call `refreshOptions()` when that configuration moves.
8532
+ *
8533
+ * @example
8534
+ * ```html
8535
+ * <tn-chip-input [dataSource]="groupOptions" />
8536
+ * ```
8537
+ */
8538
+ dataSource = input(undefined, ...(ngDevMode ? [{ debugName: "dataSource" }] : []));
8539
+ /** Debounce applied to typing before `dataSource` is queried, in ms. */
8540
+ dataSourceDebounce = input(250, ...(ngDevMode ? [{ debugName: "dataSourceDebounce" }] : []));
8541
+ /**
8542
+ * Text shown next to the spinner while a `dataSource` request is in flight.
8543
+ * Falls back to {@link TN_CHIP_INPUT_LABELS}.
8544
+ */
8545
+ loadingText = input(undefined, ...(ngDevMode ? [{ debugName: "loadingText" }] : []));
7720
8546
  /**
7721
8547
  * Comparator for value equality — used for de-duplication, display resolution
7722
8548
  * and the selected-set. Defaults to identity (`===`), correct for primitives;
@@ -7768,11 +8594,36 @@ class TnChipInputComponent {
7768
8594
  * the consumer if the lookup is expensive.
7769
8595
  */
7770
8596
  searchChange = output();
8597
+ /**
8598
+ * Emits once per failed `dataSource` request. The component recovers on its
8599
+ * own — the stream stays alive and the failed term stays retryable — so this
8600
+ * is purely for the app to report the failure the way it reports others.
8601
+ */
8602
+ dataSourceError = output();
8603
+ defaultLabels = injectTnLabels(TN_CHIP_INPUT_LABELS);
8604
+ resolvedLoadingText = computed(() => this.loadingText() ?? this.defaultLabels().loading, ...(ngDevMode ? [{ debugName: "resolvedLoadingText" }] : []));
7771
8605
  container = viewChild.required('container');
7772
8606
  inputEl = viewChild.required('inputEl');
7773
8607
  dropdownTemplate = viewChild.required('dropdownTemplate');
7774
8608
  /** Committed chip values — the form model. */
7775
8609
  values = signal([], ...(ngDevMode ? [{ debugName: "values" }] : []));
8610
+ /**
8611
+ * The label each committed value was picked FROM, for values whose row is no
8612
+ * longer in {@link optionList} or {@link options}.
8613
+ *
8614
+ * With a `dataSource` the fetched page is the only list a chip's label can be
8615
+ * resolved from, and it is replaced on the next search: pick `admins`, type
8616
+ * `dev`, and the row carrying `g-7` is gone, so the chip the user just made
8617
+ * reverts to `g-7` and stays that way. The pinned `[options]` escape hatch
8618
+ * does not cover it — the host would have to append every freshly picked row
8619
+ * to that input, which is the bookkeeping `dataSource` exists to remove.
8620
+ *
8621
+ * `tn-autocomplete` remembers the same thing in its `selectedLabel`; this is
8622
+ * the multi-value form of it. Consulted LAST (see {@link labelOptions}), so a
8623
+ * live row or a host pin always wins over a name that may since have changed,
8624
+ * and pruned to the committed values so it cannot grow without bound.
8625
+ */
8626
+ rememberedLabels = signal([], ...(ngDevMode ? [{ debugName: "rememberedLabels" }] : []));
7776
8627
  /** Current text in the field. */
7777
8628
  inputValue = signal('', ...(ngDevMode ? [{ debugName: "inputValue" }] : []));
7778
8629
  /** Whether the suggestion dropdown is open. */
@@ -7790,46 +8641,148 @@ class TnChipInputComponent {
7790
8641
  const max = this.maxChips();
7791
8642
  return max === undefined || this.values().length < max;
7792
8643
  }, ...(ngDevMode ? [{ debugName: "canAddMore" }] : []));
8644
+ /** Async engine backing `dataSource`; idle while no source is bound. */
8645
+ asyncOptions = createTnOptionsDataSource({
8646
+ source: this.dataSource,
8647
+ debounceMs: this.dataSourceDebounce,
8648
+ // The dropdown is not paged, so nothing ever asks for page 1 and this only
8649
+ // decides an `exhausted` flag no one reads yet. `0` leaves that flag false;
8650
+ // `Infinity` would latch it TRUE for every page (`rows.length < Infinity`),
8651
+ // and `loadMore()` bails on `exhausted()` before anything else — so paging
8652
+ // would silently no-op for whoever wires it into this dropdown later.
8653
+ pageSize: signal(0),
8654
+ identity: (option) => option.value,
8655
+ onError: (error) => this.dataSourceError.emit(error),
8656
+ onSettled: () => this.syncDropdownAfterFetch(),
8657
+ });
8658
+ /**
8659
+ * Whether a `dataSource` request is in flight.
8660
+ *
8661
+ * Surfaced in the dropdown because with a `dataSource` bound the rows are NOT
8662
+ * re-filtered on the label — the server already applied the query — so the
8663
+ * panel keeps showing the *previous* term's matches, clickable and looking
8664
+ * current, for the debounce plus the round trip. Without a cue that is
8665
+ * indistinguishable from "these are your results".
8666
+ *
8667
+ * It covers the ROUND TRIP only. The debounce window before it is
8668
+ * deliberately left uncued: a spinner appearing and vanishing on every
8669
+ * keystroke of a term that is still being typed is noise, and the request it
8670
+ * would announce may never be sent.
8671
+ */
8672
+ loading = computed(() => this.asyncOptions.loading(), ...(ngDevMode ? [{ debugName: "loading" }] : []));
7793
8673
  /**
7794
- * Unified option list. Value-mode `options` win; otherwise string-mode
7795
- * `suggestions` are lifted into `{ label: s, value: s }`.
8674
+ * Unified option list. A bound `dataSource` wins; then value-mode `options`;
8675
+ * otherwise string-mode `suggestions` lifted into `{ label: s, value: s }`.
7796
8676
  */
7797
8677
  optionList = computed(() => {
8678
+ if (this.dataSource()) {
8679
+ return this.asyncOptions.options();
8680
+ }
7798
8681
  const opts = this.options();
7799
8682
  if (opts.length) {
7800
8683
  return opts;
7801
8684
  }
7802
8685
  return this.suggestions().map((suggestion) => ({ label: suggestion, value: suggestion }));
7803
8686
  }, ...(ngDevMode ? [{ debugName: "optionList" }] : []));
8687
+ /**
8688
+ * The rows a chip's label and test id may be resolved from: the suggestions,
8689
+ * plus — with a `dataSource` bound — the `options` input.
8690
+ *
8691
+ * Those two lists are the same thing without a `dataSource`. With one, the
8692
+ * fetched pages are the only source of labels, and the first of them does not
8693
+ * exist until the field is focused: a form loaded with ids would render every
8694
+ * chip as its raw id until someone clicked into it. `options` is how a host
8695
+ * names values it already knows the labels for, so it stays part of the
8696
+ * lookup even where it is not part of the dropdown.
8697
+ *
8698
+ * Deliberately not deduplicated: the order is the precedence — the server's
8699
+ * fetched rows, then the host's pinned ones, then the labels this field
8700
+ * remembered committing ({@link rememberedLabels}) — and every reader takes
8701
+ * the first match, so the freshest name for a value wins.
8702
+ *
8703
+ * Read by {@link commitText} as well, so the labels the field paints and the
8704
+ * labels it accepts back are one set. Deliberately NOT read by
8705
+ * {@link filteredSuggestions}: with a source bound the rows are shown as the
8706
+ * server ordered and filtered them, and splicing the pinned rows in would
8707
+ * append the same entries to every term's results, matching or not.
8708
+ */
8709
+ labelOptions = computed(() => {
8710
+ const list = this.optionList();
8711
+ const pinned = this.dataSource() ? this.options() : [];
8712
+ const remembered = this.rememberedLabels();
8713
+ if (!pinned.length && !remembered.length) {
8714
+ return list;
8715
+ }
8716
+ return [...list, ...pinned, ...remembered];
8717
+ }, ...(ngDevMode ? [{ debugName: "labelOptions" }] : []));
7804
8718
  /** Options matching the typed text and not already selected. */
7805
8719
  filteredSuggestions = computed(() => {
7806
8720
  const term = this.inputValue().trim().toLowerCase();
8721
+ // A `dataSource` already applied the query server-side; filtering again on
8722
+ // the label would hide rows it matched on some other field.
8723
+ const isPreFiltered = !!this.dataSource();
7807
8724
  return this.optionList().filter((option) => {
7808
8725
  if (this.valuesIncludes(option.value)) {
7809
8726
  return false;
7810
8727
  }
7811
- return term === '' || option.label.toLowerCase().includes(term);
8728
+ return isPreFiltered || term === '' || option.label.toLowerCase().includes(term);
7812
8729
  });
7813
8730
  }, ...(ngDevMode ? [{ debugName: "filteredSuggestions" }] : []));
7814
8731
  onChange = () => { };
7815
8732
  onTouched = () => { };
7816
8733
  overlayRef;
7817
8734
  overlaySubs = [];
8735
+ /**
8736
+ * Set when the panel was closed deliberately — by committing a chip, or by
8737
+ * Escape — and read by everything that would otherwise re-open it.
8738
+ *
8739
+ * Committing changes the suggestion list, since the chosen row is now
8740
+ * excluded, which re-runs the effect below. On the static path the empty
8741
+ * input makes `activelySearching` false and the close stands; with a
8742
+ * `dataSource` bound, `searchingEmpty` holds regardless, so the panel sprang
8743
+ * back open against an empty field, still listing the rows of the term just
8744
+ * committed.
8745
+ *
8746
+ * Escape is here for the same reason and one more: a response landing after
8747
+ * it reaches `syncDropdownAfterFetch`, which OPENS. Typing `an`, pressing
8748
+ * Escape inside the 250 ms debounce, and waiting put the panel straight back
8749
+ * with focus still in the field — Escape defeated for the whole in-flight
8750
+ * window. The ARIA combobox pattern is that Escape dismisses the popup and it
8751
+ * stays dismissed, so that was a keyboard and AT regression, not a cosmetic
8752
+ * one.
8753
+ *
8754
+ * Cleared by the next thing that is genuinely a search, so typing re-arms the
8755
+ * panel immediately.
8756
+ */
8757
+ closedByUser = false;
7818
8758
  constructor() {
7819
8759
  // Async suggestions: when the user types, onInput runs syncDropdown()
7820
8760
  // against the still-stale list and leaves the panel closed; results land a
7821
8761
  // tick later via [suggestions]/[options]. Re-open the panel once fresh
7822
8762
  // matches arrive while the field is focused and actively searching. This
7823
- // only ever opens (never closes), so it doesn't fight Escape, blur, or the
7824
- // post-commit close — those stay shut until the option set next changes.
8763
+ // only ever opens (never closes), so it doesn't fight blur — and the
8764
+ // `closedByUser` latch is what keeps it off Escape and the post-commit
8765
+ // close, which would otherwise re-open on the next option-set change.
7825
8766
  effect(() => {
7826
8767
  const hasMatches = this.filteredSuggestions().length > 0;
8768
+ // A debounced search reaches the wire a quarter-second after the
8769
+ // keystroke that called `syncDropdown`, so on a term whose predecessor
8770
+ // matched nothing the panel is shut by the time the request goes out.
8771
+ // Tracking `loading` here is what puts the in-flight cue on screen in
8772
+ // that case; like everything else in this effect it only ever opens.
8773
+ const busy = this.loading();
7827
8774
  untracked(() => {
8775
+ // With a `dataSource`, the first page is fetched on focus with an
8776
+ // empty term — so an empty field is still "searching" and its results
8777
+ // should drop the panel open, the way a static suggestion list does
8778
+ // on focus. Typed-term-only would leave that first page invisible
8779
+ // until the user typed something.
8780
+ const searchingEmpty = !!this.dataSource();
7828
8781
  const activelySearching = this.focused()
7829
- && this.inputValue().trim() !== ''
8782
+ && (searchingEmpty || this.inputValue().trim() !== '')
7830
8783
  && this.canAddMore()
7831
8784
  && !this.isDisabled();
7832
- if (hasMatches && activelySearching) {
8785
+ if ((hasMatches || busy) && activelySearching && !this.closedByUser) {
7833
8786
  this.open();
7834
8787
  }
7835
8788
  });
@@ -7838,12 +8791,33 @@ class TnChipInputComponent {
7838
8791
  ngOnDestroy() {
7839
8792
  this.detachOverlay();
7840
8793
  }
8794
+ // ── Async options ──
8795
+ /**
8796
+ * Discard the pages fetched from `dataSource` and re-query the current term.
8797
+ *
8798
+ * For a caller whose `[dataSource]` is a fixed function reading live
8799
+ * configuration — the shape that keeps the source from being swapped out
8800
+ * from under a search in flight — this is how a change to that configuration
8801
+ * takes effect, rather than waiting for the next keystroke to notice.
8802
+ */
8803
+ refreshOptions() {
8804
+ this.asyncOptions.refresh();
8805
+ if (this.focused()) {
8806
+ // Suggestions are on screen (or one keystroke from it), so they have to
8807
+ // be replaced at once. An unfocused field refetches on its next focus —
8808
+ // the `prime` there is no longer answered from the invalidated pages.
8809
+ this.asyncOptions.prime();
8810
+ }
8811
+ }
7841
8812
  // ── ControlValueAccessor ──
7842
8813
  writeValue(value) {
7843
8814
  // Reflect the model verbatim — deliberately NOT clamped to maxChips. A form
7844
8815
  // may legitimately seed more values than the cap; silently dropping them
7845
8816
  // would lose data. The cap only blocks further user-driven additions.
7846
8817
  this.values.set(Array.isArray(value) ? [...value] : []);
8818
+ // A value the model dropped takes its remembered label with it — otherwise
8819
+ // a form reset would leave the field naming chips it no longer holds.
8820
+ this.pruneRememberedLabels();
7847
8821
  }
7848
8822
  registerOnChange(fn) {
7849
8823
  this.onChange = fn;
@@ -7867,12 +8841,18 @@ class TnChipInputComponent {
7867
8841
  onInput(event) {
7868
8842
  const value = event.target.value;
7869
8843
  this.inputValue.set(value);
8844
+ this.closedByUser = false;
8845
+ this.asyncOptions.search(value);
7870
8846
  this.searchChange.emit(value);
7871
8847
  this.highlightedIndex.set(-1);
7872
8848
  this.syncDropdown();
7873
8849
  }
7874
8850
  onFocus() {
7875
8851
  this.focused.set(true);
8852
+ this.closedByUser = false;
8853
+ // Fetch the first page the first time the field is used, so a form of
8854
+ // chip inputs costs nothing until one is focused. A no-op thereafter.
8855
+ this.asyncOptions.prime();
7876
8856
  this.syncDropdown();
7877
8857
  }
7878
8858
  onBlur() {
@@ -7912,6 +8892,9 @@ class TnChipInputComponent {
7912
8892
  if (event.key === 'Escape') {
7913
8893
  if (this.isOpen()) {
7914
8894
  event.preventDefault();
8895
+ // Latched, so neither the re-open effect nor a `dataSource` response
8896
+ // still in flight can undo the dismissal. See {@link closedByUser}.
8897
+ this.closedByUser = true;
7915
8898
  this.close();
7916
8899
  }
7917
8900
  return;
@@ -7920,7 +8903,7 @@ class TnChipInputComponent {
7920
8903
  event.preventDefault();
7921
8904
  const idx = this.highlightedIndex();
7922
8905
  if (this.isOpen() && idx >= 0 && idx < suggestions.length) {
7923
- this.commitValue(suggestions[idx].value);
8906
+ this.commitValue(suggestions[idx].value, suggestions[idx].label);
7924
8907
  }
7925
8908
  else {
7926
8909
  this.commitText(this.inputValue());
@@ -7935,7 +8918,7 @@ class TnChipInputComponent {
7935
8918
  }
7936
8919
  }
7937
8920
  onSuggestionClick(option) {
7938
- this.commitValue(option.value);
8921
+ this.commitValue(option.value, option.label);
7939
8922
  this.inputEl().nativeElement.focus();
7940
8923
  }
7941
8924
  /** Prevents the option `mousedown` from blurring the input before the click lands. */
@@ -7951,6 +8934,7 @@ class TnChipInputComponent {
7951
8934
  return;
7952
8935
  }
7953
8936
  this.values.update((values) => values.filter((_, i) => i !== index));
8937
+ this.pruneRememberedLabels();
7954
8938
  this.onChange(this.values());
7955
8939
  this.onTouched();
7956
8940
  this.chipRemoved.emit(removed);
@@ -8004,6 +8988,10 @@ class TnChipInputComponent {
8004
8988
  suggestionTestId(option) {
8005
8989
  return this.discriminatedTestId(optionTestId(this.resolvedTestId(), option, this.optionTestIdKey()));
8006
8990
  }
8991
+ /** Test-id parts for the dropdown's status row. Mirrors `tn-autocomplete`. */
8992
+ statusTestIdParts(status) {
8993
+ return scopeTestId(this.resolvedTestId(), status);
8994
+ }
8007
8995
  /**
8008
8996
  * Keeps a scoped id only when both halves of it survive normalization.
8009
8997
  *
@@ -8026,15 +9014,25 @@ class TnChipInputComponent {
8026
9014
  isCommitKey(event) {
8027
9015
  return event.key === 'Enter' || this.separatorKeys().includes(event.key);
8028
9016
  }
8029
- /** Commits typed text: resolve it to an option's value, else accept as custom. */
9017
+ /**
9018
+ * Commits typed text: resolve it to an option's value, else accept as custom.
9019
+ *
9020
+ * Matched against {@link labelOptions}, not the dropdown rows, so that every
9021
+ * label the field is willing to PAINT it is also willing to READ back. With a
9022
+ * `dataSource` bound, `options` names values the host already knows the
9023
+ * labels for; a chip rendered as `admins` that this method did not recognise
9024
+ * when `admins` was typed was silently dropped under
9025
+ * `allowCustomValue="false"` and committed as the raw string under the
9026
+ * default `true` — a second chip beside the one it duplicates.
9027
+ */
8030
9028
  commitText(raw) {
8031
9029
  const text = (raw ?? '').trim();
8032
9030
  if (!text) {
8033
9031
  return;
8034
9032
  }
8035
- const match = this.optionList().find((option) => option.label.toLowerCase() === text.toLowerCase());
9033
+ const match = this.labelOptions().find((option) => option.label.toLowerCase() === text.toLowerCase());
8036
9034
  if (match) {
8037
- this.commitValue(match.value);
9035
+ this.commitValue(match.value, match.label);
8038
9036
  return;
8039
9037
  }
8040
9038
  if (this.allowCustomValue()) {
@@ -8043,8 +9041,15 @@ class TnChipInputComponent {
8043
9041
  }
8044
9042
  this.clearInput();
8045
9043
  }
8046
- /** Commits a resolved value, honouring duplicate and cap rules. */
8047
- commitValue(value) {
9044
+ /**
9045
+ * Commits a resolved value, honouring duplicate and cap rules.
9046
+ *
9047
+ * `label` is the text of the option the value came from, when there was one —
9048
+ * remembered so the chip keeps that name after the page carrying its row is
9049
+ * replaced. See {@link rememberedLabels}. Free text passes none: such a chip
9050
+ * is its own label, and `String(value)` already renders it.
9051
+ */
9052
+ commitValue(value, label) {
8048
9053
  if (this.isDisabled() || !this.canAddMore()) {
8049
9054
  return;
8050
9055
  }
@@ -8053,11 +9058,24 @@ class TnChipInputComponent {
8053
9058
  return;
8054
9059
  }
8055
9060
  this.values.update((values) => [...values, value]);
9061
+ if (label !== undefined) {
9062
+ this.rememberedLabels.update((remembered) => [...remembered, { label, value }]);
9063
+ }
8056
9064
  this.onChange(this.values());
8057
9065
  this.onTouched();
8058
9066
  this.chipAdded.emit(value);
8059
9067
  this.clearInput();
8060
9068
  }
9069
+ /**
9070
+ * Drop remembered labels for values no longer committed, so the memory stays
9071
+ * bounded by the chips on screen rather than by everything ever picked.
9072
+ */
9073
+ pruneRememberedLabels() {
9074
+ if (!this.rememberedLabels().length) {
9075
+ return;
9076
+ }
9077
+ this.rememberedLabels.update((remembered) => remembered.filter((option) => this.values().some((value) => this.valueMatches(option.value, value))));
9078
+ }
8061
9079
  /**
8062
9080
  * The option a committed value came from, or `undefined` for a free-text chip.
8063
9081
  *
@@ -8071,7 +9089,7 @@ class TnChipInputComponent {
8071
9089
  optionFor(value) {
8072
9090
  const comparator = this.compareWith();
8073
9091
  if (comparator) {
8074
- return this.optionList().find((option) => comparator(option.value, value));
9092
+ return this.labelOptions().find((option) => comparator(option.value, value));
8075
9093
  }
8076
9094
  return this.optionIndex().get(value);
8077
9095
  }
@@ -8083,7 +9101,7 @@ class TnChipInputComponent {
8083
9101
  */
8084
9102
  optionIndex = computed(() => {
8085
9103
  const index = new Map();
8086
- for (const option of this.optionList()) {
9104
+ for (const option of this.labelOptions()) {
8087
9105
  if (!index.has(option.value)) {
8088
9106
  index.set(option.value, option);
8089
9107
  }
@@ -8100,15 +9118,54 @@ class TnChipInputComponent {
8100
9118
  clearInput() {
8101
9119
  this.inputValue.set('');
8102
9120
  this.inputEl().nativeElement.value = '';
9121
+ // The engine has to be told the term is gone too, or it keeps the
9122
+ // committed term's query and rows. `closedByUser` hides that for the
9123
+ // immediate re-open, but not for the next focus: blur, refocus, and the
9124
+ // panel opens on the PREVIOUS term's matches against an empty field. An
9125
+ // empty input means "show everything" on the static path, and this is what
9126
+ // makes the async path agree. One request, not one per chip — it goes
9127
+ // through the same debounce as typing.
9128
+ this.asyncOptions.search('');
9129
+ this.closedByUser = true;
8103
9130
  this.close();
8104
9131
  }
9132
+ /**
9133
+ * Re-decide the panel once a `dataSource` response lands.
9134
+ *
9135
+ * The constructor effect only ever OPENS — it must not fight Escape, blur or
9136
+ * the post-commit close — and with a source bound `onInput` runs
9137
+ * `syncDropdown()` against the PREVIOUS term's rows, which are still there,
9138
+ * so it leaves the panel open too. Nothing was left to retract it: a search
9139
+ * that matched nothing kept a bordered, empty `role="listbox"` attached,
9140
+ * `aria-expanded="true"` over it, until the next keystroke or blur. The
9141
+ * static path never reaches that state, because there the label filter has
9142
+ * already emptied the list by the time `syncDropdown()` reads it.
9143
+ *
9144
+ * The guards are every deliberate close: {@link closedByUser} covers the
9145
+ * post-commit close and Escape, `focused()` covers blur. This must not reopen
9146
+ * a panel any of them just shut.
9147
+ */
9148
+ syncDropdownAfterFetch() {
9149
+ if (this.closedByUser || !this.focused()) {
9150
+ return;
9151
+ }
9152
+ this.syncDropdown();
9153
+ }
8105
9154
  /**
8106
9155
  * Opens the dropdown when there is something to show, closes it otherwise.
8107
9156
  * Stays closed once the chip cap is reached — suggesting rows that
8108
9157
  * `commitValue()` would reject is misleading.
9158
+ *
9159
+ * A request in flight counts as something to show even with no rows behind
9160
+ * it: the loading row is the only in-flight cue this field has, and the
9161
+ * fetch most likely to be slow is the COLD one on first focus, where there
9162
+ * is nothing to keep the panel open on its own. Without this the panel was
9163
+ * detached for that whole round trip — no spinner, no `aria-busy` — and the
9164
+ * empty listbox that carries the loading row cost no height.
8109
9165
  */
8110
9166
  syncDropdown() {
8111
- if (this.filteredSuggestions().length > 0 && this.canAddMore() && !this.isDisabled()) {
9167
+ const hasSomethingToShow = this.filteredSuggestions().length > 0 || this.loading();
9168
+ if (hasSomethingToShow && this.canAddMore() && !this.isDisabled()) {
8112
9169
  this.open();
8113
9170
  }
8114
9171
  else {
@@ -8171,24 +9228,24 @@ class TnChipInputComponent {
8171
9228
  this.overlayRef = undefined;
8172
9229
  }
8173
9230
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8174
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange" }, providers: [
9231
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnChipInputComponent, isStandalone: true, selector: "tn-chip-input", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, separatorKeys: { classPropertyName: "separatorKeys", publicName: "separatorKeys", isSignal: true, isRequired: false, transformFunction: null }, addOnBlur: { classPropertyName: "addOnBlur", publicName: "addOnBlur", isSignal: true, isRequired: false, transformFunction: null }, allowCustomValue: { classPropertyName: "allowCustomValue", publicName: "allowCustomValue", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, maxChips: { classPropertyName: "maxChips", publicName: "maxChips", isSignal: true, isRequired: false, transformFunction: null }, suggestions: { classPropertyName: "suggestions", publicName: "suggestions", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, dataSourceDebounce: { classPropertyName: "dataSourceDebounce", publicName: "dataSourceDebounce", isSignal: true, isRequired: false, transformFunction: null }, loadingText: { classPropertyName: "loadingText", publicName: "loadingText", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chipAdded: "chipAdded", chipRemoved: "chipRemoved", searchChange: "searchChange", dataSourceError: "dataSourceError" }, providers: [
8175
9232
  {
8176
9233
  provide: NG_VALUE_ACCESSOR,
8177
9234
  useExisting: forwardRef(() => TnChipInputComponent),
8178
9235
  multi: true,
8179
9236
  },
8180
- ], viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, isSignal: true }, { propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(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.label }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"], dependencies: [{ kind: "component", type: TnChipComponent, selector: "tn-chip", inputs: ["label", "icon", "closable", "disabled", "color", "testId"], outputs: ["onClose", "onClick"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
9237
+ ], viewQueries: [{ propertyName: "container", first: true, predicate: ["container"], descendants: true, isSignal: true }, { propertyName: "inputEl", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 class=\"tn-chip-input__dropdown\">\n <!-- Per ARIA a listbox may only contain option children, so the loading row\n lives OUTSIDE it as a live-region sibling. The listbox is also the\n scrollport, which keeps that row pinned below the rows it describes\n rather than scrolling away with them. -->\n <div\n class=\"tn-chip-input__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(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.label }}\n </div>\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-chip-input__loading\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"statusTestIdParts('loading')\"\n >\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n }\n </div>\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{display:flex;flex-direction:column;max-height:var(--tn-chip-input-panel-max-height, 320px);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__listbox{max-height:inherit;overflow-y:auto;min-height:0}.tn-chip-input__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}.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: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
8181
9238
  }
8182
9239
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnChipInputComponent, decorators: [{
8183
9240
  type: Component,
8184
- args: [{ selector: 'tn-chip-input', standalone: true, imports: [TnChipComponent, TnTestIdDirective], providers: [
9241
+ args: [{ selector: 'tn-chip-input', standalone: true, imports: [TnChipComponent, TnSpinnerComponent, TnTestIdDirective], providers: [
8185
9242
  {
8186
9243
  provide: NG_VALUE_ACCESSOR,
8187
9244
  useExisting: forwardRef(() => TnChipInputComponent),
8188
9245
  multi: true,
8189
9246
  },
8190
- ], template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\n [attr.aria-autocomplete]=\"'list'\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-controls]=\"isOpen() ? uid + '-listbox' : null\"\n [attr.aria-activedescendant]=\"highlightedIndex() >= 0 ? uid + '-option-' + highlightedIndex() : null\"\n (input)=\"onInput($event)\"\n (focus)=\"onFocus()\"\n (blur)=\"onBlur()\"\n (keydown)=\"onKeydown($event)\"\n />\n</div>\n\n<!-- Suggestion dropdown \u2014 portaled via CDK overlay so it escapes ancestor\n overflow/clipping. Attach/detach is driven by open()/close(). -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-chip-input__dropdown\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(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.label }}\n </div>\n }\n </div>\n</ng-template>\n", styles: [".tn-chip-input{display:flex;flex-wrap:wrap;align-items:center;gap:.375rem;min-height:2.5rem;padding:.375rem .5rem;box-sizing:border-box;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:text;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.tn-chip-input:focus-within{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-chip-input--disabled{background-color:var(--tn-alt-bg1, #f8f9fa);opacity:.6;cursor:not-allowed}.tn-chip-input__chip{flex-shrink:0}.tn-chip-input__field{flex:1;min-width:120px;padding:.25rem;font-size:1rem;line-height:1.5;color:var(--tn-fg1, #212529);background:transparent;border:none;outline:none}.tn-chip-input__field::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-chip-input__field:disabled{cursor:not-allowed}.tn-chip-input__dropdown{max-height:var(--tn-chip-input-panel-max-height, 320px);overflow-y:auto;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.tn-chip-input__option{padding:.5rem .75rem;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif;font-size:1rem;color:var(--tn-fg1, #212529);cursor:pointer}.tn-chip-input__option:hover,.tn-chip-input__option.highlighted{background-color:var(--tn-bg3, #f3f4f6)}@media(prefers-reduced-motion:reduce){.tn-chip-input{transition:none}}@media(prefers-contrast:high){.tn-chip-input{border-width:2px}}\n"] }]
8191
- }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], maxChips: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxChips", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", 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 }] }] } });
9247
+ ], template: "<!-- Click-to-focus is a pointer affordance only; keyboard users Tab straight to\n the real <input> inside, so the container itself is not a focus target. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #container\n class=\"tn-chip-input\"\n [class.tn-chip-input--disabled]=\"isDisabled()\"\n (click)=\"focusInput()\"\n>\n @for (value of values(); track $index) {\n <tn-chip\n class=\"tn-chip-input__chip\"\n color=\"secondary\"\n [label]=\"displayLabel(value)\"\n [closable]=\"!isDisabled()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"chipTestId(value)\"\n (onClose)=\"removeChip($index)\"\n />\n }\n\n <input\n #inputEl\n type=\"text\"\n class=\"tn-chip-input__field\"\n role=\"combobox\"\n autocomplete=\"off\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"resolvedTestId()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [attr.aria-label]=\"ariaLabel()\"\n [attr.aria-labelledby]=\"fieldAria.labelledby()\"\n [attr.aria-describedby]=\"fieldAria.describedby()\"\n [attr.aria-invalid]=\"fieldAria.invalid()\"\n [attr.aria-required]=\"fieldAria.required()\"\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 class=\"tn-chip-input__dropdown\">\n <!-- Per ARIA a listbox may only contain option children, so the loading row\n lives OUTSIDE it as a live-region sibling. The listbox is also the\n scrollport, which keeps that row pinned below the rows it describes\n rather than scrolling away with them. -->\n <div\n class=\"tn-chip-input__listbox\"\n role=\"listbox\"\n [attr.id]=\"uid + '-listbox'\"\n [attr.aria-busy]=\"loading() ? true : null\"\n >\n @for (suggestion of filteredSuggestions(); track $index) {\n <!-- Options are tabindex=\"-1\" and never focused: keyboard selection goes\n through the input's aria-activedescendant + the Enter branch in\n onKeydown, so a key handler here would be dead code. Click is a\n pointer-only affordance. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-chip-input__option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"suggestionTestId(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.label }}\n </div>\n }\n </div>\n\n <div role=\"status\">\n @if (loading()) {\n <div\n class=\"tn-chip-input__loading\"\n tnTestIdType=\"chip-input\"\n [tnTestId]=\"statusTestIdParts('loading')\"\n >\n <tn-spinner [diameter]=\"16\" [strokeWidth]=\"2\" [ariaLabel]=\"resolvedLoadingText()\" />\n <span>{{ resolvedLoadingText() }}</span>\n </div>\n }\n </div>\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{display:flex;flex-direction:column;max-height:var(--tn-chip-input-panel-max-height, 320px);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__listbox{max-height:inherit;overflow-y:auto;min-height:0}.tn-chip-input__loading{align-items:center;color:var(--tn-fg2, #6c757d);display:flex;font-size:.875rem;gap:.5rem;padding:.5rem .75rem}.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"] }]
9248
+ }], ctorParameters: () => [], propDecorators: { placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], separatorKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "separatorKeys", required: false }] }], addOnBlur: [{ type: i0.Input, args: [{ isSignal: true, alias: "addOnBlur", required: false }] }], allowCustomValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowCustomValue", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], maxChips: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxChips", required: false }] }], suggestions: [{ type: i0.Input, args: [{ isSignal: true, alias: "suggestions", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], dataSourceDebounce: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSourceDebounce", required: false }] }], loadingText: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingText", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], chipAdded: [{ type: i0.Output, args: ["chipAdded"] }], chipRemoved: [{ type: i0.Output, args: ["chipRemoved"] }], searchChange: [{ type: i0.Output, args: ["searchChange"] }], dataSourceError: [{ type: i0.Output, args: ["dataSourceError"] }], container: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], inputEl: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
8192
9249
 
8193
9250
  /**
8194
9251
  * Harness for interacting with `tn-chip-input` in tests. Reads the committed
@@ -8283,6 +9340,23 @@ class TnChipInputHarness extends ComponentHarness {
8283
9340
  }
8284
9341
  return texts;
8285
9342
  }
9343
+ /**
9344
+ * Whether the dropdown is showing its loading row, i.e. a `[dataSource]`
9345
+ * request is in flight. Any rows on screen while this is true belong to the
9346
+ * previous term — a `dataSource` is trusted to have applied the query, so
9347
+ * they are not re-filtered as the user types. On the first focus there are
9348
+ * no rows yet and the panel holds the loading row alone.
9349
+ *
9350
+ * @example
9351
+ * ```typescript
9352
+ * const chips = await loader.getHarness(TnChipInputHarness);
9353
+ * await chips.focus();
9354
+ * expect(await chips.isLoading()).toBe(true);
9355
+ * ```
9356
+ */
9357
+ async isLoading() {
9358
+ return (await this.documentRoot.locatorForOptional('.tn-chip-input__loading')()) !== null;
9359
+ }
8286
9360
  async isDisabled() {
8287
9361
  const input = await this._input();
8288
9362
  return (await input.getProperty('disabled')) === true;
@@ -16522,6 +17596,22 @@ function getExpandDuration() {
16522
17596
  }
16523
17597
  return '225ms cubic-bezier(0.4, 0.0, 0.2, 1)';
16524
17598
  }
17599
+ /**
17600
+ * Same members, order-independent. Used to keep the expansion reconcile idempotent: it re-runs
17601
+ * whenever `expansionKey`'s identity changes, and writing a fresh `Set` every time would churn
17602
+ * the signal for a consumer that binds an inline arrow rather than a stable member.
17603
+ */
17604
+ function isSameSet(a, b) {
17605
+ if (a.size !== b.size) {
17606
+ return false;
17607
+ }
17608
+ for (const value of a) {
17609
+ if (!b.has(value)) {
17610
+ return false;
17611
+ }
17612
+ }
17613
+ return true;
17614
+ }
16525
17615
  /** English defaults used when no `TN_TABLE_LABELS` provider is registered. */
16526
17616
  const TN_TABLE_DEFAULT_LABELS = {
16527
17617
  sortBy: 'Sort by',
@@ -16591,6 +17681,62 @@ class TnTableComponent {
16591
17681
  dataSource = input([], ...(ngDevMode ? [{ debugName: "dataSource" }] : []));
16592
17682
  displayedColumns = input([], ...(ngDevMode ? [{ debugName: "displayedColumns" }] : []));
16593
17683
  trackBy = input(undefined, ...(ngDevMode ? [{ debugName: "trackBy" }] : []));
17684
+ /**
17685
+ * Identity function for SELECTION. When set, the table tracks selected rows by the
17686
+ * key this returns rather than by object reference, and the selection survives a
17687
+ * `dataSource` change: paging, sorting, filtering, or a background reload that hands
17688
+ * back freshly-built row objects. Rows selected on a page the table is no longer
17689
+ * showing stay selected and keep coming back in `selectionChange`.
17690
+ *
17691
+ * Without it the table keeps its historical behaviour — any `dataSource` reference
17692
+ * change clears the selection and emits an empty `selectionChange` — because
17693
+ * reference-keyed selections cannot be recognised in a new array and would otherwise
17694
+ * leave the header checkbox counting rows that are no longer on screen.
17695
+ *
17696
+ * This is deliberately separate from {@link trackBy}, which is a rendering concern
17697
+ * (`@for` identity, index-based by default) and is free to stay so on a table whose
17698
+ * selection has to be stable. Pass a key that identifies the ROW, not its position —
17699
+ * and bind a stable member rather than writing the function in the template, since
17700
+ * Angular's expression grammar has no arrow functions:
17701
+ * `[selectionKey]="rowKey"` for a `rowKey = (row: User) => row.id` on the host.
17702
+ *
17703
+ * The select-all checkbox stays scoped to the visible page — it selects and clears
17704
+ * the rows the table is currently showing, and `isAllSelected` reflects only those,
17705
+ * so it never claims to have selected rows the user cannot see.
17706
+ *
17707
+ * Retention is unconditional, and it has to be: the table sees one page and cannot
17708
+ * tell a row that moved off-screen from one that was destroyed. A consumer whose rows
17709
+ * can disappear for good owns that check — reconcile `selectionChange` against the
17710
+ * full list it holds, and call {@link clearSelection} once a batch action is done.
17711
+ */
17712
+ selectionKey = input(undefined, ...(ngDevMode ? [{ debugName: "selectionKey" }] : []));
17713
+ /**
17714
+ * Identifies a row for EXPANSION, so an open detail row survives a `dataSource` change.
17715
+ *
17716
+ * Detail rows are held by object identity, and a consumer that re-fetches hands back new
17717
+ * objects for the same rows — so without a key a background reload closes the row the
17718
+ * user opened, which is usually the moment its contents are worth watching (a running
17719
+ * job's logs, say). With a key set, the table re-points the open rows at the new objects
17720
+ * instead of collapsing them.
17721
+ *
17722
+ * A row the new `data()` does not carry — another page, another tab, filtered out — is
17723
+ * kept, not dropped, and re-opens on its own once that row is listed again. Retention is
17724
+ * unconditional for the same reason it is for {@link selectionKey}: the table sees one
17725
+ * page and cannot tell a row that moved off-screen from one that is gone for good. Call
17726
+ * {@link clearExpansion} when the consumer knows it is gone.
17727
+ *
17728
+ * Without it the table keeps its historical behaviour — any `dataSource` reference change
17729
+ * collapses every open row.
17730
+ *
17731
+ * With a key set, drive expansion through {@link toggleRowExpansion}, {@link expandRow}
17732
+ * and {@link clearExpansion} rather than writing {@link expandedRows} directly, or the
17733
+ * retained set and the visible set drift apart.
17734
+ *
17735
+ * Pass a key that identifies the ROW, not its position, and bind a stable member rather
17736
+ * than writing the function in the template: `[expansionKey]="rowKey"` for a
17737
+ * `rowKey = (row: Job) => row.id` on the host.
17738
+ */
17739
+ expansionKey = input(undefined, ...(ngDevMode ? [{ debugName: "expansionKey" }] : []));
16594
17740
  emptyMessage = input('No data available', ...(ngDevMode ? [{ debugName: "emptyMessage" }] : []));
16595
17741
  /**
16596
17742
  * Optional second line under `emptyMessage`, for empty states that carry both a headline and an
@@ -16834,18 +17980,52 @@ class TnTableComponent {
16834
17980
  sortColumn = model('', ...(ngDevMode ? [{ debugName: "sortColumn" }] : []));
16835
17981
  sortDirection = model('', ...(ngDevMode ? [{ debugName: "sortDirection" }] : []));
16836
17982
  /**
16837
- * Set of currently expanded row references.
16838
- * Note: uses object identity. If the consumer replaces the data array
16839
- * (e.g. after sorting), expanded state is lost. A future key-based
16840
- * approach could address this.
17983
+ * The rows of the CURRENT `data()` whose detail row is open, by object identity.
17984
+ *
17985
+ * Replacing the data array collapses everything unless {@link expansionKey} is set, in
17986
+ * which case this is the visible slice of {@link expandedByKey} and survives the swap.
17987
+ *
17988
+ * Writable for consumers that predate {@link expansionKey}. **Adding a key to a consumer
17989
+ * that writes this signal directly is a migration, not a drop-in**: a write here closes
17990
+ * what is on screen but never touches the retained map, so every row whose key is still in
17991
+ * it re-opens on the next reconcile — the one it just closed included. Move those writes to
17992
+ * {@link expandRow}, {@link toggleRowExpansion} and {@link clearExpansion}, which keep the
17993
+ * two in step.
16841
17994
  */
16842
17995
  expandedRows = signal(new Set(), ...(ngDevMode ? [{ debugName: "expandedRows" }] : []));
17996
+ /**
17997
+ * Every open detail row keyed by {@link expansionKey}, including rows no longer in
17998
+ * `data()`. Empty (and unused) when no key is set. Holds the row objects so a row that
17999
+ * comes back can be re-pointed at the object the new data carries.
18000
+ */
18001
+ expandedByKey = new Map();
16843
18002
  // Per-instance prefix for generated DOM ids, so two tables on a page can't collide.
16844
18003
  static instanceCount = 0;
16845
18004
  instanceId = `tn-table-${TnTableComponent.instanceCount++}`;
16846
18005
  // --- Selection state ---
18006
+ /**
18007
+ * The rows of the CURRENT `data()` that are selected. With `selectionKey` set this is
18008
+ * the visible slice of {@link selectedByKey} — it is what the row checkboxes and the
18009
+ * select-all state read, so both stay scoped to the page on screen.
18010
+ */
16847
18011
  selection = new SelectionModel(true, []);
18012
+ /**
18013
+ * The whole selection, keyed by `selectionKey`, including rows that are no longer in
18014
+ * `data()`. Empty (and unused) when no `selectionKey` is set.
18015
+ *
18016
+ * It holds the row objects, not just the keys, so `selectionChange` can hand a
18017
+ * consumer the rows it selected on an earlier page — a batch action has to act on
18018
+ * rows, and the table is the only thing that ever saw them.
18019
+ */
18020
+ selectedByKey = new Map();
18021
+ /** Number of selected rows present in the current `data()` — see {@link selection}. */
16848
18022
  selectionCount = signal(0, ...(ngDevMode ? [{ debugName: "selectionCount" }] : []));
18023
+ /**
18024
+ * The array last handed to `selectionChange`, so a reconcile that leaves the selection
18025
+ * exactly as the consumer already sees it can stay quiet — re-running because
18026
+ * `selectionKey`'s identity changed is not news.
18027
+ */
18028
+ lastEmittedSelection = [];
16849
18029
  initialized = false;
16850
18030
  // Column def map as a computed signal
16851
18031
  columnDefMap = computed(() => {
@@ -16859,20 +18039,84 @@ class TnTableComponent {
16859
18039
  return map;
16860
18040
  }, ...(ngDevMode ? [{ debugName: "columnDefMap" }] : []));
16861
18041
  constructor() {
16862
- // Clear selection and expansion when data reference changes
18042
+ // Reconcile open detail rows against the rows now on screen.
18043
+ //
18044
+ // Without `expansionKey` this collapses everything: the set holds rows from the array
18045
+ // that is going away, and reference-keyed rows cannot be recognised in a new one.
18046
+ //
18047
+ // With a key it re-points them instead. It therefore has to read `expansionKey()` as
18048
+ // well as `data()` — a key being taken away must be noticed promptly — which means an
18049
+ // unrelated re-creation of that input (an inline arrow in a template, which the type
18050
+ // permits) re-runs this. That must not churn the signal or, worse, close what the user
18051
+ // opened, so the keyed branch derives `visible` from the retained map and writes only
18052
+ // when the result actually differs. `selectionKey` is still never read here: a
18053
+ // selection concern must not touch expansion.
16863
18054
  effect(() => {
16864
- this.data();
16865
- if (this.initialized) {
16866
- this.selection.clear();
16867
- this.selectionCount.set(0);
18055
+ const rows = this.data();
18056
+ const expansionKey = this.expansionKey();
18057
+ if (!this.initialized) {
18058
+ return;
18059
+ }
18060
+ if (!expansionKey) {
18061
+ // Also drop anything retained under a key, so a table whose `expansionKey` is taken
18062
+ // away cannot re-open those rows if it is given one again.
18063
+ this.expandedByKey.clear();
16868
18064
  this.expandedRows.set(new Set());
16869
- this.selectionChange.emit([]);
18065
+ return;
18066
+ }
18067
+ // Re-point each retained row at the object the new data carries and show the ones this
18068
+ // page actually has. Rows it does not have stay in the map and re-open when they return.
18069
+ const visible = new Set();
18070
+ for (const row of rows) {
18071
+ const key = expansionKey(row);
18072
+ if (this.expandedByKey.has(key)) {
18073
+ this.expandedByKey.set(key, row);
18074
+ visible.add(row);
18075
+ }
18076
+ }
18077
+ // `untracked`: the guard needs the current value, not a subscription. Reading it
18078
+ // tracked would make every write to `expandedRows` re-run this effect and rewrite it
18079
+ // from the map — so a consumer closing a visible row with `expandedRows.set(new Set())`
18080
+ // would have it put straight back, and the signal would be read-only in all but name.
18081
+ if (!isSameSet(visible, untracked(this.expandedRows))) {
18082
+ this.expandedRows.set(visible);
16870
18083
  }
16871
18084
  });
16872
- // Clear expanded rows when expandable is toggled off
18085
+ // Reconcile the retained selection against the rows now on screen. This has to
18086
+ // react to `selectionKey` as well as to `data()` — a key being taken away must be
18087
+ // noticed promptly — so the emit is guarded by whether the selection the consumer
18088
+ // last saw actually changed, and a key swapped for an equivalent one stays silent.
18089
+ effect(() => {
18090
+ const rows = this.data();
18091
+ const selectionKey = this.selectionKey();
18092
+ if (!this.initialized) {
18093
+ return;
18094
+ }
18095
+ if (!selectionKey) {
18096
+ // Also drop anything retained under a key, so a table whose `selectionKey` is
18097
+ // taken away cannot resurrect those rows if it is given one again.
18098
+ this.clearSelection();
18099
+ return;
18100
+ }
18101
+ // Re-point every retained selection at the row object the new data carries, so a
18102
+ // reload that rebuilt its rows leaves the consumer holding live references rather
18103
+ // than the stale ones it selected. Rows the new data does not carry (another page,
18104
+ // or filtered out) keep the reference they were selected with.
18105
+ for (const row of rows) {
18106
+ const key = selectionKey(row);
18107
+ if (this.selectedByKey.has(key)) {
18108
+ this.selectedByKey.set(key, row);
18109
+ }
18110
+ }
18111
+ this.syncSelectionToData(rows, selectionKey);
18112
+ this.emitSelectionIfChanged();
18113
+ });
18114
+ // Clear expanded rows when expandable is toggled off. Through `clearExpansion()`, so the
18115
+ // rows retained under `expansionKey` go as well: clearing only the visible set would let
18116
+ // the next `dataSource` change re-open rows the table itself had just closed.
16873
18117
  effect(() => {
16874
18118
  if (!this.expandable()) {
16875
- this.expandedRows.set(new Set());
18119
+ this.clearExpansion();
16876
18120
  }
16877
18121
  });
16878
18122
  // Prune rows the predicate no longer allows from the expanded set, so a row
@@ -16883,7 +18127,8 @@ class TnTableComponent {
16883
18127
  // before the predicate runs — there is nothing to prune, and the next toggle
16884
18128
  // re-runs this effect and re-tracks the predicate's signals. The
16885
18129
  // next.size !== expanded.size guard makes the self-write converge after one
16886
- // extra run, so there is no infinite loop.
18130
+ // extra run, so there is no infinite loop. A rejected row leaves the retained
18131
+ // `expansionKey` map as well — see below.
16887
18132
  effect(() => {
16888
18133
  const predicate = this.isRowExpandable();
16889
18134
  if (!predicate) {
@@ -16893,11 +18138,17 @@ class TnTableComponent {
16893
18138
  if (expanded.size === 0) {
16894
18139
  return;
16895
18140
  }
18141
+ const expansionKey = this.expansionKey();
16896
18142
  const next = new Set();
16897
18143
  for (const row of expanded) {
16898
18144
  if (predicate(row)) {
16899
18145
  next.add(row);
16900
18146
  }
18147
+ else if (expansionKey) {
18148
+ // Drop it from the retained map too, or the guarantee above holds only until the
18149
+ // next `dataSource` change re-points the row and opens it again.
18150
+ this.expandedByKey.delete(expansionKey(row));
18151
+ }
16901
18152
  }
16902
18153
  if (next.size !== expanded.size) {
16903
18154
  this.expandedRows.set(next);
@@ -17035,7 +18286,7 @@ class TnTableComponent {
17035
18286
  ngOnInit() {
17036
18287
  this.initialized = true;
17037
18288
  this.destroyRef.onDestroy(() => {
17038
- this.selection.clear();
18289
+ this.clearSelection();
17039
18290
  this.resizeObserver?.disconnect();
17040
18291
  });
17041
18292
  }
@@ -17168,17 +18419,62 @@ class TnTableComponent {
17168
18419
  return;
17169
18420
  }
17170
18421
  const expanded = new Set(this.expandedRows());
17171
- if (expanded.has(row)) {
18422
+ const expansionKey = this.expansionKey();
18423
+ if (this.isExpansionOpen(row)) {
18424
+ // `delete` is a no-op for a row that has paged away — it is not in the visible set —
18425
+ // and dropping the key is what actually closes it. Resolving through the key is why
18426
+ // toggling a retained row closes it instead of re-adding it, matching
18427
+ // `toggleRowSelection`.
17172
18428
  expanded.delete(row);
18429
+ if (expansionKey) {
18430
+ this.expandedByKey.delete(expansionKey(row));
18431
+ }
17173
18432
  }
17174
18433
  else {
17175
18434
  if (this.singleExpand()) {
17176
18435
  expanded.clear();
18436
+ if (expansionKey) {
18437
+ this.expandedByKey.clear();
18438
+ }
17177
18439
  }
17178
18440
  expanded.add(row);
18441
+ if (expansionKey) {
18442
+ this.expandedByKey.set(expansionKey(row), row);
18443
+ }
17179
18444
  }
17180
18445
  this.expandedRows.set(expanded);
17181
18446
  }
18447
+ /**
18448
+ * Whether `row`'s detail row counts as open for a toggle. With {@link expansionKey} that is
18449
+ * the retained map, which includes rows this page is not showing; without one it is object
18450
+ * identity against the visible set. Distinct from {@link isRowExpanded}, which drives
18451
+ * rendering and must therefore stay about the row in front of the user.
18452
+ */
18453
+ isExpansionOpen(row) {
18454
+ const expansionKey = this.expansionKey();
18455
+ return expansionKey ? this.expandedByKey.has(expansionKey(row)) : this.expandedRows().has(row);
18456
+ }
18457
+ /**
18458
+ * Opens `row`'s detail row programmatically — the same thing a chevron click does, for a
18459
+ * consumer restoring expansion from somewhere else (a `?jobId=` query parameter, say).
18460
+ * Honours `singleExpand` and keeps {@link expansionKey}'s retained set in step, which
18461
+ * writing {@link expandedRows} directly does not.
18462
+ */
18463
+ expandRow(row) {
18464
+ if (this.isExpansionOpen(row)) {
18465
+ return;
18466
+ }
18467
+ this.toggleRowExpansion(row);
18468
+ }
18469
+ /**
18470
+ * Collapses every detail row, including rows retained under {@link expansionKey} that are
18471
+ * not on screen. `expandedRows.set(new Set())` only closes what is visible, so with a key
18472
+ * set the off-screen rows would re-open on the next reconcile.
18473
+ */
18474
+ clearExpansion() {
18475
+ this.expandedByKey.clear();
18476
+ this.expandedRows.set(new Set());
18477
+ }
17182
18478
  isRowExpanded(row) {
17183
18479
  return this.expandedRows().has(row);
17184
18480
  }
@@ -17479,23 +18775,103 @@ class TnTableComponent {
17479
18775
  this.toggleRowSelection(row);
17480
18776
  }
17481
18777
  toggleSelectAll() {
17482
- if (this.isAllSelected()) {
17483
- this.selection.clear();
18778
+ const selectAll = !this.isAllSelected();
18779
+ if (selectAll) {
18780
+ this.selection.select(...this.data());
17484
18781
  }
17485
18782
  else {
17486
- this.selection.select(...this.data());
18783
+ this.selection.clear();
18784
+ }
18785
+ // Only the visible rows move: select-all is scoped to the page, so rows retained
18786
+ // from elsewhere are neither selected nor dropped by it.
18787
+ const selectionKey = this.selectionKey();
18788
+ if (selectionKey) {
18789
+ for (const row of this.data()) {
18790
+ const key = selectionKey(row);
18791
+ if (selectAll) {
18792
+ this.selectedByKey.set(key, row);
18793
+ }
18794
+ else {
18795
+ this.selectedByKey.delete(key);
18796
+ }
18797
+ }
17487
18798
  }
17488
18799
  this.selectionCount.set(this.selection.selected.length);
17489
- this.selectionChange.emit(this.selection.selected);
18800
+ this.emitSelection();
17490
18801
  }
17491
18802
  toggleRowSelection(row) {
17492
18803
  this.selection.toggle(row);
18804
+ const selectionKey = this.selectionKey();
18805
+ if (selectionKey) {
18806
+ const key = selectionKey(row);
18807
+ if (this.selection.isSelected(row)) {
18808
+ this.selectedByKey.set(key, row);
18809
+ }
18810
+ else {
18811
+ this.selectedByKey.delete(key);
18812
+ }
18813
+ }
17493
18814
  this.selectionCount.set(this.selection.selected.length);
17494
- this.selectionChange.emit(this.selection.selected);
18815
+ this.emitSelection();
17495
18816
  }
17496
18817
  isRowSelected(row) {
18818
+ const selectionKey = this.selectionKey();
18819
+ if (selectionKey) {
18820
+ // Read the keyed store rather than the model: a row object rebuilt by a reload is
18821
+ // a different reference, and it should render checked before the reconciling
18822
+ // effect has had a chance to re-point the model at it.
18823
+ return this.selectedByKey.has(selectionKey(row));
18824
+ }
17497
18825
  return this.selection.isSelected(row);
17498
18826
  }
18827
+ /**
18828
+ * Drops the whole selection, including rows retained from pages the table is not
18829
+ * showing. Use it after a batch action consumes the selection.
18830
+ *
18831
+ * Emits an empty `selectionChange` if anything was selected. The caller knows it
18832
+ * cleared, but `selectionChange` is the only way anything else can: a toolbar bound to
18833
+ * a mirrored copy of the selection a layer up would otherwise stay stale until the next
18834
+ * `dataSource` change happened to correct it. Clearing {@link selection} directly is not
18835
+ * enough once `selectionKey` is set: the retained rows would survive and come back on
18836
+ * the next reconcile.
18837
+ */
18838
+ clearSelection() {
18839
+ this.selection.clear();
18840
+ this.selectedByKey.clear();
18841
+ this.selectionCount.set(0);
18842
+ this.emitSelectionIfChanged();
18843
+ }
18844
+ /** Re-applies the retained selection to the rows currently on screen. */
18845
+ syncSelectionToData(rows, selectionKey) {
18846
+ this.selection.clear();
18847
+ const selected = rows.filter((row) => this.selectedByKey.has(selectionKey(row)));
18848
+ if (selected.length) {
18849
+ this.selection.select(...selected);
18850
+ }
18851
+ this.selectionCount.set(this.selection.selected.length);
18852
+ }
18853
+ /** What `selectionChange` carries: the whole selection when keyed, the page's otherwise. */
18854
+ emittedSelection() {
18855
+ return this.selectionKey() ? [...this.selectedByKey.values()] : this.selection.selected;
18856
+ }
18857
+ /** Emits the current selection and records it. For the paths that always change it. */
18858
+ emitSelection() {
18859
+ this.lastEmittedSelection = this.emittedSelection();
18860
+ this.selectionChange.emit(this.lastEmittedSelection);
18861
+ }
18862
+ /**
18863
+ * Emits only if the selection differs from the one last emitted, by row identity — for
18864
+ * the paths that may be re-run without anything having moved.
18865
+ */
18866
+ emitSelectionIfChanged() {
18867
+ const next = this.emittedSelection();
18868
+ const previous = this.lastEmittedSelection;
18869
+ if (next.length === previous.length && next.every((row, index) => row === previous[index])) {
18870
+ return;
18871
+ }
18872
+ this.lastEmittedSelection = next;
18873
+ this.selectionChange.emit(next);
18874
+ }
17499
18875
  // --- Card-mode computeds ---
17500
18876
  /**
17501
18877
  * The column rendered as the card title. The first `displayedColumns` entry
@@ -17627,7 +19003,7 @@ class TnTableComponent {
17627
19003
  return row[column];
17628
19004
  }
17629
19005
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17630
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTableComponent, isStandalone: true, selector: "tn-table", inputs: { dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, displayedColumns: { classPropertyName: "displayedColumns", publicName: "displayedColumns", isSignal: true, isRequired: false, transformFunction: null }, trackBy: { classPropertyName: "trackBy", publicName: "trackBy", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, emptyDescription: { classPropertyName: "emptyDescription", publicName: "emptyDescription", isSignal: true, isRequired: false, transformFunction: null }, emptyIcon: { classPropertyName: "emptyIcon", publicName: "emptyIcon", isSignal: true, isRequired: false, transformFunction: null }, scrollRegionAriaLabel: { classPropertyName: "scrollRegionAriaLabel", publicName: "scrollRegionAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, expandable: { classPropertyName: "expandable", publicName: "expandable", isSignal: true, isRequired: false, transformFunction: null }, bordered: { classPropertyName: "bordered", publicName: "bordered", isSignal: true, isRequired: false, transformFunction: null }, isRowExpandable: { classPropertyName: "isRowExpandable", publicName: "isRowExpandable", isSignal: true, isRequired: false, transformFunction: null }, activeRow: { classPropertyName: "activeRow", publicName: "activeRow", isSignal: true, isRequired: false, transformFunction: null }, activeWhen: { classPropertyName: "activeWhen", publicName: "activeWhen", isSignal: true, isRequired: false, transformFunction: null }, activeBg: { classPropertyName: "activeBg", publicName: "activeBg", isSignal: true, isRequired: false, transformFunction: null }, activeIndicator: { classPropertyName: "activeIndicator", publicName: "activeIndicator", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingMessage: { classPropertyName: "loadingMessage", publicName: "loadingMessage", isSignal: true, isRequired: false, transformFunction: null }, clickable: { classPropertyName: "clickable", publicName: "clickable", isSignal: true, isRequired: false, transformFunction: null }, expandOnRowClick: { classPropertyName: "expandOnRowClick", publicName: "expandOnRowClick", isSignal: true, isRequired: false, transformFunction: null }, singleExpand: { classPropertyName: "singleExpand", publicName: "singleExpand", isSignal: true, isRequired: false, transformFunction: null }, fixedLayout: { classPropertyName: "fixedLayout", publicName: "fixedLayout", isSignal: true, isRequired: false, transformFunction: null }, minColumnWidth: { classPropertyName: "minColumnWidth", publicName: "minColumnWidth", isSignal: true, isRequired: false, transformFunction: null }, minWidth: { classPropertyName: "minWidth", publicName: "minWidth", isSignal: true, isRequired: false, transformFunction: null }, mobileLayout: { classPropertyName: "mobileLayout", publicName: "mobileLayout", isSignal: true, isRequired: false, transformFunction: null }, cardBreakpoint: { classPropertyName: "cardBreakpoint", publicName: "cardBreakpoint", isSignal: true, isRequired: false, transformFunction: null }, cardPrimaryCount: { classPropertyName: "cardPrimaryCount", publicName: "cardPrimaryCount", isSignal: true, isRequired: false, transformFunction: null }, sortColumn: { classPropertyName: "sortColumn", publicName: "sortColumn", isSignal: true, isRequired: false, transformFunction: null }, sortDirection: { classPropertyName: "sortDirection", publicName: "sortDirection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sortChange: "sortChange", selectionChange: "selectionChange", rowClick: "rowClick", rowDoubleClick: "rowDoubleClick", sortColumn: "sortColumnChange", sortDirection: "sortDirectionChange" }, host: { properties: { "class.tn-table--bordered": "bordered()", "class.tn-table--fixed-layout": "fixedLayout()", "class.tn-table--loading": "loading()", "class.tn-table--cards": "isCardMode()", "class.tn-table--scroll": "isScrollMode()", "style.--tn-table-active-bg": "activeBg()", "style.--tn-table-active-indicator": "activeIndicator()", "attr.tabindex": "scrollKeyboardReachable() ? \"0\" : null", "attr.role": "scrollKeyboardReachable() ? \"group\" : null", "attr.aria-label": "scrollKeyboardReachable() ? resolvedScrollRegionLabel() : null" }, classAttribute: "tn-table" }, queries: [{ propertyName: "columnDefs", predicate: TnTableColumnDirective, isSignal: true }, { propertyName: "detailRowDef", first: true, predicate: TnDetailRowDefDirective, descendants: true, isSignal: true }, { propertyName: "rowActionsDef", first: true, predicate: TnRowActionsDefDirective, descendants: true, isSignal: true }], hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "@if (isCardMode()) {\n <!-- Card layout (narrow container) -->\n @if (selectable() || sortableColumns().length > 0) {\n <div class=\"tn-table__cards-toolbar\" [attr.inert]=\"loading() ? '' : null\">\n @if (selectable()) {\n <!--\n A plain <div>, not a <label>: tn-checkbox renders its own <label for>,\n and nesting one label inside another is invalid. The visible text is\n passed through the checkbox's own `label` input so it stays associated.\n\n Two activation paths, deliberately, and they are mutually exclusive. The\n checkbox activates itself and reports through `(change)`; this wrapper is\n only the hit area around it, so its handler stands down for anything that\n started inside the checkbox \u2014 see `onSelectAllHitAreaClick`. Until #236 the\n wrapper was the only path, because `.tn-table__checkbox` was\n `pointer-events: none` and no real click could reach the control; that\n shape is what forced a focusable `role=\"checkbox\"` onto the table header's\n <th>, nesting one widget inside another.\n\n No `preventDefault()` here any more, and that is load-bearing rather than\n tidiness: a click on the checkbox's own <label> bubbles to this wrapper, and\n cancelling it cancels the label's default action \u2014 which is how the label\n activates the input.\n\n The click/key and focusable lint rules are suppressed rather than satisfied:\n this wrapper is a pointer hit area around a control that is already\n focusable and already handles Space. Giving it `tabindex` and a key handler\n would add a second tab stop and a second activation path for the same\n checkbox, which is worse than the rule it silences.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__cards-selectall\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\" />\n </div>\n }\n @if (sortableColumns().length > 0) {\n <div class=\"tn-table__cards-sort\">\n <tn-icon name=\"mat-sort\" size=\"sm\" aria-hidden=\"true\" />\n <select\n class=\"tn-table__cards-sort-select\"\n [attr.aria-label]=\"labels().sortBy\"\n [value]=\"sortColumn()\"\n (change)=\"onSortSelectChange($event)\">\n <!--\n `[selected]` on the options, not just `[value]` on the <select>:\n the select's value is bound before the @for options exist, so the\n browser discards it and the binding is never dirty again \u2014 a table\n sorted before card mode engaged would render as \"Unsorted\".\n -->\n <option value=\"\" [selected]=\"!sortColumn()\">{{ labels().unsorted }}</option>\n @for (column of sortableColumns(); track column) {\n <option [value]=\"column\" [selected]=\"column === sortColumn()\">\n {{ getCardLabel(column) }}\n </option>\n }\n </select>\n @if (canSortActiveColumn()) {\n <!--\n Gated on the column being genuinely sortable, not merely active:\n `sortableColumns` keeps a non-sortable active column listed so the sort stays\n clearable, and this toggle must not ride along \u2014 card mode would otherwise\n reorder by a column whose table header ignores clicks.\n\n Three states, not two. A column with an empty direction is not sorted \u2014\n reachable through the two-way binding \u2014 and this control used to render an\n up arrow labelled \"Sort descending\" for it, while table mode showed no\n arrow and no `aria-sort` for the same state. It keeps rendering, because\n it is how the user escapes that state, but shows the neutral\n `mat-unfold_more` the table header already uses for unsorted columns.\n\n The label names the action, so it is `sortAscending` both when unsorted\n and when descending \u2014 `toggleSortDirection()` starts at ascending.\n -->\n <button\n type=\"button\"\n class=\"tn-table__cards-sort-dir\"\n [attr.aria-label]=\"sortDirection() === 'asc' ? labels().sortDescending : labels().sortAscending\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"toggleSortDirection()\">\n @if (sortDirection() === 'asc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_upward\" aria-hidden=\"true\" />\n } @else if (sortDirection() === 'desc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_downward\" aria-hidden=\"true\" />\n } @else {\n <tn-icon size=\"sm\" name=\"mat-unfold_more\" aria-hidden=\"true\" />\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!--\n Decorative `tn-icon`s carry `aria-hidden=\"true\"`. tn-icon renders `role=\"img\"` and\n falls back to its `name` for the accessible name, so without this the raw token\n leaks: a standalone \"mat-sort\" image beside Select all, and disclosures announced\n as \"More fields mat-keyboard_arrow_down\". The repo convention is the same \u2014 see\n button, card, input and file-picker-popup.\n -->\n <ng-template #cardField let-column let-row=\"row\">\n <div class=\"tn-table__card-field\" [attr.data-column]=\"column\">\n <dt class=\"tn-table__card-field-label\">{{ getCardLabel(column) }}</dt>\n <dd class=\"tn-table__card-field-value\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n {{ getCellValue(row, column) }}\n }\n </dd>\n </div>\n </ng-template>\n\n <div class=\"tn-table__cards\" role=\"list\" [attr.inert]=\"loading() ? '' : null\">\n <!--\n Card ARIA differs from the table row on purpose, because `listitem` supports\n far less than `row` does:\n - Active state uses `aria-current`, not `aria-selected`, which `listitem`\n does not support (an earlier revision set it here and it was dropped for\n that reason; axe reports it as a critical aria-allowed-attr violation).\n - Selection state stays on the checkbox, which is a real control.\n - `aria-expanded` IS permitted here \u2014 `listitem` is a subclass of the\n abstract `section` role, and `section` supports `aria-expanded`, so\n `listitem` inherits it (axe-core's own role table lists it in\n `listitem`'s allowedAttrs alongside aria-level/posinset/setsize). So the\n card carries it whenever the card itself acts as the expand trigger,\n matching what table mode does for its rows. The \"Details\" button reports\n the same state unconditionally, because it is the control a screen-reader\n user actually tabs to \u2014 see the note above it.\n The card is deliberately NOT `role=\"button\"`: that role has presentational\n children, which would hide the card's checkbox, row actions and \"Details\"\n toggle from assistive tech entirely.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <div\n class=\"tn-table__card\"\n role=\"listitem\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__card--active]=\"isRowActive(row)\"\n [class.tn-table__card--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-current]=\"isRowActive(row) ? 'true' : null\"\n [attr.aria-expanded]=\"isCardExpandTrigger(row) ? isRowExpanded(row) : null\"\n [attr.aria-controls]=\"isCardExpandTrigger(row) && isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"onCardClick($event, row)\"\n (dblclick)=\"onCardDoubleClick($event, row)\"\n (keydown)=\"onCardKeydown($event, row)\">\n <div class=\"tn-table__card-header\">\n @if (selectable()) {\n <!--\n <div>, not <label>, and the handler is on the wrapper rather than the\n checkbox \u2014 see the toolbar's select-all above for both reasons.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__card-select\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <!--\n Named from the card's title, not \"Select row N\": with `hideLabel` this is the\n checkbox's entire accessible name, and a card list has no visible row numbers\n to match it against. Falls back to the index when the title column renders\n derived data through a `tnCellDef` and `getCellValue` has nothing to return \u2014\n the same case `getCardLabel()` already falls back for.\n -->\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"\n 'Select ' + (getCellValue(row, cardTitleColumn()) ?? 'row ' + (rowIdx + 1))\n \"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </div>\n }\n <span class=\"tn-table__card-title\">\n @if (getColumnDef(cardTitleColumn())?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: cardTitleColumn() }\" />\n } @else {\n {{ getCellValue(row, cardTitleColumn()) }}\n }\n </span>\n @if (rowActionsDef()) {\n <div class=\"tn-table__card-actions\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n </div>\n\n <dl class=\"tn-table__card-fields\">\n @for (column of cardPrimaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n\n <!--\n The disclosure sits outside the primary <dl> and wraps its fields in a\n <dl> of its own: <details> is not a permitted child of <dl>, so nesting\n it there detached the folded dt/dd pairs from any description list.\n -->\n @if (cardSecondaryColumns().length > 0) {\n <details class=\"tn-table__card-more\">\n <summary class=\"tn-table__card-more-summary\">\n <span>{{ labels().moreFields }}</span>\n <tn-icon size=\"sm\" name=\"mat-keyboard_arrow_down\" aria-hidden=\"true\" />\n </summary>\n <dl class=\"tn-table__card-fields\">\n @for (column of cardSecondaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n </details>\n }\n\n @if (canExpandRow(row) && detailRowDef()) {\n <!--\n The button always carries the state, even when the card is also a trigger.\n An earlier revision suppressed it here to avoid announcing the same panel\n twice, but that muted the primary control: this button is what a\n screen-reader user reaches by tabbing through controls, and it was\n announced as a bare \"Details, button\" with no expanded state, before or\n after activation. The state sat on the `role=\"listitem\"` card, heard only\n if the user happened to focus the card itself.\n\n Two controls that each toggle the same region and each report its state is\n the disclosure pattern applied twice \u2014 redundant, not invalid. Redundancy\n is the cheaper failure.\n -->\n <button\n type=\"button\"\n class=\"tn-table__card-detail-toggle\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"toggleRowExpansion(row); $event.stopPropagation()\">\n <span>{{ labels().details }}</span>\n <tn-icon\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n @if (isRowExpanded(row)) {\n <div class=\"tn-table__card-detail\" [attr.id]=\"detailPanelId(rowIdx)\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n }\n </div>\n }\n </div>\n} @else {\n <table\n class=\"tn-table__table\"\n [style.min-width]=\"resolvedMinWidth()\"\n [attr.inert]=\"loading() ? '' : null\">\n <!-- Header Row -->\n <thead class=\"tn-table__header\">\n <tr class=\"tn-table__header-row\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <!--\n A plain <th>, so it keeps its implicit `columnheader` role. It used to\n carry `role=\"checkbox\"` and `tabindex=\"0\"` of its own, with the real\n checkbox inside it: two tab stops for one action, a widget nested in a\n widget (axe `nested-interactive`), and a selection column that was no\n longer a header at all (#236).\n\n The cell is now only the hit area \u2014 the same arrangement card mode has\n always used, and the same `(change)` + hit-area pair as the row cells\n below. `onSelectAllHitAreaClick` stands down when the click started\n inside the checkbox, which activates itself.\n\n Enter is bound because the removed <th> handled it and a native checkbox\n does not; Space needs no binding, being the checkbox's own key.\n\n `disabled` on an empty table is not decoration \u2014 see `canSelectAll`.\n -->\n <th class=\"tn-table__header-cell tn-table__select-cell\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\"\n (keydown.enter)=\"onSelectAllEnter($event)\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().expand }}</span>\n </th>\n } @else {\n <th\n class=\"tn-table__header-cell\"\n [class.tn-table__header-cell--sortable]=\"getColumnDef(column)?.sortable()\"\n [class.tn-table__header-cell--sorted]=\"isSorted(column)\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.aria-sort]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'ascending' : 'descending')\n : (getColumnDef(column)?.sortable() ? 'none' : null)\n \"\n [attr.tabindex]=\"getColumnDef(column)?.sortable() ? 0 : null\"\n [attr.data-column]=\"column\"\n (click)=\"onSortHeaderClick(column, $event)\"\n (keydown.enter)=\"onSortHeaderKeydown(column, $event)\"\n (keydown.space)=\"onSortHeaderKeydown(column, $event)\">\n <span class=\"tn-table__sort-container\">\n @if (getColumnDef(column)?.hideLabel()) {\n <!--\n A deliberately blank header still has to be announced: a <th>\n with no text fails axe `empty-table-header`, and the column\n goes unnamed for anyone reading the header row (#246). The\n hidden span is the same pattern the built-in `__expand` and\n row-actions headers use, and it lives here rather than in a\n consumer's `tnHeaderCellDef` because `.cdk-visually-hidden`\n is defined in this component's own stylesheet \u2014 projected\n markup carries the host's encapsulation attribute and would\n render the label visibly.\n -->\n <span class=\"cdk-visually-hidden\">\n {{ getColumnDef(column)?.label() ?? column }}\n </span>\n } @else {\n <span class=\"tn-table__header-text\">\n @if (getColumnDef(column)?.headerTemplate(); as tmpl) {\n <ng-container [ngTemplateOutlet]=\"tmpl\" />\n } @else {\n {{ getColumnDef(column)?.label() ?? column }}\n }\n </span>\n }\n @if (getColumnDef(column)?.sortable()) {\n <!--\n aria-hidden: the <th> has no aria-label, so its accessible name is\n computed from its contents \u2014 without this, every sortable column\n announced as \"Name mat-unfold_more\". `aria-sort` on the <th> already\n carries the state properly, so the token is pure noise.\n -->\n <tn-icon\n class=\"tn-table__sort-icon\"\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'mat-arrow_upward' : 'mat-arrow_downward')\n : 'mat-unfold_more'\n \" />\n }\n </span>\n </th>\n }\n }\n @if (rowActionsDef()) {\n <th class=\"tn-table__header-cell tn-table__actions-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().actions }}</span>\n </th>\n }\n </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\n <!--\n No `aria-expanded` here, unlike the card. `row` supports it only inside a\n `treegrid`; on a plain `table` it is ignored, so the state it appeared to\n publish reached nobody, while reading as though the work were done (axe\n `aria-conditional-attr`, #246). The chevron in the `__expand` cell is the\n control that carries it \u2014 a `button`, where the attribute is valid and\n where a screen-reader user lands by tabbing. `aria-controls` is global\n rather than role-conditional, so the row keeps pointing at the panel it\n opens; that is the half of the association a row CAN hold.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <tr\n class=\"tn-table__row\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__row--expandable]=\"canExpandRow(row)\"\n [class.tn-table__row--expanded]=\"canExpandRow(row) && isRowExpanded(row)\"\n [class.tn-table__row--active]=\"isRowActive(row)\"\n [class.tn-table__row--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-selected]=\"clickable() ? isRowActive(row) : null\"\n [attr.aria-controls]=\"isRowExpandTrigger(row) && isRowExpanded(row) && detailRowDef()\n ? detailPanelId(rowIdx)\n : null\"\n (click)=\"onRowClick(row, $event)\"\n (dblclick)=\"onRowDoubleClick(row, $event)\"\n (keydown)=\"onRowKeydown($event, row)\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <td class=\"tn-table__cell tn-table__select-cell\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </td>\n } @else if (column === '__expand') {\n <td class=\"tn-table__cell tn-table__expand-cell\"\n (click)=\"$event.stopPropagation()\">\n @if (canExpandRow(row)) {\n <button\n type=\"button\"\n class=\"tn-table__expand-button\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n [attr.aria-label]=\"isRowExpanded(row) ? labels().collapseRow : labels().expandRow\"\n (click)=\"toggleRowExpansion(row)\">\n <tn-icon\n class=\"tn-table__expand-icon\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n }\n </td>\n } @else {\n <td\n class=\"tn-table__cell\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.data-column]=\"column\">\n <div class=\"tn-table__cell-content\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n <span>{{ getCellValue(row, column) }}</span>\n }\n </div>\n </td>\n }\n }\n @if (rowActionsDef()) {\n <td class=\"tn-table__cell tn-table__actions-cell\"\n (click)=\"$event.stopPropagation()\">\n <div class=\"tn-table__actions-content\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n </td>\n }\n </tr>\n\n <!-- Detail / Expanded Row -->\n @if (detailRowDef() && canExpandRow(row) && isRowExpanded(row)) {\n <tr class=\"tn-table__detail-row\" [@detailExpand]=\"'expanded'\">\n <td\n class=\"tn-table__detail-cell\"\n [attr.id]=\"detailPanelId(rowIdx)\"\n [attr.colspan]=\"totalColumnCount()\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </td>\n </tr>\n }\n }\n </tbody>\n </table>\n}\n\n@if (data().length === 0 && !loading()) {\n <tn-empty\n size=\"compact\"\n [title]=\"emptyMessage()\"\n [description]=\"emptyDescription()\"\n [icon]=\"emptyIcon()\" />\n}\n\n@if (loading()) {\n <div\n class=\"tn-table__loading-overlay\"\n role=\"status\"\n aria-live=\"polite\"\n [attr.aria-label]=\"loadingMessage()\">\n <tn-spinner [ariaLabel]=\"loadingMessage()\" />\n </div>\n}\n", styles: [":host{display:block;position:relative;width:100%;overflow-x:auto;border-radius:4px}:host{--tn-table-select-width: 72px;--tn-table-actions-width: 128px;--tn-table-expand-width: 56px}.cdk-visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}:host(.tn-table--fixed-layout) table{table-layout:fixed;width:100%}:host(.tn-table--fixed-layout) .tn-table__actions-cell{box-sizing:border-box;width:var(--tn-table-actions-width)}:host(.tn-table--bordered){border:1px solid var(--tn-lines);border-radius:4px}:host(.tn-table--loading) .tn-table__table,:host(.tn-table--loading) .tn-table__cards,:host(.tn-table--loading) .tn-table__cards-toolbar{pointer-events:none;-webkit-user-select:none;user-select:none}:host(.tn-table--scroll) .tn-table__select-cell{position:sticky;left:0}:host(.tn-table--scroll) .tn-table__header-cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{position:sticky;left:0;z-index:1}:host(.tn-table--scroll) .tn-table__actions-cell{position:sticky;right:0;z-index:3}:host(.tn-table--scroll) .tn-table__header-row>.tn-table__header-cell.tn-table__select-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell.tn-table__select-cell{z-index:2}:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{left:var(--tn-table-select-width)}:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{background-color:inherit}:host(.tn-table--scroll) .tn-table__header-row .tn-table__header-cell:first-child:not(.tn-table__header-cell--sortable:hover),:host(.tn-table--scroll) .tn-table__header-row .tn-table__select-cell+.tn-table__header-cell:not(.tn-table__header-cell--sortable:hover){background-color:var(--tn-topbar)}.tn-table__table{width:100%;border-collapse:collapse;border-spacing:0;background-color:var(--tn-bg2)}.tn-table__header{background-color:var(--tn-topbar);color:var(--tn-topbar-txt)}.tn-table__header-row{height:56px}.tn-table__header-cell{padding:0 16px;text-align:left;font-weight:600;font-size:14px;border-bottom:1px solid var(--tn-lines);white-space:nowrap;vertical-align:middle}.tn-table__header-cell--sortable{cursor:pointer;-webkit-user-select:none;user-select:none}.tn-table__header-cell--sortable:hover{background-color:var(--tn-topbar-hover)}.tn-table__sort-container{display:inline-flex;align-items:center;gap:4px}.tn-table__sort-icon{opacity:0;transition:opacity .2s ease}.tn-table__header-cell--sortable:hover .tn-table__sort-icon,.tn-table__header-cell--sortable:focus-visible .tn-table__sort-icon{opacity:.5}.tn-table__header-cell--sorted .tn-table__sort-icon{opacity:1}.tn-table__body{background-color:var(--tn-bg2)}.tn-table__row{height:48px;transition:background-color .2s ease;background-color:var(--tn-bg2)}.tn-table__row:hover{background-color:var(--tn-alt-bg1)}.tn-table__row:not(:last-child){border-bottom:1px solid var(--tn-lines)}.tn-table__row--expanded{background-color:var(--tn-alt-bg1)}.tn-table__row--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333))}.tn-table__row--active>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__row--clickable{cursor:pointer}.tn-table__row--clickable:focus-visible{outline:none}.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-primary),inset 0 0 0 2px var(--tn-primary)}.tn-table__row--active.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__cell{padding:0 16px;font-size:14px;color:var(--tn-fg1);vertical-align:middle;overflow:hidden}.tn-table__cell-content{display:flex;align-items:center;min-height:24px;white-space:normal;overflow-wrap:anywhere;overflow:hidden;padding:4px;margin:-4px}.tn-table__cell-content>*{min-width:0}.tn-table__select-cell{box-sizing:border-box;width:var(--tn-table-select-width);padding:0 8px 0 16px;cursor:pointer}.tn-table__expand-cell{box-sizing:border-box;width:var(--tn-table-expand-width);padding:0 16px 0 8px;text-align:center}.tn-table__expand-button{background:none;border:none;padding:4px;cursor:pointer;border-radius:4px;display:inline-flex;align-items:center;color:var(--tn-fg2)}.tn-table__expand-button:hover{background-color:var(--tn-alt-bg1)}.tn-table__expand-button:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__expand-icon{transition:transform .2s ease}.tn-table__detail-row{background-color:var(--tn-alt-bg1);border-bottom:1px solid var(--tn-lines)}.tn-table__detail-cell{padding:16px}.tn-table__actions-cell{width:1%;white-space:nowrap;text-align:right;background-color:inherit;border-left:1px solid var(--tn-lines)}.tn-table__header .tn-table__actions-cell{background-color:var(--tn-topbar)}.tn-table__actions-content{display:inline-flex;align-items:center;gap:4px}.tn-table__loading-overlay{position:absolute;inset:0;z-index:4;display:flex;align-items:center;justify-content:center;background-color:color-mix(in srgb,var(--tn-bg2, #282828) 65%,transparent);-webkit-backdrop-filter:blur(1px);backdrop-filter:blur(1px);pointer-events:all;border-radius:4px}.tn-table--dense .tn-table__header-row{height:40px}.tn-table--dense .tn-table__row{height:32px}.tn-table--dense .tn-table__header-cell,.tn-table--dense .tn-table__cell{padding:0 12px;font-size:13px}.tn-table__cards-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 4px 12px}.tn-table__cards-selectall{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--tn-fg1);cursor:pointer}.tn-table__cards-sort{display:inline-flex;align-items:center;gap:6px;margin-left:auto;color:var(--tn-fg2)}.tn-table__cards-sort-select{height:32px;padding:0 8px;border:1px solid var(--tn-lines);border-radius:4px;background-color:var(--tn-bg2);color:var(--tn-fg1);font-size:14px}.tn-table__cards-sort-select:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards-sort-dir{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid var(--tn-lines);border-radius:4px;background:none;color:var(--tn-fg2);cursor:pointer}.tn-table__cards-sort-dir:hover{background-color:var(--tn-alt-bg1)}.tn-table__cards-sort-dir:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards{display:flex;flex-direction:column;gap:8px}.tn-table__card{background-color:var(--tn-bg2);border:1px solid var(--tn-lines);border-radius:6px;padding:12px}.tn-table__card--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333));box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__card--clickable{cursor:pointer}.tn-table__card--clickable:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--tn-primary)}.tn-table__card--active.tn-table__card--clickable:focus-visible{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__card-header{display:flex;align-items:center;gap:10px}.tn-table__card-select{display:inline-flex;align-items:center;flex-shrink:0;cursor:pointer}.tn-table__card-title{flex:1;min-width:0;font-weight:600;font-size:15px;color:var(--tn-fg1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-table__card-actions{display:inline-flex;align-items:center;gap:4px;flex-shrink:0}.tn-table__card-fields{margin:0;padding:10px 0 0}.tn-table__card-field{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:3px 0;font-size:13px}.tn-table__card-field-label{flex-shrink:0;color:var(--tn-fg2)}.tn-table__card-field-value{margin:0;min-width:0;text-align:right;color:var(--tn-fg1);overflow-wrap:anywhere}.tn-table__card-more[open]>.tn-table__card-more-summary tn-icon{transform:rotate(180deg)}.tn-table__card-more{margin-top:2px}.tn-table__card-more-summary{display:inline-flex;align-items:center;gap:4px;padding:4px 0;font-size:13px;color:var(--tn-fg1);cursor:pointer;list-style:none}.tn-table__card-more-summary::-webkit-details-marker{display:none}.tn-table__card-more-summary tn-icon{transition:transform .2s ease}.tn-table__card-more-summary:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail-toggle{display:inline-flex;align-items:center;gap:4px;margin-top:8px;padding:4px 0;background:none;border:none;color:var(--tn-fg1);font-size:13px;cursor:pointer}.tn-table__card-detail-toggle:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail{margin-top:8px;padding-top:8px;border-top:1px solid var(--tn-lines)}@media(max-width:768px){.tn-table__table{font-size:12px}.tn-table__header-cell,.tn-table__cell{padding:0 8px}}@media(prefers-reduced-motion:reduce){.tn-table__card-more-summary tn-icon,.tn-table__sort-icon,.tn-table__expand-icon,.tn-table__row{transition:none}.tn-table__detail-row{animation:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "component", type: TnEmptyComponent, selector: "tn-empty", inputs: ["title", "description", "icon", "iconLibrary", "iconSize", "actionText", "bordered", "size"], outputs: ["onAction"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }], animations: [
19006
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTableComponent, isStandalone: true, selector: "tn-table", inputs: { dataSource: { classPropertyName: "dataSource", publicName: "dataSource", isSignal: true, isRequired: false, transformFunction: null }, displayedColumns: { classPropertyName: "displayedColumns", publicName: "displayedColumns", isSignal: true, isRequired: false, transformFunction: null }, trackBy: { classPropertyName: "trackBy", publicName: "trackBy", isSignal: true, isRequired: false, transformFunction: null }, selectionKey: { classPropertyName: "selectionKey", publicName: "selectionKey", isSignal: true, isRequired: false, transformFunction: null }, expansionKey: { classPropertyName: "expansionKey", publicName: "expansionKey", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, emptyDescription: { classPropertyName: "emptyDescription", publicName: "emptyDescription", isSignal: true, isRequired: false, transformFunction: null }, emptyIcon: { classPropertyName: "emptyIcon", publicName: "emptyIcon", isSignal: true, isRequired: false, transformFunction: null }, scrollRegionAriaLabel: { classPropertyName: "scrollRegionAriaLabel", publicName: "scrollRegionAriaLabel", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, expandable: { classPropertyName: "expandable", publicName: "expandable", isSignal: true, isRequired: false, transformFunction: null }, bordered: { classPropertyName: "bordered", publicName: "bordered", isSignal: true, isRequired: false, transformFunction: null }, isRowExpandable: { classPropertyName: "isRowExpandable", publicName: "isRowExpandable", isSignal: true, isRequired: false, transformFunction: null }, activeRow: { classPropertyName: "activeRow", publicName: "activeRow", isSignal: true, isRequired: false, transformFunction: null }, activeWhen: { classPropertyName: "activeWhen", publicName: "activeWhen", isSignal: true, isRequired: false, transformFunction: null }, activeBg: { classPropertyName: "activeBg", publicName: "activeBg", isSignal: true, isRequired: false, transformFunction: null }, activeIndicator: { classPropertyName: "activeIndicator", publicName: "activeIndicator", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingMessage: { classPropertyName: "loadingMessage", publicName: "loadingMessage", isSignal: true, isRequired: false, transformFunction: null }, clickable: { classPropertyName: "clickable", publicName: "clickable", isSignal: true, isRequired: false, transformFunction: null }, expandOnRowClick: { classPropertyName: "expandOnRowClick", publicName: "expandOnRowClick", isSignal: true, isRequired: false, transformFunction: null }, singleExpand: { classPropertyName: "singleExpand", publicName: "singleExpand", isSignal: true, isRequired: false, transformFunction: null }, fixedLayout: { classPropertyName: "fixedLayout", publicName: "fixedLayout", isSignal: true, isRequired: false, transformFunction: null }, minColumnWidth: { classPropertyName: "minColumnWidth", publicName: "minColumnWidth", isSignal: true, isRequired: false, transformFunction: null }, minWidth: { classPropertyName: "minWidth", publicName: "minWidth", isSignal: true, isRequired: false, transformFunction: null }, mobileLayout: { classPropertyName: "mobileLayout", publicName: "mobileLayout", isSignal: true, isRequired: false, transformFunction: null }, cardBreakpoint: { classPropertyName: "cardBreakpoint", publicName: "cardBreakpoint", isSignal: true, isRequired: false, transformFunction: null }, cardPrimaryCount: { classPropertyName: "cardPrimaryCount", publicName: "cardPrimaryCount", isSignal: true, isRequired: false, transformFunction: null }, sortColumn: { classPropertyName: "sortColumn", publicName: "sortColumn", isSignal: true, isRequired: false, transformFunction: null }, sortDirection: { classPropertyName: "sortDirection", publicName: "sortDirection", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sortChange: "sortChange", selectionChange: "selectionChange", rowClick: "rowClick", rowDoubleClick: "rowDoubleClick", sortColumn: "sortColumnChange", sortDirection: "sortDirectionChange" }, host: { properties: { "class.tn-table--bordered": "bordered()", "class.tn-table--fixed-layout": "fixedLayout()", "class.tn-table--loading": "loading()", "class.tn-table--cards": "isCardMode()", "class.tn-table--scroll": "isScrollMode()", "style.--tn-table-active-bg": "activeBg()", "style.--tn-table-active-indicator": "activeIndicator()", "attr.tabindex": "scrollKeyboardReachable() ? \"0\" : null", "attr.role": "scrollKeyboardReachable() ? \"group\" : null", "attr.aria-label": "scrollKeyboardReachable() ? resolvedScrollRegionLabel() : null" }, classAttribute: "tn-table" }, queries: [{ propertyName: "columnDefs", predicate: TnTableColumnDirective, isSignal: true }, { propertyName: "detailRowDef", first: true, predicate: TnDetailRowDefDirective, descendants: true, isSignal: true }, { propertyName: "rowActionsDef", first: true, predicate: TnRowActionsDefDirective, descendants: true, isSignal: true }], hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "@if (isCardMode()) {\n <!-- Card layout (narrow container) -->\n @if (selectable() || sortableColumns().length > 0) {\n <div class=\"tn-table__cards-toolbar\" [attr.inert]=\"loading() ? '' : null\">\n @if (selectable()) {\n <!--\n A plain <div>, not a <label>: tn-checkbox renders its own <label for>,\n and nesting one label inside another is invalid. The visible text is\n passed through the checkbox's own `label` input so it stays associated.\n\n Two activation paths, deliberately, and they are mutually exclusive. The\n checkbox activates itself and reports through `(change)`; this wrapper is\n only the hit area around it, so its handler stands down for anything that\n started inside the checkbox \u2014 see `onSelectAllHitAreaClick`. Until #236 the\n wrapper was the only path, because `.tn-table__checkbox` was\n `pointer-events: none` and no real click could reach the control; that\n shape is what forced a focusable `role=\"checkbox\"` onto the table header's\n <th>, nesting one widget inside another.\n\n No `preventDefault()` here any more, and that is load-bearing rather than\n tidiness: a click on the checkbox's own <label> bubbles to this wrapper, and\n cancelling it cancels the label's default action \u2014 which is how the label\n activates the input.\n\n The click/key and focusable lint rules are suppressed rather than satisfied:\n this wrapper is a pointer hit area around a control that is already\n focusable and already handles Space. Giving it `tabindex` and a key handler\n would add a second tab stop and a second activation path for the same\n checkbox, which is worse than the rule it silences.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__cards-selectall\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\" />\n </div>\n }\n @if (sortableColumns().length > 0) {\n <div class=\"tn-table__cards-sort\">\n <tn-icon name=\"mat-sort\" size=\"sm\" aria-hidden=\"true\" />\n <select\n class=\"tn-table__cards-sort-select\"\n [attr.aria-label]=\"labels().sortBy\"\n [value]=\"sortColumn()\"\n (change)=\"onSortSelectChange($event)\">\n <!--\n `[selected]` on the options, not just `[value]` on the <select>:\n the select's value is bound before the @for options exist, so the\n browser discards it and the binding is never dirty again \u2014 a table\n sorted before card mode engaged would render as \"Unsorted\".\n -->\n <option value=\"\" [selected]=\"!sortColumn()\">{{ labels().unsorted }}</option>\n @for (column of sortableColumns(); track column) {\n <option [value]=\"column\" [selected]=\"column === sortColumn()\">\n {{ getCardLabel(column) }}\n </option>\n }\n </select>\n @if (canSortActiveColumn()) {\n <!--\n Gated on the column being genuinely sortable, not merely active:\n `sortableColumns` keeps a non-sortable active column listed so the sort stays\n clearable, and this toggle must not ride along \u2014 card mode would otherwise\n reorder by a column whose table header ignores clicks.\n\n Three states, not two. A column with an empty direction is not sorted \u2014\n reachable through the two-way binding \u2014 and this control used to render an\n up arrow labelled \"Sort descending\" for it, while table mode showed no\n arrow and no `aria-sort` for the same state. It keeps rendering, because\n it is how the user escapes that state, but shows the neutral\n `mat-unfold_more` the table header already uses for unsorted columns.\n\n The label names the action, so it is `sortAscending` both when unsorted\n and when descending \u2014 `toggleSortDirection()` starts at ascending.\n -->\n <button\n type=\"button\"\n class=\"tn-table__cards-sort-dir\"\n [attr.aria-label]=\"sortDirection() === 'asc' ? labels().sortDescending : labels().sortAscending\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"toggleSortDirection()\">\n @if (sortDirection() === 'asc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_upward\" aria-hidden=\"true\" />\n } @else if (sortDirection() === 'desc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_downward\" aria-hidden=\"true\" />\n } @else {\n <tn-icon size=\"sm\" name=\"mat-unfold_more\" aria-hidden=\"true\" />\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!--\n Decorative `tn-icon`s carry `aria-hidden=\"true\"`. tn-icon renders `role=\"img\"` and\n falls back to its `name` for the accessible name, so without this the raw token\n leaks: a standalone \"mat-sort\" image beside Select all, and disclosures announced\n as \"More fields mat-keyboard_arrow_down\". The repo convention is the same \u2014 see\n button, card, input and file-picker-popup.\n -->\n <ng-template #cardField let-column let-row=\"row\">\n <div class=\"tn-table__card-field\" [attr.data-column]=\"column\">\n <dt class=\"tn-table__card-field-label\">{{ getCardLabel(column) }}</dt>\n <dd class=\"tn-table__card-field-value\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n {{ getCellValue(row, column) }}\n }\n </dd>\n </div>\n </ng-template>\n\n <div class=\"tn-table__cards\" role=\"list\" [attr.inert]=\"loading() ? '' : null\">\n <!--\n Card ARIA differs from the table row on purpose, because `listitem` supports\n far less than `row` does:\n - Active state uses `aria-current`, not `aria-selected`, which `listitem`\n does not support (an earlier revision set it here and it was dropped for\n that reason; axe reports it as a critical aria-allowed-attr violation).\n - Selection state stays on the checkbox, which is a real control.\n - `aria-expanded` IS permitted here \u2014 `listitem` is a subclass of the\n abstract `section` role, and `section` supports `aria-expanded`, so\n `listitem` inherits it (axe-core's own role table lists it in\n `listitem`'s allowedAttrs alongside aria-level/posinset/setsize). So the\n card carries it whenever the card itself acts as the expand trigger,\n matching what table mode does for its rows. The \"Details\" button reports\n the same state unconditionally, because it is the control a screen-reader\n user actually tabs to \u2014 see the note above it.\n The card is deliberately NOT `role=\"button\"`: that role has presentational\n children, which would hide the card's checkbox, row actions and \"Details\"\n toggle from assistive tech entirely.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <div\n class=\"tn-table__card\"\n role=\"listitem\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__card--active]=\"isRowActive(row)\"\n [class.tn-table__card--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-current]=\"isRowActive(row) ? 'true' : null\"\n [attr.aria-expanded]=\"isCardExpandTrigger(row) ? isRowExpanded(row) : null\"\n [attr.aria-controls]=\"isCardExpandTrigger(row) && isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"onCardClick($event, row)\"\n (dblclick)=\"onCardDoubleClick($event, row)\"\n (keydown)=\"onCardKeydown($event, row)\">\n <div class=\"tn-table__card-header\">\n @if (selectable()) {\n <!--\n <div>, not <label>, and the handler is on the wrapper rather than the\n checkbox \u2014 see the toolbar's select-all above for both reasons.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__card-select\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <!--\n Named from the card's title, not \"Select row N\": with `hideLabel` this is the\n checkbox's entire accessible name, and a card list has no visible row numbers\n to match it against. Falls back to the index when the title column renders\n derived data through a `tnCellDef` and `getCellValue` has nothing to return \u2014\n the same case `getCardLabel()` already falls back for.\n -->\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"\n 'Select ' + (getCellValue(row, cardTitleColumn()) ?? 'row ' + (rowIdx + 1))\n \"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </div>\n }\n <span class=\"tn-table__card-title\">\n @if (getColumnDef(cardTitleColumn())?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: cardTitleColumn() }\" />\n } @else {\n {{ getCellValue(row, cardTitleColumn()) }}\n }\n </span>\n @if (rowActionsDef()) {\n <div class=\"tn-table__card-actions\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n </div>\n\n <dl class=\"tn-table__card-fields\">\n @for (column of cardPrimaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n\n <!--\n The disclosure sits outside the primary <dl> and wraps its fields in a\n <dl> of its own: <details> is not a permitted child of <dl>, so nesting\n it there detached the folded dt/dd pairs from any description list.\n -->\n @if (cardSecondaryColumns().length > 0) {\n <details class=\"tn-table__card-more\">\n <summary class=\"tn-table__card-more-summary\">\n <span>{{ labels().moreFields }}</span>\n <tn-icon size=\"sm\" name=\"mat-keyboard_arrow_down\" aria-hidden=\"true\" />\n </summary>\n <dl class=\"tn-table__card-fields\">\n @for (column of cardSecondaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n </details>\n }\n\n @if (canExpandRow(row) && detailRowDef()) {\n <!--\n The button always carries the state, even when the card is also a trigger.\n An earlier revision suppressed it here to avoid announcing the same panel\n twice, but that muted the primary control: this button is what a\n screen-reader user reaches by tabbing through controls, and it was\n announced as a bare \"Details, button\" with no expanded state, before or\n after activation. The state sat on the `role=\"listitem\"` card, heard only\n if the user happened to focus the card itself.\n\n Two controls that each toggle the same region and each report its state is\n the disclosure pattern applied twice \u2014 redundant, not invalid. Redundancy\n is the cheaper failure.\n -->\n <button\n type=\"button\"\n class=\"tn-table__card-detail-toggle\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"toggleRowExpansion(row); $event.stopPropagation()\">\n <span>{{ labels().details }}</span>\n <tn-icon\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n @if (isRowExpanded(row)) {\n <div class=\"tn-table__card-detail\" [attr.id]=\"detailPanelId(rowIdx)\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n }\n </div>\n }\n </div>\n} @else {\n <table\n class=\"tn-table__table\"\n [style.min-width]=\"resolvedMinWidth()\"\n [attr.inert]=\"loading() ? '' : null\">\n <!-- Header Row -->\n <thead class=\"tn-table__header\">\n <tr class=\"tn-table__header-row\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <!--\n A plain <th>, so it keeps its implicit `columnheader` role. It used to\n carry `role=\"checkbox\"` and `tabindex=\"0\"` of its own, with the real\n checkbox inside it: two tab stops for one action, a widget nested in a\n widget (axe `nested-interactive`), and a selection column that was no\n longer a header at all (#236).\n\n The cell is now only the hit area \u2014 the same arrangement card mode has\n always used, and the same `(change)` + hit-area pair as the row cells\n below. `onSelectAllHitAreaClick` stands down when the click started\n inside the checkbox, which activates itself.\n\n Enter is bound because the removed <th> handled it and a native checkbox\n does not; Space needs no binding, being the checkbox's own key.\n\n `disabled` on an empty table is not decoration \u2014 see `canSelectAll`.\n -->\n <th class=\"tn-table__header-cell tn-table__select-cell\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\"\n (keydown.enter)=\"onSelectAllEnter($event)\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().expand }}</span>\n </th>\n } @else {\n <th\n class=\"tn-table__header-cell\"\n [class.tn-table__header-cell--sortable]=\"getColumnDef(column)?.sortable()\"\n [class.tn-table__header-cell--sorted]=\"isSorted(column)\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.aria-sort]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'ascending' : 'descending')\n : (getColumnDef(column)?.sortable() ? 'none' : null)\n \"\n [attr.tabindex]=\"getColumnDef(column)?.sortable() ? 0 : null\"\n [attr.data-column]=\"column\"\n (click)=\"onSortHeaderClick(column, $event)\"\n (keydown.enter)=\"onSortHeaderKeydown(column, $event)\"\n (keydown.space)=\"onSortHeaderKeydown(column, $event)\">\n <span class=\"tn-table__sort-container\">\n @if (getColumnDef(column)?.hideLabel()) {\n <!--\n A deliberately blank header still has to be announced: a <th>\n with no text fails axe `empty-table-header`, and the column\n goes unnamed for anyone reading the header row (#246). The\n hidden span is the same pattern the built-in `__expand` and\n row-actions headers use, and it lives here rather than in a\n consumer's `tnHeaderCellDef` because `.cdk-visually-hidden`\n is defined in this component's own stylesheet \u2014 projected\n markup carries the host's encapsulation attribute and would\n render the label visibly.\n -->\n <span class=\"cdk-visually-hidden\">\n {{ getColumnDef(column)?.label() ?? column }}\n </span>\n } @else {\n <span class=\"tn-table__header-text\">\n @if (getColumnDef(column)?.headerTemplate(); as tmpl) {\n <ng-container [ngTemplateOutlet]=\"tmpl\" />\n } @else {\n {{ getColumnDef(column)?.label() ?? column }}\n }\n </span>\n }\n @if (getColumnDef(column)?.sortable()) {\n <!--\n aria-hidden: the <th> has no aria-label, so its accessible name is\n computed from its contents \u2014 without this, every sortable column\n announced as \"Name mat-unfold_more\". `aria-sort` on the <th> already\n carries the state properly, so the token is pure noise.\n -->\n <tn-icon\n class=\"tn-table__sort-icon\"\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'mat-arrow_upward' : 'mat-arrow_downward')\n : 'mat-unfold_more'\n \" />\n }\n </span>\n </th>\n }\n }\n @if (rowActionsDef()) {\n <th class=\"tn-table__header-cell tn-table__actions-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().actions }}</span>\n </th>\n }\n </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\n <!--\n No `aria-expanded` here, unlike the card. `row` supports it only inside a\n `treegrid`; on a plain `table` it is ignored, so the state it appeared to\n publish reached nobody, while reading as though the work were done (axe\n `aria-conditional-attr`, #246). The chevron in the `__expand` cell is the\n control that carries it \u2014 a `button`, where the attribute is valid and\n where a screen-reader user lands by tabbing. `aria-controls` is global\n rather than role-conditional, so the row keeps pointing at the panel it\n opens; that is the half of the association a row CAN hold.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <tr\n class=\"tn-table__row\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__row--expandable]=\"canExpandRow(row)\"\n [class.tn-table__row--expanded]=\"canExpandRow(row) && isRowExpanded(row)\"\n [class.tn-table__row--active]=\"isRowActive(row)\"\n [class.tn-table__row--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-selected]=\"clickable() ? isRowActive(row) : null\"\n [attr.aria-controls]=\"isRowExpandTrigger(row) && isRowExpanded(row) && detailRowDef()\n ? detailPanelId(rowIdx)\n : null\"\n (click)=\"onRowClick(row, $event)\"\n (dblclick)=\"onRowDoubleClick(row, $event)\"\n (keydown)=\"onRowKeydown($event, row)\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <td class=\"tn-table__cell tn-table__select-cell\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </td>\n } @else if (column === '__expand') {\n <td class=\"tn-table__cell tn-table__expand-cell\"\n (click)=\"$event.stopPropagation()\">\n @if (canExpandRow(row)) {\n <button\n type=\"button\"\n class=\"tn-table__expand-button\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n [attr.aria-label]=\"isRowExpanded(row) ? labels().collapseRow : labels().expandRow\"\n (click)=\"toggleRowExpansion(row)\">\n <tn-icon\n class=\"tn-table__expand-icon\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n }\n </td>\n } @else {\n <td\n class=\"tn-table__cell\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.data-column]=\"column\">\n <div class=\"tn-table__cell-content\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n <span>{{ getCellValue(row, column) }}</span>\n }\n </div>\n </td>\n }\n }\n @if (rowActionsDef()) {\n <td class=\"tn-table__cell tn-table__actions-cell\"\n (click)=\"$event.stopPropagation()\">\n <div class=\"tn-table__actions-content\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n </td>\n }\n </tr>\n\n <!-- Detail / Expanded Row -->\n @if (detailRowDef() && canExpandRow(row) && isRowExpanded(row)) {\n <tr class=\"tn-table__detail-row\" [@detailExpand]=\"'expanded'\">\n <td\n class=\"tn-table__detail-cell\"\n [attr.id]=\"detailPanelId(rowIdx)\"\n [attr.colspan]=\"totalColumnCount()\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </td>\n </tr>\n }\n }\n </tbody>\n </table>\n}\n\n@if (data().length === 0 && !loading()) {\n <tn-empty\n size=\"compact\"\n [title]=\"emptyMessage()\"\n [description]=\"emptyDescription()\"\n [icon]=\"emptyIcon()\" />\n}\n\n@if (loading()) {\n <div\n class=\"tn-table__loading-overlay\"\n role=\"status\"\n aria-live=\"polite\"\n [attr.aria-label]=\"loadingMessage()\">\n <tn-spinner [ariaLabel]=\"loadingMessage()\" />\n </div>\n}\n", styles: [":host{display:block;position:relative;width:100%;overflow-x:auto;border-radius:4px}:host{--tn-table-select-width: 72px;--tn-table-actions-width: 128px;--tn-table-expand-width: 56px}.cdk-visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}:host(.tn-table--fixed-layout) table{table-layout:fixed;width:100%}:host(.tn-table--fixed-layout) .tn-table__actions-cell{box-sizing:border-box;width:var(--tn-table-actions-width)}:host(.tn-table--bordered){border:1px solid var(--tn-lines);border-radius:4px}:host(.tn-table--loading) .tn-table__table,:host(.tn-table--loading) .tn-table__cards,:host(.tn-table--loading) .tn-table__cards-toolbar{pointer-events:none;-webkit-user-select:none;user-select:none}:host(.tn-table--scroll) .tn-table__select-cell{position:sticky;left:0}:host(.tn-table--scroll) .tn-table__header-cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{position:sticky;left:0;z-index:1}:host(.tn-table--scroll) .tn-table__actions-cell{position:sticky;right:0;z-index:3}:host(.tn-table--scroll) .tn-table__header-row>.tn-table__header-cell.tn-table__select-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell.tn-table__select-cell{z-index:2}:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{left:var(--tn-table-select-width)}:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{background-color:inherit}:host(.tn-table--scroll) .tn-table__header-row .tn-table__header-cell:first-child:not(.tn-table__header-cell--sortable:hover),:host(.tn-table--scroll) .tn-table__header-row .tn-table__select-cell+.tn-table__header-cell:not(.tn-table__header-cell--sortable:hover){background-color:var(--tn-topbar)}.tn-table__table{width:100%;border-collapse:collapse;border-spacing:0;background-color:var(--tn-bg2)}.tn-table__header{background-color:var(--tn-topbar);color:var(--tn-topbar-txt)}.tn-table__header-row{height:56px}.tn-table__header-cell{padding:0 16px;text-align:left;font-weight:600;font-size:14px;border-bottom:1px solid var(--tn-lines);white-space:nowrap;vertical-align:middle}.tn-table__header-cell--sortable{cursor:pointer;-webkit-user-select:none;user-select:none}.tn-table__header-cell--sortable:hover{background-color:var(--tn-topbar-hover)}.tn-table__sort-container{display:inline-flex;align-items:center;gap:4px}.tn-table__sort-icon{opacity:0;transition:opacity .2s ease}.tn-table__header-cell--sortable:hover .tn-table__sort-icon,.tn-table__header-cell--sortable:focus-visible .tn-table__sort-icon{opacity:.5}.tn-table__header-cell--sorted .tn-table__sort-icon{opacity:1}.tn-table__body{background-color:var(--tn-bg2)}.tn-table__row{height:48px;transition:background-color .2s ease;background-color:var(--tn-bg2)}.tn-table__row:hover{background-color:var(--tn-alt-bg1)}.tn-table__row:not(:last-child){border-bottom:1px solid var(--tn-lines)}.tn-table__row--expanded{background-color:var(--tn-alt-bg1)}.tn-table__row--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333))}.tn-table__row--active>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__row--clickable{cursor:pointer}.tn-table__row--clickable:focus-visible{outline:none}.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-primary),inset 0 0 0 2px var(--tn-primary)}.tn-table__row--active.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__cell{padding:0 16px;font-size:14px;color:var(--tn-fg1);vertical-align:middle;overflow:hidden}.tn-table__cell-content{display:flex;align-items:center;min-height:24px;white-space:normal;overflow-wrap:anywhere;overflow:hidden;padding:4px;margin:-4px}.tn-table__cell-content>*{min-width:0}.tn-table__select-cell{box-sizing:border-box;width:var(--tn-table-select-width);padding:0 8px 0 16px;cursor:pointer}.tn-table__expand-cell{box-sizing:border-box;width:var(--tn-table-expand-width);padding:0 16px 0 8px;text-align:center}.tn-table__expand-button{background:none;border:none;padding:4px;cursor:pointer;border-radius:4px;display:inline-flex;align-items:center;color:var(--tn-fg2)}.tn-table__expand-button:hover{background-color:var(--tn-alt-bg1)}.tn-table__expand-button:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__expand-icon{transition:transform .2s ease}.tn-table__detail-row{background-color:var(--tn-alt-bg1);border-bottom:1px solid var(--tn-lines)}.tn-table__detail-cell{padding:16px}.tn-table__actions-cell{width:1%;white-space:nowrap;text-align:right;background-color:inherit;border-left:1px solid var(--tn-lines)}.tn-table__header .tn-table__actions-cell{background-color:var(--tn-topbar)}.tn-table__actions-content{display:inline-flex;align-items:center;gap:4px}.tn-table__loading-overlay{position:absolute;inset:0;z-index:4;display:flex;align-items:center;justify-content:center;background-color:color-mix(in srgb,var(--tn-bg2, #282828) 65%,transparent);-webkit-backdrop-filter:blur(1px);backdrop-filter:blur(1px);pointer-events:all;border-radius:4px}.tn-table--dense .tn-table__header-row{height:40px}.tn-table--dense .tn-table__row{height:32px}.tn-table--dense .tn-table__header-cell,.tn-table--dense .tn-table__cell{padding:0 12px;font-size:13px}.tn-table__cards-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 4px 12px}.tn-table__cards-selectall{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--tn-fg1);cursor:pointer}.tn-table__cards-sort{display:inline-flex;align-items:center;gap:6px;margin-left:auto;color:var(--tn-fg2)}.tn-table__cards-sort-select{height:32px;padding:0 8px;border:1px solid var(--tn-lines);border-radius:4px;background-color:var(--tn-bg2);color:var(--tn-fg1);font-size:14px}.tn-table__cards-sort-select:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards-sort-dir{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid var(--tn-lines);border-radius:4px;background:none;color:var(--tn-fg2);cursor:pointer}.tn-table__cards-sort-dir:hover{background-color:var(--tn-alt-bg1)}.tn-table__cards-sort-dir:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards{display:flex;flex-direction:column;gap:8px}.tn-table__card{background-color:var(--tn-bg2);border:1px solid var(--tn-lines);border-radius:6px;padding:12px}.tn-table__card--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333));box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__card--clickable{cursor:pointer}.tn-table__card--clickable:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--tn-primary)}.tn-table__card--active.tn-table__card--clickable:focus-visible{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__card-header{display:flex;align-items:center;gap:10px}.tn-table__card-select{display:inline-flex;align-items:center;flex-shrink:0;cursor:pointer}.tn-table__card-title{flex:1;min-width:0;font-weight:600;font-size:15px;color:var(--tn-fg1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-table__card-actions{display:inline-flex;align-items:center;gap:4px;flex-shrink:0}.tn-table__card-fields{margin:0;padding:10px 0 0}.tn-table__card-field{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:3px 0;font-size:13px}.tn-table__card-field-label{flex-shrink:0;color:var(--tn-fg2)}.tn-table__card-field-value{margin:0;min-width:0;text-align:right;color:var(--tn-fg1);overflow-wrap:anywhere}.tn-table__card-more[open]>.tn-table__card-more-summary tn-icon{transform:rotate(180deg)}.tn-table__card-more{margin-top:2px}.tn-table__card-more-summary{display:inline-flex;align-items:center;gap:4px;padding:4px 0;font-size:13px;color:var(--tn-fg1);cursor:pointer;list-style:none}.tn-table__card-more-summary::-webkit-details-marker{display:none}.tn-table__card-more-summary tn-icon{transition:transform .2s ease}.tn-table__card-more-summary:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail-toggle{display:inline-flex;align-items:center;gap:4px;margin-top:8px;padding:4px 0;background:none;border:none;color:var(--tn-fg1);font-size:13px;cursor:pointer}.tn-table__card-detail-toggle:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail{margin-top:8px;padding-top:8px;border-top:1px solid var(--tn-lines)}@media(max-width:768px){.tn-table__table{font-size:12px}.tn-table__header-cell,.tn-table__cell{padding:0 8px}}@media(prefers-reduced-motion:reduce){.tn-table__card-more-summary tn-icon,.tn-table__sort-icon,.tn-table__expand-icon,.tn-table__row{transition:none}.tn-table__detail-row{animation:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "component", type: TnEmptyComponent, selector: "tn-empty", inputs: ["title", "description", "icon", "iconLibrary", "iconSize", "actionText", "bordered", "size"], outputs: ["onAction"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "component", type: TnSpinnerComponent, selector: "tn-spinner", inputs: ["mode", "value", "diameter", "strokeWidth", "ariaLabel", "ariaLabelledby"] }], animations: [
17631
19007
  trigger('detailExpand', [
17632
19008
  state('collapsed,void', style({ height: '0px', minHeight: '0', overflow: 'hidden' })),
17633
19009
  state('expanded', style({ height: '*' })),
@@ -17664,7 +19040,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
17664
19040
  '[attr.role]': 'scrollKeyboardReachable() ? "group" : null',
17665
19041
  '[attr.aria-label]': 'scrollKeyboardReachable() ? resolvedScrollRegionLabel() : null',
17666
19042
  }, template: "@if (isCardMode()) {\n <!-- Card layout (narrow container) -->\n @if (selectable() || sortableColumns().length > 0) {\n <div class=\"tn-table__cards-toolbar\" [attr.inert]=\"loading() ? '' : null\">\n @if (selectable()) {\n <!--\n A plain <div>, not a <label>: tn-checkbox renders its own <label for>,\n and nesting one label inside another is invalid. The visible text is\n passed through the checkbox's own `label` input so it stays associated.\n\n Two activation paths, deliberately, and they are mutually exclusive. The\n checkbox activates itself and reports through `(change)`; this wrapper is\n only the hit area around it, so its handler stands down for anything that\n started inside the checkbox \u2014 see `onSelectAllHitAreaClick`. Until #236 the\n wrapper was the only path, because `.tn-table__checkbox` was\n `pointer-events: none` and no real click could reach the control; that\n shape is what forced a focusable `role=\"checkbox\"` onto the table header's\n <th>, nesting one widget inside another.\n\n No `preventDefault()` here any more, and that is load-bearing rather than\n tidiness: a click on the checkbox's own <label> bubbles to this wrapper, and\n cancelling it cancels the label's default action \u2014 which is how the label\n activates the input.\n\n The click/key and focusable lint rules are suppressed rather than satisfied:\n this wrapper is a pointer hit area around a control that is already\n focusable and already handles Space. Giving it `tabindex` and a key handler\n would add a second tab stop and a second activation path for the same\n checkbox, which is worse than the rule it silences.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__cards-selectall\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\" />\n </div>\n }\n @if (sortableColumns().length > 0) {\n <div class=\"tn-table__cards-sort\">\n <tn-icon name=\"mat-sort\" size=\"sm\" aria-hidden=\"true\" />\n <select\n class=\"tn-table__cards-sort-select\"\n [attr.aria-label]=\"labels().sortBy\"\n [value]=\"sortColumn()\"\n (change)=\"onSortSelectChange($event)\">\n <!--\n `[selected]` on the options, not just `[value]` on the <select>:\n the select's value is bound before the @for options exist, so the\n browser discards it and the binding is never dirty again \u2014 a table\n sorted before card mode engaged would render as \"Unsorted\".\n -->\n <option value=\"\" [selected]=\"!sortColumn()\">{{ labels().unsorted }}</option>\n @for (column of sortableColumns(); track column) {\n <option [value]=\"column\" [selected]=\"column === sortColumn()\">\n {{ getCardLabel(column) }}\n </option>\n }\n </select>\n @if (canSortActiveColumn()) {\n <!--\n Gated on the column being genuinely sortable, not merely active:\n `sortableColumns` keeps a non-sortable active column listed so the sort stays\n clearable, and this toggle must not ride along \u2014 card mode would otherwise\n reorder by a column whose table header ignores clicks.\n\n Three states, not two. A column with an empty direction is not sorted \u2014\n reachable through the two-way binding \u2014 and this control used to render an\n up arrow labelled \"Sort descending\" for it, while table mode showed no\n arrow and no `aria-sort` for the same state. It keeps rendering, because\n it is how the user escapes that state, but shows the neutral\n `mat-unfold_more` the table header already uses for unsorted columns.\n\n The label names the action, so it is `sortAscending` both when unsorted\n and when descending \u2014 `toggleSortDirection()` starts at ascending.\n -->\n <button\n type=\"button\"\n class=\"tn-table__cards-sort-dir\"\n [attr.aria-label]=\"sortDirection() === 'asc' ? labels().sortDescending : labels().sortAscending\"\n [attr.data-sort-direction]=\"sortDirection()\"\n (click)=\"toggleSortDirection()\">\n @if (sortDirection() === 'asc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_upward\" aria-hidden=\"true\" />\n } @else if (sortDirection() === 'desc') {\n <tn-icon size=\"sm\" name=\"mat-arrow_downward\" aria-hidden=\"true\" />\n } @else {\n <tn-icon size=\"sm\" name=\"mat-unfold_more\" aria-hidden=\"true\" />\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!--\n Decorative `tn-icon`s carry `aria-hidden=\"true\"`. tn-icon renders `role=\"img\"` and\n falls back to its `name` for the accessible name, so without this the raw token\n leaks: a standalone \"mat-sort\" image beside Select all, and disclosures announced\n as \"More fields mat-keyboard_arrow_down\". The repo convention is the same \u2014 see\n button, card, input and file-picker-popup.\n -->\n <ng-template #cardField let-column let-row=\"row\">\n <div class=\"tn-table__card-field\" [attr.data-column]=\"column\">\n <dt class=\"tn-table__card-field-label\">{{ getCardLabel(column) }}</dt>\n <dd class=\"tn-table__card-field-value\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n {{ getCellValue(row, column) }}\n }\n </dd>\n </div>\n </ng-template>\n\n <div class=\"tn-table__cards\" role=\"list\" [attr.inert]=\"loading() ? '' : null\">\n <!--\n Card ARIA differs from the table row on purpose, because `listitem` supports\n far less than `row` does:\n - Active state uses `aria-current`, not `aria-selected`, which `listitem`\n does not support (an earlier revision set it here and it was dropped for\n that reason; axe reports it as a critical aria-allowed-attr violation).\n - Selection state stays on the checkbox, which is a real control.\n - `aria-expanded` IS permitted here \u2014 `listitem` is a subclass of the\n abstract `section` role, and `section` supports `aria-expanded`, so\n `listitem` inherits it (axe-core's own role table lists it in\n `listitem`'s allowedAttrs alongside aria-level/posinset/setsize). So the\n card carries it whenever the card itself acts as the expand trigger,\n matching what table mode does for its rows. The \"Details\" button reports\n the same state unconditionally, because it is the control a screen-reader\n user actually tabs to \u2014 see the note above it.\n The card is deliberately NOT `role=\"button\"`: that role has presentational\n children, which would hide the card's checkbox, row actions and \"Details\"\n toggle from assistive tech entirely.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <div\n class=\"tn-table__card\"\n role=\"listitem\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__card--active]=\"isRowActive(row)\"\n [class.tn-table__card--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-current]=\"isRowActive(row) ? 'true' : null\"\n [attr.aria-expanded]=\"isCardExpandTrigger(row) ? isRowExpanded(row) : null\"\n [attr.aria-controls]=\"isCardExpandTrigger(row) && isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"onCardClick($event, row)\"\n (dblclick)=\"onCardDoubleClick($event, row)\"\n (keydown)=\"onCardKeydown($event, row)\">\n <div class=\"tn-table__card-header\">\n @if (selectable()) {\n <!--\n <div>, not <label>, and the handler is on the wrapper rather than the\n checkbox \u2014 see the toolbar's select-all above for both reasons.\n -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n <div\n class=\"tn-table__card-select\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <!--\n Named from the card's title, not \"Select row N\": with `hideLabel` this is the\n checkbox's entire accessible name, and a card list has no visible row numbers\n to match it against. Falls back to the index when the title column renders\n derived data through a `tnCellDef` and `getCellValue` has nothing to return \u2014\n the same case `getCardLabel()` already falls back for.\n -->\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"\n 'Select ' + (getCellValue(row, cardTitleColumn()) ?? 'row ' + (rowIdx + 1))\n \"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </div>\n }\n <span class=\"tn-table__card-title\">\n @if (getColumnDef(cardTitleColumn())?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: cardTitleColumn() }\" />\n } @else {\n {{ getCellValue(row, cardTitleColumn()) }}\n }\n </span>\n @if (rowActionsDef()) {\n <div class=\"tn-table__card-actions\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n </div>\n\n <dl class=\"tn-table__card-fields\">\n @for (column of cardPrimaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n\n <!--\n The disclosure sits outside the primary <dl> and wraps its fields in a\n <dl> of its own: <details> is not a permitted child of <dl>, so nesting\n it there detached the folded dt/dd pairs from any description list.\n -->\n @if (cardSecondaryColumns().length > 0) {\n <details class=\"tn-table__card-more\">\n <summary class=\"tn-table__card-more-summary\">\n <span>{{ labels().moreFields }}</span>\n <tn-icon size=\"sm\" name=\"mat-keyboard_arrow_down\" aria-hidden=\"true\" />\n </summary>\n <dl class=\"tn-table__card-fields\">\n @for (column of cardSecondaryColumns(); track column) {\n <ng-container\n [ngTemplateOutlet]=\"cardField\"\n [ngTemplateOutletContext]=\"{ $implicit: column, row: row }\" />\n }\n </dl>\n </details>\n }\n\n @if (canExpandRow(row) && detailRowDef()) {\n <!--\n The button always carries the state, even when the card is also a trigger.\n An earlier revision suppressed it here to avoid announcing the same panel\n twice, but that muted the primary control: this button is what a\n screen-reader user reaches by tabbing through controls, and it was\n announced as a bare \"Details, button\" with no expanded state, before or\n after activation. The state sat on the `role=\"listitem\"` card, heard only\n if the user happened to focus the card itself.\n\n Two controls that each toggle the same region and each report its state is\n the disclosure pattern applied twice \u2014 redundant, not invalid. Redundancy\n is the cheaper failure.\n -->\n <button\n type=\"button\"\n class=\"tn-table__card-detail-toggle\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n (click)=\"toggleRowExpansion(row); $event.stopPropagation()\">\n <span>{{ labels().details }}</span>\n <tn-icon\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n @if (isRowExpanded(row)) {\n <div class=\"tn-table__card-detail\" [attr.id]=\"detailPanelId(rowIdx)\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n }\n }\n </div>\n }\n </div>\n} @else {\n <table\n class=\"tn-table__table\"\n [style.min-width]=\"resolvedMinWidth()\"\n [attr.inert]=\"loading() ? '' : null\">\n <!-- Header Row -->\n <thead class=\"tn-table__header\">\n <tr class=\"tn-table__header-row\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <!--\n A plain <th>, so it keeps its implicit `columnheader` role. It used to\n carry `role=\"checkbox\"` and `tabindex=\"0\"` of its own, with the real\n checkbox inside it: two tab stops for one action, a widget nested in a\n widget (axe `nested-interactive`), and a selection column that was no\n longer a header at all (#236).\n\n The cell is now only the hit area \u2014 the same arrangement card mode has\n always used, and the same `(change)` + hit-area pair as the row cells\n below. `onSelectAllHitAreaClick` stands down when the click started\n inside the checkbox, which activates itself.\n\n Enter is bound because the removed <th> handled it and a native checkbox\n does not; Space needs no binding, being the checkbox's own key.\n\n `disabled` on an empty table is not decoration \u2014 see `canSelectAll`.\n -->\n <th class=\"tn-table__header-cell tn-table__select-cell\"\n (click)=\"onSelectAllHitAreaClick($event)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [disabled]=\"!canSelectAll()\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\"\n (change)=\"toggleSelectAll()\"\n (keydown.enter)=\"onSelectAllEnter($event)\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().expand }}</span>\n </th>\n } @else {\n <th\n class=\"tn-table__header-cell\"\n [class.tn-table__header-cell--sortable]=\"getColumnDef(column)?.sortable()\"\n [class.tn-table__header-cell--sorted]=\"isSorted(column)\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.aria-sort]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'ascending' : 'descending')\n : (getColumnDef(column)?.sortable() ? 'none' : null)\n \"\n [attr.tabindex]=\"getColumnDef(column)?.sortable() ? 0 : null\"\n [attr.data-column]=\"column\"\n (click)=\"onSortHeaderClick(column, $event)\"\n (keydown.enter)=\"onSortHeaderKeydown(column, $event)\"\n (keydown.space)=\"onSortHeaderKeydown(column, $event)\">\n <span class=\"tn-table__sort-container\">\n @if (getColumnDef(column)?.hideLabel()) {\n <!--\n A deliberately blank header still has to be announced: a <th>\n with no text fails axe `empty-table-header`, and the column\n goes unnamed for anyone reading the header row (#246). The\n hidden span is the same pattern the built-in `__expand` and\n row-actions headers use, and it lives here rather than in a\n consumer's `tnHeaderCellDef` because `.cdk-visually-hidden`\n is defined in this component's own stylesheet \u2014 projected\n markup carries the host's encapsulation attribute and would\n render the label visibly.\n -->\n <span class=\"cdk-visually-hidden\">\n {{ getColumnDef(column)?.label() ?? column }}\n </span>\n } @else {\n <span class=\"tn-table__header-text\">\n @if (getColumnDef(column)?.headerTemplate(); as tmpl) {\n <ng-container [ngTemplateOutlet]=\"tmpl\" />\n } @else {\n {{ getColumnDef(column)?.label() ?? column }}\n }\n </span>\n }\n @if (getColumnDef(column)?.sortable()) {\n <!--\n aria-hidden: the <th> has no aria-label, so its accessible name is\n computed from its contents \u2014 without this, every sortable column\n announced as \"Name mat-unfold_more\". `aria-sort` on the <th> already\n carries the state properly, so the token is pure noise.\n -->\n <tn-icon\n class=\"tn-table__sort-icon\"\n size=\"sm\"\n aria-hidden=\"true\"\n [name]=\"\n isSorted(column)\n ? (sortDirection() === 'asc' ? 'mat-arrow_upward' : 'mat-arrow_downward')\n : 'mat-unfold_more'\n \" />\n }\n </span>\n </th>\n }\n }\n @if (rowActionsDef()) {\n <th class=\"tn-table__header-cell tn-table__actions-cell\">\n <span class=\"cdk-visually-hidden\">{{ labels().actions }}</span>\n </th>\n }\n </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\n <!--\n No `aria-expanded` here, unlike the card. `row` supports it only inside a\n `treegrid`; on a plain `table` it is ignored, so the state it appeared to\n publish reached nobody, while reading as though the work were done (axe\n `aria-conditional-attr`, #246). The chevron in the `__expand` cell is the\n control that carries it \u2014 a `button`, where the attribute is valid and\n where a screen-reader user lands by tabbing. `aria-controls` is global\n rather than role-conditional, so the row keeps pointing at the panel it\n opens; that is the half of the association a row CAN hold.\n -->\n @for (row of data(); track trackByFn()($index, row); let rowIdx = $index) {\n <tr\n class=\"tn-table__row\"\n [attr.data-row-index]=\"rowIdx\"\n [class.tn-table__row--expandable]=\"canExpandRow(row)\"\n [class.tn-table__row--expanded]=\"canExpandRow(row) && isRowExpanded(row)\"\n [class.tn-table__row--active]=\"isRowActive(row)\"\n [class.tn-table__row--clickable]=\"clickable()\"\n [attr.tabindex]=\"clickable() ? 0 : null\"\n [attr.aria-selected]=\"clickable() ? isRowActive(row) : null\"\n [attr.aria-controls]=\"isRowExpandTrigger(row) && isRowExpanded(row) && detailRowDef()\n ? detailPanelId(rowIdx)\n : null\"\n (click)=\"onRowClick(row, $event)\"\n (dblclick)=\"onRowDoubleClick(row, $event)\"\n (keydown)=\"onRowKeydown($event, row)\">\n @for (column of effectiveDisplayedColumns(); track $index) {\n @if (column === '__select') {\n <td class=\"tn-table__cell tn-table__select-cell\"\n (click)=\"onRowSelectHitAreaClick($event, row)\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(row)\"\n (change)=\"toggleRowSelection(row)\" />\n </td>\n } @else if (column === '__expand') {\n <td class=\"tn-table__cell tn-table__expand-cell\"\n (click)=\"$event.stopPropagation()\">\n @if (canExpandRow(row)) {\n <button\n type=\"button\"\n class=\"tn-table__expand-button\"\n [attr.aria-expanded]=\"isRowExpanded(row)\"\n [attr.aria-controls]=\"isRowExpanded(row) ? detailPanelId(rowIdx) : null\"\n [attr.aria-label]=\"isRowExpanded(row) ? labels().collapseRow : labels().expandRow\"\n (click)=\"toggleRowExpansion(row)\">\n <tn-icon\n class=\"tn-table__expand-icon\"\n aria-hidden=\"true\"\n [name]=\"isRowExpanded(row) ? 'mat-keyboard_arrow_up' : 'mat-keyboard_arrow_down'\" />\n </button>\n }\n </td>\n } @else {\n <td\n class=\"tn-table__cell\"\n [style.width]=\"getColumnDef(column)?.width()\"\n [attr.data-column]=\"column\">\n <div class=\"tn-table__cell-content\">\n @if (getColumnDef(column)?.cellTemplate(); as tmpl) {\n <ng-container\n [ngTemplateOutlet]=\"tmpl\"\n [ngTemplateOutletContext]=\"{ $implicit: row, column: column }\" />\n } @else {\n <span>{{ getCellValue(row, column) }}</span>\n }\n </div>\n </td>\n }\n }\n @if (rowActionsDef()) {\n <td class=\"tn-table__cell tn-table__actions-cell\"\n (click)=\"$event.stopPropagation()\">\n <div class=\"tn-table__actions-content\">\n <ng-container\n [ngTemplateOutlet]=\"rowActionsDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </div>\n </td>\n }\n </tr>\n\n <!-- Detail / Expanded Row -->\n @if (detailRowDef() && canExpandRow(row) && isRowExpanded(row)) {\n <tr class=\"tn-table__detail-row\" [@detailExpand]=\"'expanded'\">\n <td\n class=\"tn-table__detail-cell\"\n [attr.id]=\"detailPanelId(rowIdx)\"\n [attr.colspan]=\"totalColumnCount()\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </td>\n </tr>\n }\n }\n </tbody>\n </table>\n}\n\n@if (data().length === 0 && !loading()) {\n <tn-empty\n size=\"compact\"\n [title]=\"emptyMessage()\"\n [description]=\"emptyDescription()\"\n [icon]=\"emptyIcon()\" />\n}\n\n@if (loading()) {\n <div\n class=\"tn-table__loading-overlay\"\n role=\"status\"\n aria-live=\"polite\"\n [attr.aria-label]=\"loadingMessage()\">\n <tn-spinner [ariaLabel]=\"loadingMessage()\" />\n </div>\n}\n", styles: [":host{display:block;position:relative;width:100%;overflow-x:auto;border-radius:4px}:host{--tn-table-select-width: 72px;--tn-table-actions-width: 128px;--tn-table-expand-width: 56px}.cdk-visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}:host(.tn-table--fixed-layout) table{table-layout:fixed;width:100%}:host(.tn-table--fixed-layout) .tn-table__actions-cell{box-sizing:border-box;width:var(--tn-table-actions-width)}:host(.tn-table--bordered){border:1px solid var(--tn-lines);border-radius:4px}:host(.tn-table--loading) .tn-table__table,:host(.tn-table--loading) .tn-table__cards,:host(.tn-table--loading) .tn-table__cards-toolbar{pointer-events:none;-webkit-user-select:none;user-select:none}:host(.tn-table--scroll) .tn-table__select-cell{position:sticky;left:0}:host(.tn-table--scroll) .tn-table__header-cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{position:sticky;left:0;z-index:1}:host(.tn-table--scroll) .tn-table__actions-cell{position:sticky;right:0;z-index:3}:host(.tn-table--scroll) .tn-table__header-row>.tn-table__header-cell.tn-table__select-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__cell.tn-table__select-cell{z-index:2}:host(.tn-table--scroll) .tn-table__select-cell+.tn-table__header-cell,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{left:var(--tn-table-select-width)}:host(.tn-table--scroll) .tn-table__row>.tn-table__cell:first-child,:host(.tn-table--scroll) .tn-table__row>.tn-table__select-cell+.tn-table__cell{background-color:inherit}:host(.tn-table--scroll) .tn-table__header-row .tn-table__header-cell:first-child:not(.tn-table__header-cell--sortable:hover),:host(.tn-table--scroll) .tn-table__header-row .tn-table__select-cell+.tn-table__header-cell:not(.tn-table__header-cell--sortable:hover){background-color:var(--tn-topbar)}.tn-table__table{width:100%;border-collapse:collapse;border-spacing:0;background-color:var(--tn-bg2)}.tn-table__header{background-color:var(--tn-topbar);color:var(--tn-topbar-txt)}.tn-table__header-row{height:56px}.tn-table__header-cell{padding:0 16px;text-align:left;font-weight:600;font-size:14px;border-bottom:1px solid var(--tn-lines);white-space:nowrap;vertical-align:middle}.tn-table__header-cell--sortable{cursor:pointer;-webkit-user-select:none;user-select:none}.tn-table__header-cell--sortable:hover{background-color:var(--tn-topbar-hover)}.tn-table__sort-container{display:inline-flex;align-items:center;gap:4px}.tn-table__sort-icon{opacity:0;transition:opacity .2s ease}.tn-table__header-cell--sortable:hover .tn-table__sort-icon,.tn-table__header-cell--sortable:focus-visible .tn-table__sort-icon{opacity:.5}.tn-table__header-cell--sorted .tn-table__sort-icon{opacity:1}.tn-table__body{background-color:var(--tn-bg2)}.tn-table__row{height:48px;transition:background-color .2s ease;background-color:var(--tn-bg2)}.tn-table__row:hover{background-color:var(--tn-alt-bg1)}.tn-table__row:not(:last-child){border-bottom:1px solid var(--tn-lines)}.tn-table__row--expanded{background-color:var(--tn-alt-bg1)}.tn-table__row--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333))}.tn-table__row--active>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__row--clickable{cursor:pointer}.tn-table__row--clickable:focus-visible{outline:none}.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-primary),inset 0 0 0 2px var(--tn-primary)}.tn-table__row--active.tn-table__row--clickable:focus-visible>.tn-table__cell:first-child{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__cell{padding:0 16px;font-size:14px;color:var(--tn-fg1);vertical-align:middle;overflow:hidden}.tn-table__cell-content{display:flex;align-items:center;min-height:24px;white-space:normal;overflow-wrap:anywhere;overflow:hidden;padding:4px;margin:-4px}.tn-table__cell-content>*{min-width:0}.tn-table__select-cell{box-sizing:border-box;width:var(--tn-table-select-width);padding:0 8px 0 16px;cursor:pointer}.tn-table__expand-cell{box-sizing:border-box;width:var(--tn-table-expand-width);padding:0 16px 0 8px;text-align:center}.tn-table__expand-button{background:none;border:none;padding:4px;cursor:pointer;border-radius:4px;display:inline-flex;align-items:center;color:var(--tn-fg2)}.tn-table__expand-button:hover{background-color:var(--tn-alt-bg1)}.tn-table__expand-button:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__expand-icon{transition:transform .2s ease}.tn-table__detail-row{background-color:var(--tn-alt-bg1);border-bottom:1px solid var(--tn-lines)}.tn-table__detail-cell{padding:16px}.tn-table__actions-cell{width:1%;white-space:nowrap;text-align:right;background-color:inherit;border-left:1px solid var(--tn-lines)}.tn-table__header .tn-table__actions-cell{background-color:var(--tn-topbar)}.tn-table__actions-content{display:inline-flex;align-items:center;gap:4px}.tn-table__loading-overlay{position:absolute;inset:0;z-index:4;display:flex;align-items:center;justify-content:center;background-color:color-mix(in srgb,var(--tn-bg2, #282828) 65%,transparent);-webkit-backdrop-filter:blur(1px);backdrop-filter:blur(1px);pointer-events:all;border-radius:4px}.tn-table--dense .tn-table__header-row{height:40px}.tn-table--dense .tn-table__row{height:32px}.tn-table--dense .tn-table__header-cell,.tn-table--dense .tn-table__cell{padding:0 12px;font-size:13px}.tn-table__cards-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 4px 12px}.tn-table__cards-selectall{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--tn-fg1);cursor:pointer}.tn-table__cards-sort{display:inline-flex;align-items:center;gap:6px;margin-left:auto;color:var(--tn-fg2)}.tn-table__cards-sort-select{height:32px;padding:0 8px;border:1px solid var(--tn-lines);border-radius:4px;background-color:var(--tn-bg2);color:var(--tn-fg1);font-size:14px}.tn-table__cards-sort-select:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards-sort-dir{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid var(--tn-lines);border-radius:4px;background:none;color:var(--tn-fg2);cursor:pointer}.tn-table__cards-sort-dir:hover{background-color:var(--tn-alt-bg1)}.tn-table__cards-sort-dir:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__cards{display:flex;flex-direction:column;gap:8px}.tn-table__card{background-color:var(--tn-bg2);border:1px solid var(--tn-lines);border-radius:6px;padding:12px}.tn-table__card--active{background-color:var(--tn-table-active-bg, var(--tn-bg3, #333333));box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3))}.tn-table__card--clickable{cursor:pointer}.tn-table__card--clickable:focus-visible{outline:none;box-shadow:inset 0 0 0 2px var(--tn-primary)}.tn-table__card--active.tn-table__card--clickable:focus-visible{box-shadow:inset 3px 0 0 0 var(--tn-table-active-indicator, var(--tn-primary, #007db3)),inset 0 0 0 2px var(--tn-primary)}.tn-table__card-header{display:flex;align-items:center;gap:10px}.tn-table__card-select{display:inline-flex;align-items:center;flex-shrink:0;cursor:pointer}.tn-table__card-title{flex:1;min-width:0;font-weight:600;font-size:15px;color:var(--tn-fg1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tn-table__card-actions{display:inline-flex;align-items:center;gap:4px;flex-shrink:0}.tn-table__card-fields{margin:0;padding:10px 0 0}.tn-table__card-field{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:3px 0;font-size:13px}.tn-table__card-field-label{flex-shrink:0;color:var(--tn-fg2)}.tn-table__card-field-value{margin:0;min-width:0;text-align:right;color:var(--tn-fg1);overflow-wrap:anywhere}.tn-table__card-more[open]>.tn-table__card-more-summary tn-icon{transform:rotate(180deg)}.tn-table__card-more{margin-top:2px}.tn-table__card-more-summary{display:inline-flex;align-items:center;gap:4px;padding:4px 0;font-size:13px;color:var(--tn-fg1);cursor:pointer;list-style:none}.tn-table__card-more-summary::-webkit-details-marker{display:none}.tn-table__card-more-summary tn-icon{transition:transform .2s ease}.tn-table__card-more-summary:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail-toggle{display:inline-flex;align-items:center;gap:4px;margin-top:8px;padding:4px 0;background:none;border:none;color:var(--tn-fg1);font-size:13px;cursor:pointer}.tn-table__card-detail-toggle:focus-visible{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-table__card-detail{margin-top:8px;padding-top:8px;border-top:1px solid var(--tn-lines)}@media(max-width:768px){.tn-table__table{font-size:12px}.tn-table__header-cell,.tn-table__cell{padding:0 8px}}@media(prefers-reduced-motion:reduce){.tn-table__card-more-summary tn-icon,.tn-table__sort-icon,.tn-table__expand-icon,.tn-table__row{transition:none}.tn-table__detail-row{animation:none}}\n"] }]
17667
- }], ctorParameters: () => [], propDecorators: { dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], displayedColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayedColumns", required: false }] }], trackBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackBy", required: false }] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }], emptyDescription: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyDescription", required: false }] }], emptyIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyIcon", required: false }] }], scrollRegionAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollRegionAriaLabel", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], expandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandable", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], isRowExpandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "isRowExpandable", required: false }] }], activeRow: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeRow", required: false }] }], activeWhen: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeWhen", required: false }] }], activeBg: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeBg", required: false }] }], activeIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeIndicator", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingMessage", required: false }] }], clickable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clickable", required: false }] }], expandOnRowClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandOnRowClick", required: false }] }], singleExpand: [{ type: i0.Input, args: [{ isSignal: true, alias: "singleExpand", required: false }] }], fixedLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "fixedLayout", required: false }] }], minColumnWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minColumnWidth", required: false }] }], minWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minWidth", required: false }] }], mobileLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileLayout", required: false }] }], cardBreakpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardBreakpoint", required: false }] }], cardPrimaryCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardPrimaryCount", required: false }] }], sortChange: [{ type: i0.Output, args: ["sortChange"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], rowClick: [{ type: i0.Output, args: ["rowClick"] }], rowDoubleClick: [{ type: i0.Output, args: ["rowDoubleClick"] }], columnDefs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => TnTableColumnDirective), { isSignal: true }] }], detailRowDef: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnDetailRowDefDirective), { isSignal: true }] }], rowActionsDef: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnRowActionsDefDirective), { isSignal: true }] }], sortColumn: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortColumn", required: false }] }, { type: i0.Output, args: ["sortColumnChange"] }], sortDirection: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortDirection", required: false }] }, { type: i0.Output, args: ["sortDirectionChange"] }] } });
19043
+ }], ctorParameters: () => [], propDecorators: { dataSource: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataSource", required: false }] }], displayedColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayedColumns", required: false }] }], trackBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackBy", required: false }] }], selectionKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectionKey", required: false }] }], expansionKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "expansionKey", required: false }] }], emptyMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyMessage", required: false }] }], emptyDescription: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyDescription", required: false }] }], emptyIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyIcon", required: false }] }], scrollRegionAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollRegionAriaLabel", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], expandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandable", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], isRowExpandable: [{ type: i0.Input, args: [{ isSignal: true, alias: "isRowExpandable", required: false }] }], activeRow: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeRow", required: false }] }], activeWhen: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeWhen", required: false }] }], activeBg: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeBg", required: false }] }], activeIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeIndicator", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], loadingMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "loadingMessage", required: false }] }], clickable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clickable", required: false }] }], expandOnRowClick: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandOnRowClick", required: false }] }], singleExpand: [{ type: i0.Input, args: [{ isSignal: true, alias: "singleExpand", required: false }] }], fixedLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "fixedLayout", required: false }] }], minColumnWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minColumnWidth", required: false }] }], minWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minWidth", required: false }] }], mobileLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileLayout", required: false }] }], cardBreakpoint: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardBreakpoint", required: false }] }], cardPrimaryCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardPrimaryCount", required: false }] }], sortChange: [{ type: i0.Output, args: ["sortChange"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], rowClick: [{ type: i0.Output, args: ["rowClick"] }], rowDoubleClick: [{ type: i0.Output, args: ["rowDoubleClick"] }], columnDefs: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => TnTableColumnDirective), { isSignal: true }] }], detailRowDef: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnDetailRowDefDirective), { isSignal: true }] }], rowActionsDef: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnRowActionsDefDirective), { isSignal: true }] }], sortColumn: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortColumn", required: false }] }, { type: i0.Output, args: ["sortColumnChange"] }], sortDirection: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortDirection", required: false }] }, { type: i0.Output, args: ["sortDirectionChange"] }] } });
17668
19044
 
17669
19045
  /**
17670
19046
  * Harness for interacting with `tn-table` in tests.
@@ -18821,7 +20197,12 @@ class TnTablePagerComponent {
18821
20197
  pageSize: this.pageSize(),
18822
20198
  };
18823
20199
  this.lastPushedPagination = pagination;
18824
- provider.setPagination(pagination);
20200
+ // The provider gets its OWN object. Handing it this one aliases the echo guard to
20201
+ // the provider's live pagination, and a provider that re-paginates in place — a
20202
+ // filter that resets to page 1, say — silently rewrites what we think we pushed.
20203
+ // The guard then reads the provider's own change back as our echo and leaves the
20204
+ // pager showing a page the provider is no longer serving.
20205
+ provider.setPagination({ ...pagination });
18825
20206
  }
18826
20207
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
18827
20208
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTablePagerComponent, isStandalone: true, selector: "tn-table-pager", inputs: { testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, currentPage: { classPropertyName: "currentPage", publicName: "currentPage", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, totalItems: { classPropertyName: "totalItems", publicName: "totalItems", isSignal: true, isRequired: false, transformFunction: null }, dataProvider: { classPropertyName: "dataProvider", publicName: "dataProvider", isSignal: true, isRequired: false, transformFunction: null }, itemsPerPageLabel: { classPropertyName: "itemsPerPageLabel", publicName: "itemsPerPageLabel", isSignal: true, isRequired: false, transformFunction: null }, ofLabel: { classPropertyName: "ofLabel", publicName: "ofLabel", isSignal: true, isRequired: false, transformFunction: null }, firstPageLabel: { classPropertyName: "firstPageLabel", publicName: "firstPageLabel", isSignal: true, isRequired: false, transformFunction: null }, previousPageLabel: { classPropertyName: "previousPageLabel", publicName: "previousPageLabel", isSignal: true, isRequired: false, transformFunction: null }, nextPageLabel: { classPropertyName: "nextPageLabel", publicName: "nextPageLabel", isSignal: true, isRequired: false, transformFunction: null }, lastPageLabel: { classPropertyName: "lastPageLabel", publicName: "lastPageLabel", isSignal: true, isRequired: false, transformFunction: null }, tablePaginationLabel: { classPropertyName: "tablePaginationLabel", publicName: "tablePaginationLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "ariaLabelledby", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentPage: "currentPageChange", pageSize: "pageSizeChange", pageChange: "pageChange", pageSizeChange: "pageSizeChange" }, host: { attributes: { "role": "navigation" }, properties: { "attr.aria-label": "resolvedTablePaginationLabel()", "attr.aria-labelledby": "ariaLabelledby() || null" }, classAttribute: "tn-table-pager" }, ngImport: i0, template: "<div class=\"tn-table-pager__page-size\">\n <span class=\"tn-table-pager__label\">{{ resolvedItemsPerPageLabel() }}:</span>\n <tn-select\n class=\"tn-table-pager__size-select\"\n [testId]=\"childTestId('page-size')\"\n [options]=\"pageSizeSelectOptions()\"\n [ariaLabel]=\"resolvedItemsPerPageLabel()\"\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange($event)\" />\n</div>\n\n<span class=\"tn-table-pager__range\">\n @if (effectiveTotalItems() === 0) {\n 0 {{ resolvedOfLabel() }} 0\n } @else if (lastItemOnPage() > firstItemOnPage()) {\n {{ firstItemOnPage() }} \u2013 {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n } @else {\n {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n }\n</span>\n\n<div class=\"tn-table-pager__buttons\">\n <tn-icon-button\n name=\"page-first\"\n library=\"mdi\"\n [testId]=\"childTestId('first-page')\"\n [ariaLabel]=\"resolvedFirstPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"goToPage(1)\" />\n <tn-icon-button\n name=\"chevron-left\"\n library=\"mdi\"\n [testId]=\"childTestId('previous-page')\"\n [ariaLabel]=\"resolvedPreviousPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"previousPage()\" />\n <tn-icon-button\n name=\"chevron-right\"\n library=\"mdi\"\n [testId]=\"childTestId('next-page')\"\n [ariaLabel]=\"resolvedNextPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"nextPage()\" />\n <tn-icon-button\n name=\"page-last\"\n library=\"mdi\"\n [testId]=\"childTestId('last-page')\"\n [ariaLabel]=\"resolvedLastPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"goToPage(totalPages())\" />\n</div>\n", styles: [":host{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:10px;background-color:var(--tn-bg2);border:1px solid var(--tn-lines);color:var(--tn-fg2)}.tn-table-pager__page-size{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.tn-table-pager__label{white-space:nowrap}.tn-table-pager__size-select{min-width:84px}.tn-table-pager__range{white-space:nowrap}.tn-table-pager__buttons{display:flex;align-items:center;gap:4px}@media(max-width:600px){:host{gap:8px}.tn-table-pager__page-size{gap:4px}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "tooltipSticky", "library", "iconClass"], outputs: ["onClick"] }, { kind: "component", type: TnSelectComponent, selector: "tn-select", inputs: ["options", "optionGroups", "placeholder", "ariaLabel", "noOptionsLabel", "allowEmpty", "emptyLabel", "disabled", "showSelectAll", "selectAllLabel", "testId", "multiple", "optionTestIdKey", "compareWith"], outputs: ["selectionChange", "multiSelectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -25765,7 +27146,7 @@ class TnSidePanelComponent {
25765
27146
  return;
25766
27147
  }
25767
27148
  guard()
25768
- .pipe(take(1), takeUntilDestroyed(this.destroyRef))
27149
+ .pipe(take$1(1), takeUntilDestroyed(this.destroyRef))
25769
27150
  .subscribe((canClose) => {
25770
27151
  if (canClose) {
25771
27152
  this.open.set(false);
@@ -26711,7 +28092,7 @@ class TnFilePickerPopupComponent {
26711
28092
  return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()} ${timePart}`;
26712
28093
  }
26713
28094
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
26714
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnFilePickerPopupComponent, isStandalone: true, selector: "tn-file-picker-popup", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, multiSelect: { classPropertyName: "multiSelect", publicName: "multiSelect", isSignal: true, isRequired: false, transformFunction: null }, createActions: { classPropertyName: "createActions", publicName: "createActions", isSignal: true, isRequired: false, transformFunction: null }, currentPath: { classPropertyName: "currentPath", publicName: "currentPath", isSignal: true, isRequired: false, transformFunction: null }, rootPath: { classPropertyName: "rootPath", publicName: "rootPath", isSignal: true, isRequired: false, transformFunction: null }, fileItems: { classPropertyName: "fileItems", publicName: "fileItems", isSignal: true, isRequired: false, transformFunction: null }, selectedItems: { classPropertyName: "selectedItems", publicName: "selectedItems", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fileExtensions: { classPropertyName: "fileExtensions", publicName: "fileExtensions", isSignal: true, isRequired: false, transformFunction: null }, testIdBase: { classPropertyName: "testIdBase", publicName: "testIdBase", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick", itemDoubleClick: "itemDoubleClick", pathNavigate: "pathNavigate", createAction: "createAction", created: "created", clearSelection: "clearSelection", close: "close", submit: "submit", cancel: "cancel" }, host: { classAttribute: "tn-file-picker-popup" }, ngImport: i0, template: "<!-- Header with breadcrumb navigation -->\n<div class=\"tn-file-picker-header\">\n <nav class=\"tn-file-picker-breadcrumb\" aria-label=\"File path\">\n @for (segment of currentPath() | tnTruncatePath : rootPath(); track $index; let last = $last) {\n <button\n class=\"breadcrumb-segment\"\n tnTestIdType=\"button\"\n [tnTestId]=\"breadcrumbTestId(segment.name)\"\n [class.current]=\"last\"\n [class.nameless]=\"!segment.name\"\n [disabled]=\"last\"\n [attr.aria-label]=\"segment.name === '\u2026' || !segment.name ? 'Navigate to ' + segment.path : null\"\n (click)=\"navigateToPath(segment.path)\">\n {{ segment.name }}\n </button>\n }\n </nav>\n</div>\n\n<!-- Loading indicator -->\n@if (loading()) {\n <div class=\"tn-file-picker-loading\">\n <tn-icon name=\"loading\" library=\"mdi\" />\n <span>Loading...</span>\n </div>\n}\n\n<!-- File table -->\n@if (!loading()) {\n <div class=\"tn-file-picker-content\">\n\n <tn-table\n [dataSource]=\"filteredFileItems()\"\n [displayedColumns]=\"multiSelect() ? displayedColumns : displayedColumns.slice(1)\"\n [clickable]=\"true\"\n [activeWhen]=\"isRowSelected\"\n (rowClick)=\"onItemClick($event)\"\n (rowDoubleClick)=\"onItemDoubleClick($event)\">\n\n <!-- Selection column: explicit width so the auto table layout doesn't\n hand the near-empty column a share of the spare width -->\n @if (multiSelect()) {\n <ng-container tnColumnDef=\"select\" width=\"20px\">\n <ng-template tnHeaderCellDef>\n <!-- Select all checkbox -->\n </ng-template>\n <ng-template let-item tnCellDef>\n @if (!item.inlineCreate) {\n <tn-checkbox\n [hideLabel]=\"true\"\n [label]=\"'Select ' + item.name\"\n [testId]=\"itemTestId(item)\"\n [checked]=\"isSelected(item)\"\n [disabled]=\"!!item.disabled\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"onItemClick(item)\" />\n }\n </ng-template>\n </ng-container>\n }\n\n <!-- Name column -->\n <ng-container tnColumnDef=\"name\">\n <ng-template tnHeaderCellDef>Name</ng-template>\n <ng-template let-item tnCellDef>\n\n <!-- Inline creation row for create actions with a `create` callback -->\n @if (item.inlineCreate) {\n @if (inlineCreation(); as creation) {\n <div class=\"inline-create-cell\">\n <div class=\"inline-create-row\" [class.has-error]=\"!!creation.error\">\n <tn-icon class=\"file-icon\" [name]=\"creation.action.icon || 'mdi-folder'\" />\n <input\n type=\"text\"\n class=\"inline-create-input\"\n spellcheck=\"false\"\n autocomplete=\"off\"\n tnTestIdType=\"input\"\n [tnTestId]=\"inlineCreateTestId()\"\n [attr.aria-label]=\"creation.action.label + ' name'\"\n [attr.aria-invalid]=\"creation.error ? true : null\"\n [attr.aria-describedby]=\"creation.error ? inlineErrorId : null\"\n [class.error]=\"!!creation.error\"\n [disabled]=\"creation.loading\"\n [value]=\"creation.name\"\n (input)=\"onInlineCreationInput($event)\"\n (keydown)=\"onInlineCreationKeyDown($event)\"\n (blur)=\"onInlineCreationBlur()\">\n @if (creation.loading) {\n <tn-icon name=\"mdi-loading\" class=\"creation-loading-icon\" aria-hidden=\"true\" />\n }\n </div>\n @if (creation.error) {\n <div class=\"inline-create-error\" role=\"alert\" [id]=\"inlineErrorId\">\n <tn-icon name=\"mdi-alert-circle\" class=\"error-icon\" aria-hidden=\"true\" />\n <span class=\"error-text\">{{ creation.error }}</span>\n </div>\n }\n </div>\n }\n } @else {\n <div\n class=\"file-name-cell\"\n tnTestIdType=\"option\"\n [tnTestId]=\"itemTestId(item)\"\n [class.selected]=\"isSelected(item)\"\n [class.disabled]=\"!!item.dimmed\"\n [class.zfs-object]=\"isZfsObject(item)\">\n <tn-icon\n [name]=\"getItemIcon(item)\"\n [class]=\"'file-icon file-icon-' + item.type\" />\n <span class=\"file-name\">{{ item.name }}</span>\n\n <!-- ZFS badge -->\n @if (isZfsObject(item)) {\n <span\n [class]=\"'zfs-badge zfs-badge-' + item.type\">\n {{ getZfsBadge(item) }}\n </span>\n }\n\n <!-- Permission indicator -->\n @if (item.permissions === 'none') {\n <tn-icon\n name=\"lock\"\n library=\"mdi\"\n class=\"permission-icon\" />\n }\n\n <!-- Navigation affordance: single click opens the directory -->\n @if (isNavigatable(item)) {\n <button\n type=\"button\"\n class=\"navigate-button\"\n tnTestIdType=\"button\"\n [tnTestId]=\"navigateTestId(item)\"\n [attr.aria-label]=\"'Open ' + item.name\"\n [attr.title]=\"'Open ' + item.name\"\n (click)=\"onNavigateClick($event, item)\"\n (dblclick)=\"$event.stopPropagation()\">\n <tn-icon name=\"mdi-chevron-right\" size=\"sm\" />\n </button>\n }\n </div>\n }\n </ng-template>\n </ng-container>\n\n <!-- Size column -->\n <ng-container tnColumnDef=\"size\">\n <ng-template tnHeaderCellDef>Size</ng-template>\n <ng-template let-item tnCellDef>\n @if (item.size !== undefined) {\n <span>{{ item.size | tnFileSize }}</span>\n }\n @if (item.size === undefined && item.type === 'folder') {\n <span class=\"folder-indicator\">--</span>\n }\n </ng-template>\n </ng-container>\n\n <!-- Modified column -->\n <ng-container tnColumnDef=\"modified\">\n <ng-template tnHeaderCellDef>Modified</ng-template>\n <ng-template let-item tnCellDef>\n @if (item.modified) {\n <span>{{ formatDate(item.modified) }}</span>\n }\n </ng-template>\n </ng-container>\n\n\n </tn-table>\n\n <!-- Empty state -->\n @if (filteredFileItems().length === 0) {\n <div class=\"empty-state\">\n <tn-icon name=\"folder-open\" library=\"mdi\" customSize=\"48px\" />\n <p>No items found</p>\n </div>\n }\n </div>\n}\n\n<!-- Footer -->\n@if (!loading()) {\n <div class=\"tn-file-picker-footer\">\n <div class=\"footer-selection\">\n @if (selectedItems().length > 0) {\n <span class=\"selection-count\">\n {{ selectedItems().length }} item{{ selectedItems().length !== 1 ? 's' : '' }} selected\n </span>\n <tn-button\n variant=\"outline\"\n label=\"Clear Selection\"\n [testId]=\"clearSelectionTestId()\"\n (onClick)=\"onClearSelection()\" />\n } @else if (allowCurrentDirectorySelection()) {\n <span class=\"selection-count current-directory-selection\">\n Current directory selected\n </span>\n } @else {\n <span class=\"selection-count\">\n No items selected\n </span>\n }\n </div>\n <div class=\"footer-actions\">\n @for (action of createActions(); track action.id) {\n <tn-button\n variant=\"outline\"\n [label]=\"action.label\"\n [testId]=\"createActionTestId(action)\"\n [disabled]=\"!!inlineCreation()\"\n (onClick)=\"onCreateAction(action)\" />\n }\n <!-- Disabled while the inline creation row is open: its blur auto-submit\n would race the selection submit -->\n <tn-button\n label=\"Select\"\n [testId]=\"selectTestId()\"\n [disabled]=\"(selectedItems().length === 0 && !allowCurrentDirectorySelection()) || !!inlineCreation()\"\n (onClick)=\"onSubmit()\" />\n </div>\n </div>\n}", styles: [":host{display:block;background:var(--tn-bg1, white);color:var(--tn-fg1, #333);padding:0;box-shadow:0 4px 16px #0000001f,0 1px 4px #00000014;border-radius:8px;border:1px solid var(--tn-lines, #e0e0e0);width:520px;max-width:calc(100vw - 32px);min-height:min(500px,100vh - 16px);max-height:min(600px,100vh - 16px);font-family:var(--tn-font-family-body);display:flex;flex-direction:column;overflow:hidden}.tn-file-picker-header{display:flex;align-items:center;justify-content:space-between;padding:var(--tn-content-padding, 24px);padding-bottom:16px;border-bottom:1px solid var(--tn-lines)}.tn-file-picker-breadcrumb{display:flex;align-items:center;flex:1;min-width:0}.tn-file-picker-breadcrumb .breadcrumb-segment{background:transparent;border:none;color:var(--tn-primary-text, var(--tn-primary, #0074a7));cursor:pointer;padding:4px 2px;border-radius:4px;font-size:1rem;white-space:nowrap;transition:background-color .15s ease-in-out}.tn-file-picker-breadcrumb .breadcrumb-segment:hover:not(:disabled){background:var(--tn-bg2)}.tn-file-picker-breadcrumb .breadcrumb-segment:disabled,.tn-file-picker-breadcrumb .breadcrumb-segment.current{color:var(--tn-fg1);cursor:default;font-weight:500}.tn-file-picker-breadcrumb .breadcrumb-segment:not(:last-child):after{content:\"/\";margin-left:2px;color:var(--tn-alt-fg1)}.tn-file-picker-breadcrumb .breadcrumb-segment:first-child:before{content:\"/\";margin-right:2px;color:var(--tn-alt-fg1)}.tn-file-picker-breadcrumb .breadcrumb-segment.nameless:not(:last-child):after{content:none}.tn-file-picker-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:40px;color:var(--tn-fg2)}.tn-file-picker-loading tn-icon{animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.tn-file-picker-content{flex:1;min-height:0;overflow-y:auto}.inline-create-cell{display:flex;flex-direction:column;gap:4px;flex:1;min-width:0}.inline-create-row{display:flex;align-items:center;gap:8px}.inline-create-row .inline-create-input{flex:1;padding:4px 8px;border:2px solid var(--tn-primary);border-radius:3px;background:var(--tn-bg1);color:var(--tn-fg1);font-size:inherit;font-family:inherit;outline:none;min-width:0}.inline-create-row .inline-create-input.error{border-color:var(--tn-error, var(--tn-red, #bd2626))}.inline-create-row .inline-create-input:disabled{opacity:.6;cursor:not-allowed;background:var(--tn-bg2)}.inline-create-row .creation-loading-icon{animation:spin 1s linear infinite;color:var(--tn-primary);flex-shrink:0}.inline-create-error{display:flex;align-items:center;gap:6px;margin-top:4px;padding:4px 8px;background:color-mix(in srgb,var(--tn-error, var(--tn-red, #bd2626)) 10%,transparent);border-left:3px solid var(--tn-error, var(--tn-red, #bd2626));border-radius:3px;font-size:1rem;color:var(--tn-error, var(--tn-red, #bd2626))}.inline-create-error .error-icon{flex-shrink:0}.inline-create-error .error-text{flex:1}.file-list-viewport{width:100%;height:100%}.file-list-viewport .cdk-virtual-scroll-content-wrapper{width:100%}tn-table{width:100%}tn-table th,tn-table .tn-table__header-cell{font-weight:600;padding:12px 16px;border-bottom:2px solid var(--tn-lines)}tn-table td,tn-table .tn-table__cell{padding:8px 16px;border-bottom:1px solid var(--tn-lines)}.file-name-cell{display:flex;align-items:center;gap:8px;cursor:pointer}.file-name-cell.disabled{opacity:.5;color:var(--tn-fg2, #757575)}.file-name-cell.disabled .file-name{color:var(--tn-fg2, #757575)}.file-name-cell.disabled .file-icon{opacity:.6}.file-name-cell.disabled:has(.file-icon-folder),.file-name-cell.disabled:has(.file-icon-dataset),.file-name-cell.disabled:has(.file-icon-mountpoint){cursor:pointer}.file-name-cell.disabled:not(:has(.file-icon-folder)):not(:has(.file-icon-dataset)):not(:has(.file-icon-mountpoint)){cursor:not-allowed}.file-icon{display:flex;align-items:center;justify-content:center;font-size:var(--tn-icon-md, 20px);flex-shrink:0;line-height:1}.file-icon.file-icon-folder{color:var(--tn-primary)}.file-icon.file-icon-dataset{color:var(--tn-blue, #007db3)}.file-icon.file-icon-zvol{color:var(--tn-green, #71BF44)}.file-icon.file-icon-mountpoint{color:var(--tn-orange, #E68D37)}.file-name{flex:1;font-weight:500;line-height:1.4}.zfs-badge{display:inline-flex;align-items:center;background:var(--tn-alt-bg2);color:var(--tn-alt-fg2);font-size:.625rem;font-weight:600;padding:2px 6px;border-radius:12px;text-transform:uppercase;letter-spacing:.5px;line-height:1}.zfs-badge.zfs-badge-dataset{background:var(--tn-blue);color:#fff}.zfs-badge.zfs-badge-zvol{background:var(--tn-green);color:#fff}.zfs-badge.zfs-badge-mountpoint{background:var(--tn-orange);color:#fff}.permission-icon{display:flex;align-items:center;justify-content:center;color:var(--tn-red);font-size:var(--tn-icon-sm, 16px);line-height:1}.navigate-button{display:flex;align-items:center;justify-content:center;margin-left:auto;padding:2px;background:none;border:none;border-radius:4px;color:var(--tn-fg2);cursor:pointer}.navigate-button:hover,.navigate-button:focus-visible{background:var(--tn-bg3);color:var(--tn-primary)}.file-type{font-size:1rem;padding:2px 8px;border-radius:12px}.file-type.type-folder{background:var(--tn-alt-bg1);color:var(--tn-alt-fg2)}.file-type.type-file{background:var(--tn-bg2);color:var(--tn-fg2)}.file-type.type-dataset{background:#007db31a;color:var(--tn-blue)}.file-type.type-zvol{background:#71bf441a;color:var(--tn-green)}.file-type.type-mountpoint{background:#e68d371a;color:var(--tn-orange)}.folder-indicator{color:var(--tn-alt-fg1);font-style:italic}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--tn-alt-fg1);text-align:center}.empty-state tn-icon{margin-bottom:16px;opacity:.5}.empty-state p{margin:0;font-size:1rem}.tn-file-picker-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px var(--tn-content-padding, 24px);border-top:1px solid var(--tn-lines);background:var(--tn-bg2);border-bottom-left-radius:8px;border-bottom-right-radius:8px}.tn-file-picker-footer .footer-selection{display:flex;align-items:center;gap:12px}.selection-count{font-size:1rem;color:var(--tn-fg2);font-weight:500}.footer-actions{display:flex;gap:8px}@media(prefers-reduced-motion:reduce){.file-item,.breadcrumb-segment{transition:none}.tn-file-picker-loading tn-icon{animation:none}}@media(prefers-contrast:high){:host{border-width:2px}.file-item:hover,.file-item.selected{border:2px solid var(--tn-fg1)}.zfs-badge{border:1px solid var(--tn-fg1)}}@media(max-width:768px){:host{min-width:300px;max-width:calc(100vw - 32px);max-height:calc(100vh - 64px)}.tn-file-picker-header{flex-direction:column;gap:12px;align-items:stretch}.tn-file-picker-breadcrumb{overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.tn-file-picker-breadcrumb::-webkit-scrollbar{display:none}.file-item{padding:12px;min-height:56px}.file-info{font-size:1rem}}\n"], dependencies: [{ kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "component", type: TnButtonComponent, selector: "tn-button", inputs: ["primary", "color", "variant", "backgroundColor", "label", "icon", "iconPosition", "disabled", "fullWidth", "type", "testId", "href", "routerLink", "queryParams", "fragment", "target", "rel", "ariaLabel"], outputs: ["onClick"] }, { kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "component", type: TnTableComponent, selector: "tn-table", inputs: ["dataSource", "displayedColumns", "trackBy", "emptyMessage", "emptyDescription", "emptyIcon", "scrollRegionAriaLabel", "selectable", "expandable", "bordered", "isRowExpandable", "activeRow", "activeWhen", "activeBg", "activeIndicator", "loading", "loadingMessage", "clickable", "expandOnRowClick", "singleExpand", "fixedLayout", "minColumnWidth", "minWidth", "mobileLayout", "cardBreakpoint", "cardPrimaryCount", "sortColumn", "sortDirection"], outputs: ["sortChange", "selectionChange", "rowClick", "rowDoubleClick", "sortColumnChange", "sortDirectionChange"] }, { kind: "directive", type: TnTableColumnDirective, selector: "[tnColumnDef]", inputs: ["tnColumnDef", "sortable", "width", "label", "hideLabel", "cardPriority", "cardTitle", "cardHidden", "cardLabel"], exportAs: ["tnColumnDef"] }, { kind: "directive", type: TnHeaderCellDefDirective, selector: "[tnHeaderCellDef]" }, { kind: "directive", type: TnCellDefDirective, selector: "[tnCellDef]" }, { kind: "ngmodule", type: ScrollingModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }, { kind: "pipe", type: FileSizePipe, name: "tnFileSize" }, { kind: "pipe", type: TruncatePathPipe, name: "tnTruncatePath" }] });
28095
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnFilePickerPopupComponent, isStandalone: true, selector: "tn-file-picker-popup", inputs: { mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, multiSelect: { classPropertyName: "multiSelect", publicName: "multiSelect", isSignal: true, isRequired: false, transformFunction: null }, createActions: { classPropertyName: "createActions", publicName: "createActions", isSignal: true, isRequired: false, transformFunction: null }, currentPath: { classPropertyName: "currentPath", publicName: "currentPath", isSignal: true, isRequired: false, transformFunction: null }, rootPath: { classPropertyName: "rootPath", publicName: "rootPath", isSignal: true, isRequired: false, transformFunction: null }, fileItems: { classPropertyName: "fileItems", publicName: "fileItems", isSignal: true, isRequired: false, transformFunction: null }, selectedItems: { classPropertyName: "selectedItems", publicName: "selectedItems", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fileExtensions: { classPropertyName: "fileExtensions", publicName: "fileExtensions", isSignal: true, isRequired: false, transformFunction: null }, testIdBase: { classPropertyName: "testIdBase", publicName: "testIdBase", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick", itemDoubleClick: "itemDoubleClick", pathNavigate: "pathNavigate", createAction: "createAction", created: "created", clearSelection: "clearSelection", close: "close", submit: "submit", cancel: "cancel" }, host: { classAttribute: "tn-file-picker-popup" }, ngImport: i0, template: "<!-- Header with breadcrumb navigation -->\n<div class=\"tn-file-picker-header\">\n <nav class=\"tn-file-picker-breadcrumb\" aria-label=\"File path\">\n @for (segment of currentPath() | tnTruncatePath : rootPath(); track $index; let last = $last) {\n <button\n class=\"breadcrumb-segment\"\n tnTestIdType=\"button\"\n [tnTestId]=\"breadcrumbTestId(segment.name)\"\n [class.current]=\"last\"\n [class.nameless]=\"!segment.name\"\n [disabled]=\"last\"\n [attr.aria-label]=\"segment.name === '\u2026' || !segment.name ? 'Navigate to ' + segment.path : null\"\n (click)=\"navigateToPath(segment.path)\">\n {{ segment.name }}\n </button>\n }\n </nav>\n</div>\n\n<!-- Loading indicator -->\n@if (loading()) {\n <div class=\"tn-file-picker-loading\">\n <tn-icon name=\"loading\" library=\"mdi\" />\n <span>Loading...</span>\n </div>\n}\n\n<!-- File table -->\n@if (!loading()) {\n <div class=\"tn-file-picker-content\">\n\n <tn-table\n [dataSource]=\"filteredFileItems()\"\n [displayedColumns]=\"multiSelect() ? displayedColumns : displayedColumns.slice(1)\"\n [clickable]=\"true\"\n [activeWhen]=\"isRowSelected\"\n (rowClick)=\"onItemClick($event)\"\n (rowDoubleClick)=\"onItemDoubleClick($event)\">\n\n <!-- Selection column: explicit width so the auto table layout doesn't\n hand the near-empty column a share of the spare width -->\n @if (multiSelect()) {\n <ng-container tnColumnDef=\"select\" width=\"20px\">\n <ng-template tnHeaderCellDef>\n <!-- Select all checkbox -->\n </ng-template>\n <ng-template let-item tnCellDef>\n @if (!item.inlineCreate) {\n <tn-checkbox\n [hideLabel]=\"true\"\n [label]=\"'Select ' + item.name\"\n [testId]=\"itemTestId(item)\"\n [checked]=\"isSelected(item)\"\n [disabled]=\"!!item.disabled\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"onItemClick(item)\" />\n }\n </ng-template>\n </ng-container>\n }\n\n <!-- Name column -->\n <ng-container tnColumnDef=\"name\">\n <ng-template tnHeaderCellDef>Name</ng-template>\n <ng-template let-item tnCellDef>\n\n <!-- Inline creation row for create actions with a `create` callback -->\n @if (item.inlineCreate) {\n @if (inlineCreation(); as creation) {\n <div class=\"inline-create-cell\">\n <div class=\"inline-create-row\" [class.has-error]=\"!!creation.error\">\n <tn-icon class=\"file-icon\" [name]=\"creation.action.icon || 'mdi-folder'\" />\n <input\n type=\"text\"\n class=\"inline-create-input\"\n spellcheck=\"false\"\n autocomplete=\"off\"\n tnTestIdType=\"input\"\n [tnTestId]=\"inlineCreateTestId()\"\n [attr.aria-label]=\"creation.action.label + ' name'\"\n [attr.aria-invalid]=\"creation.error ? true : null\"\n [attr.aria-describedby]=\"creation.error ? inlineErrorId : null\"\n [class.error]=\"!!creation.error\"\n [disabled]=\"creation.loading\"\n [value]=\"creation.name\"\n (input)=\"onInlineCreationInput($event)\"\n (keydown)=\"onInlineCreationKeyDown($event)\"\n (blur)=\"onInlineCreationBlur()\">\n @if (creation.loading) {\n <tn-icon name=\"mdi-loading\" class=\"creation-loading-icon\" aria-hidden=\"true\" />\n }\n </div>\n @if (creation.error) {\n <div class=\"inline-create-error\" role=\"alert\" [id]=\"inlineErrorId\">\n <tn-icon name=\"mdi-alert-circle\" class=\"error-icon\" aria-hidden=\"true\" />\n <span class=\"error-text\">{{ creation.error }}</span>\n </div>\n }\n </div>\n }\n } @else {\n <div\n class=\"file-name-cell\"\n tnTestIdType=\"option\"\n [tnTestId]=\"itemTestId(item)\"\n [class.selected]=\"isSelected(item)\"\n [class.disabled]=\"!!item.dimmed\"\n [class.zfs-object]=\"isZfsObject(item)\">\n <tn-icon\n [name]=\"getItemIcon(item)\"\n [class]=\"'file-icon file-icon-' + item.type\" />\n <span class=\"file-name\">{{ item.name }}</span>\n\n <!-- ZFS badge -->\n @if (isZfsObject(item)) {\n <span\n [class]=\"'zfs-badge zfs-badge-' + item.type\">\n {{ getZfsBadge(item) }}\n </span>\n }\n\n <!-- Permission indicator -->\n @if (item.permissions === 'none') {\n <tn-icon\n name=\"lock\"\n library=\"mdi\"\n class=\"permission-icon\" />\n }\n\n <!-- Navigation affordance: single click opens the directory -->\n @if (isNavigatable(item)) {\n <button\n type=\"button\"\n class=\"navigate-button\"\n tnTestIdType=\"button\"\n [tnTestId]=\"navigateTestId(item)\"\n [attr.aria-label]=\"'Open ' + item.name\"\n [attr.title]=\"'Open ' + item.name\"\n (click)=\"onNavigateClick($event, item)\"\n (dblclick)=\"$event.stopPropagation()\">\n <tn-icon name=\"mdi-chevron-right\" size=\"sm\" />\n </button>\n }\n </div>\n }\n </ng-template>\n </ng-container>\n\n <!-- Size column -->\n <ng-container tnColumnDef=\"size\">\n <ng-template tnHeaderCellDef>Size</ng-template>\n <ng-template let-item tnCellDef>\n @if (item.size !== undefined) {\n <span>{{ item.size | tnFileSize }}</span>\n }\n @if (item.size === undefined && item.type === 'folder') {\n <span class=\"folder-indicator\">--</span>\n }\n </ng-template>\n </ng-container>\n\n <!-- Modified column -->\n <ng-container tnColumnDef=\"modified\">\n <ng-template tnHeaderCellDef>Modified</ng-template>\n <ng-template let-item tnCellDef>\n @if (item.modified) {\n <span>{{ formatDate(item.modified) }}</span>\n }\n </ng-template>\n </ng-container>\n\n\n </tn-table>\n\n <!-- Empty state -->\n @if (filteredFileItems().length === 0) {\n <div class=\"empty-state\">\n <tn-icon name=\"folder-open\" library=\"mdi\" customSize=\"48px\" />\n <p>No items found</p>\n </div>\n }\n </div>\n}\n\n<!-- Footer -->\n@if (!loading()) {\n <div class=\"tn-file-picker-footer\">\n <div class=\"footer-selection\">\n @if (selectedItems().length > 0) {\n <span class=\"selection-count\">\n {{ selectedItems().length }} item{{ selectedItems().length !== 1 ? 's' : '' }} selected\n </span>\n <tn-button\n variant=\"outline\"\n label=\"Clear Selection\"\n [testId]=\"clearSelectionTestId()\"\n (onClick)=\"onClearSelection()\" />\n } @else if (allowCurrentDirectorySelection()) {\n <span class=\"selection-count current-directory-selection\">\n Current directory selected\n </span>\n } @else {\n <span class=\"selection-count\">\n No items selected\n </span>\n }\n </div>\n <div class=\"footer-actions\">\n @for (action of createActions(); track action.id) {\n <tn-button\n variant=\"outline\"\n [label]=\"action.label\"\n [testId]=\"createActionTestId(action)\"\n [disabled]=\"!!inlineCreation()\"\n (onClick)=\"onCreateAction(action)\" />\n }\n <!-- Disabled while the inline creation row is open: its blur auto-submit\n would race the selection submit -->\n <tn-button\n label=\"Select\"\n [testId]=\"selectTestId()\"\n [disabled]=\"(selectedItems().length === 0 && !allowCurrentDirectorySelection()) || !!inlineCreation()\"\n (onClick)=\"onSubmit()\" />\n </div>\n </div>\n}", styles: [":host{display:block;background:var(--tn-bg1, white);color:var(--tn-fg1, #333);padding:0;box-shadow:0 4px 16px #0000001f,0 1px 4px #00000014;border-radius:8px;border:1px solid var(--tn-lines, #e0e0e0);width:520px;max-width:calc(100vw - 32px);min-height:min(500px,100vh - 16px);max-height:min(600px,100vh - 16px);font-family:var(--tn-font-family-body);display:flex;flex-direction:column;overflow:hidden}.tn-file-picker-header{display:flex;align-items:center;justify-content:space-between;padding:var(--tn-content-padding, 24px);padding-bottom:16px;border-bottom:1px solid var(--tn-lines)}.tn-file-picker-breadcrumb{display:flex;align-items:center;flex:1;min-width:0}.tn-file-picker-breadcrumb .breadcrumb-segment{background:transparent;border:none;color:var(--tn-primary-text, var(--tn-primary, #0074a7));cursor:pointer;padding:4px 2px;border-radius:4px;font-size:1rem;white-space:nowrap;transition:background-color .15s ease-in-out}.tn-file-picker-breadcrumb .breadcrumb-segment:hover:not(:disabled){background:var(--tn-bg2)}.tn-file-picker-breadcrumb .breadcrumb-segment:disabled,.tn-file-picker-breadcrumb .breadcrumb-segment.current{color:var(--tn-fg1);cursor:default;font-weight:500}.tn-file-picker-breadcrumb .breadcrumb-segment:not(:last-child):after{content:\"/\";margin-left:2px;color:var(--tn-alt-fg1)}.tn-file-picker-breadcrumb .breadcrumb-segment:first-child:before{content:\"/\";margin-right:2px;color:var(--tn-alt-fg1)}.tn-file-picker-breadcrumb .breadcrumb-segment.nameless:not(:last-child):after{content:none}.tn-file-picker-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:40px;color:var(--tn-fg2)}.tn-file-picker-loading tn-icon{animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.tn-file-picker-content{flex:1;min-height:0;overflow-y:auto}.inline-create-cell{display:flex;flex-direction:column;gap:4px;flex:1;min-width:0}.inline-create-row{display:flex;align-items:center;gap:8px}.inline-create-row .inline-create-input{flex:1;padding:4px 8px;border:2px solid var(--tn-primary);border-radius:3px;background:var(--tn-bg1);color:var(--tn-fg1);font-size:inherit;font-family:inherit;outline:none;min-width:0}.inline-create-row .inline-create-input.error{border-color:var(--tn-error, var(--tn-red, #bd2626))}.inline-create-row .inline-create-input:disabled{opacity:.6;cursor:not-allowed;background:var(--tn-bg2)}.inline-create-row .creation-loading-icon{animation:spin 1s linear infinite;color:var(--tn-primary);flex-shrink:0}.inline-create-error{display:flex;align-items:center;gap:6px;margin-top:4px;padding:4px 8px;background:color-mix(in srgb,var(--tn-error, var(--tn-red, #bd2626)) 10%,transparent);border-left:3px solid var(--tn-error, var(--tn-red, #bd2626));border-radius:3px;font-size:1rem;color:var(--tn-error, var(--tn-red, #bd2626))}.inline-create-error .error-icon{flex-shrink:0}.inline-create-error .error-text{flex:1}.file-list-viewport{width:100%;height:100%}.file-list-viewport .cdk-virtual-scroll-content-wrapper{width:100%}tn-table{width:100%}tn-table th,tn-table .tn-table__header-cell{font-weight:600;padding:12px 16px;border-bottom:2px solid var(--tn-lines)}tn-table td,tn-table .tn-table__cell{padding:8px 16px;border-bottom:1px solid var(--tn-lines)}.file-name-cell{display:flex;align-items:center;gap:8px;cursor:pointer}.file-name-cell.disabled{opacity:.5;color:var(--tn-fg2, #757575)}.file-name-cell.disabled .file-name{color:var(--tn-fg2, #757575)}.file-name-cell.disabled .file-icon{opacity:.6}.file-name-cell.disabled:has(.file-icon-folder),.file-name-cell.disabled:has(.file-icon-dataset),.file-name-cell.disabled:has(.file-icon-mountpoint){cursor:pointer}.file-name-cell.disabled:not(:has(.file-icon-folder)):not(:has(.file-icon-dataset)):not(:has(.file-icon-mountpoint)){cursor:not-allowed}.file-icon{display:flex;align-items:center;justify-content:center;font-size:var(--tn-icon-md, 20px);flex-shrink:0;line-height:1}.file-icon.file-icon-folder{color:var(--tn-primary)}.file-icon.file-icon-dataset{color:var(--tn-blue, #007db3)}.file-icon.file-icon-zvol{color:var(--tn-green, #71BF44)}.file-icon.file-icon-mountpoint{color:var(--tn-orange, #E68D37)}.file-name{flex:1;font-weight:500;line-height:1.4}.zfs-badge{display:inline-flex;align-items:center;background:var(--tn-alt-bg2);color:var(--tn-alt-fg2);font-size:.625rem;font-weight:600;padding:2px 6px;border-radius:12px;text-transform:uppercase;letter-spacing:.5px;line-height:1}.zfs-badge.zfs-badge-dataset{background:var(--tn-blue);color:#fff}.zfs-badge.zfs-badge-zvol{background:var(--tn-green);color:#fff}.zfs-badge.zfs-badge-mountpoint{background:var(--tn-orange);color:#fff}.permission-icon{display:flex;align-items:center;justify-content:center;color:var(--tn-red);font-size:var(--tn-icon-sm, 16px);line-height:1}.navigate-button{display:flex;align-items:center;justify-content:center;margin-left:auto;padding:2px;background:none;border:none;border-radius:4px;color:var(--tn-fg2);cursor:pointer}.navigate-button:hover,.navigate-button:focus-visible{background:var(--tn-bg3);color:var(--tn-primary)}.file-type{font-size:1rem;padding:2px 8px;border-radius:12px}.file-type.type-folder{background:var(--tn-alt-bg1);color:var(--tn-alt-fg2)}.file-type.type-file{background:var(--tn-bg2);color:var(--tn-fg2)}.file-type.type-dataset{background:#007db31a;color:var(--tn-blue)}.file-type.type-zvol{background:#71bf441a;color:var(--tn-green)}.file-type.type-mountpoint{background:#e68d371a;color:var(--tn-orange)}.folder-indicator{color:var(--tn-alt-fg1);font-style:italic}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--tn-alt-fg1);text-align:center}.empty-state tn-icon{margin-bottom:16px;opacity:.5}.empty-state p{margin:0;font-size:1rem}.tn-file-picker-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px var(--tn-content-padding, 24px);border-top:1px solid var(--tn-lines);background:var(--tn-bg2);border-bottom-left-radius:8px;border-bottom-right-radius:8px}.tn-file-picker-footer .footer-selection{display:flex;align-items:center;gap:12px}.selection-count{font-size:1rem;color:var(--tn-fg2);font-weight:500}.footer-actions{display:flex;gap:8px}@media(prefers-reduced-motion:reduce){.file-item,.breadcrumb-segment{transition:none}.tn-file-picker-loading tn-icon{animation:none}}@media(prefers-contrast:high){:host{border-width:2px}.file-item:hover,.file-item.selected{border:2px solid var(--tn-fg1)}.zfs-badge{border:1px solid var(--tn-fg1)}}@media(max-width:768px){:host{min-width:300px;max-width:calc(100vw - 32px);max-height:calc(100vh - 64px)}.tn-file-picker-header{flex-direction:column;gap:12px;align-items:stretch}.tn-file-picker-breadcrumb{overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.tn-file-picker-breadcrumb::-webkit-scrollbar{display:none}.file-item{padding:12px;min-height:56px}.file-info{font-size:1rem}}\n"], dependencies: [{ kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }, { kind: "component", type: TnButtonComponent, selector: "tn-button", inputs: ["primary", "color", "variant", "backgroundColor", "label", "icon", "iconPosition", "disabled", "fullWidth", "type", "testId", "href", "routerLink", "queryParams", "fragment", "target", "rel", "ariaLabel"], outputs: ["onClick"] }, { kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "component", type: TnTableComponent, selector: "tn-table", inputs: ["dataSource", "displayedColumns", "trackBy", "selectionKey", "expansionKey", "emptyMessage", "emptyDescription", "emptyIcon", "scrollRegionAriaLabel", "selectable", "expandable", "bordered", "isRowExpandable", "activeRow", "activeWhen", "activeBg", "activeIndicator", "loading", "loadingMessage", "clickable", "expandOnRowClick", "singleExpand", "fixedLayout", "minColumnWidth", "minWidth", "mobileLayout", "cardBreakpoint", "cardPrimaryCount", "sortColumn", "sortDirection"], outputs: ["sortChange", "selectionChange", "rowClick", "rowDoubleClick", "sortColumnChange", "sortDirectionChange"] }, { kind: "directive", type: TnTableColumnDirective, selector: "[tnColumnDef]", inputs: ["tnColumnDef", "sortable", "width", "label", "hideLabel", "cardPriority", "cardTitle", "cardHidden", "cardLabel"], exportAs: ["tnColumnDef"] }, { kind: "directive", type: TnHeaderCellDefDirective, selector: "[tnHeaderCellDef]" }, { kind: "directive", type: TnCellDefDirective, selector: "[tnCellDef]" }, { kind: "ngmodule", type: ScrollingModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }, { kind: "pipe", type: FileSizePipe, name: "tnFileSize" }, { kind: "pipe", type: TruncatePathPipe, name: "tnTruncatePath" }] });
26715
28096
  }
26716
28097
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, decorators: [{
26717
28098
  type: Component,
@@ -28630,5 +30011,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
28630
30011
  * Generated bundle index. Do not edit.
28631
30012
  */
28632
30013
 
28633
- export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, THEME_MAP, THEME_STORAGE_KEY, TN_AUTOCOMPLETE_DEFAULT_LABELS, TN_AUTOCOMPLETE_LABELS, TN_BRANDED_SPINNER_DEFAULT_LABEL, TN_CALENDAR_INTL, TN_CALENDAR_INTL_DEFAULTS, TN_DEFAULT_FALLBACK_LABELS, TN_DIALOG_CHROME_LABELS, TN_DIALOG_DEFAULT_CHROME_LABELS, TN_DIALOG_SHELL_DEFAULT_LABEL, TN_DRAWER_CONTENT_LABEL, TN_DRAWER_DEFAULT_LABEL, TN_FALLBACK_LABELS, TN_FORM_FIELD_CONTEXT, TN_FORM_FIELD_DISMISSIBLE_ERRORS, TN_FORM_FIELD_ERRORS, TN_FORM_LIST_CONTEXT, TN_PARTICLE_PROGRESS_BAR_DEFAULT_LABEL, TN_PROGRESS_BAR_DEFAULT_LABEL, TN_RADIO_GROUP, TN_SELECT_DEFAULT_LABELS, TN_SELECT_LABELS, TN_SIDE_PANEL_CONTENT_LABEL, TN_SIDE_PANEL_DEFAULT_LABEL, TN_SIDE_PANEL_OVERFLOW_TOLERANCE_PX, TN_SPINNER_DEFAULT_LABEL, TN_STEPPER_STATUS_COMPLETED, TN_STEPPER_STATUS_ERROR, TN_TABLE_DEFAULT_LABELS, TN_TABLE_LABELS, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TABLE_SCROLL_REGION_LABEL, TN_TAB_PANEL_CONTENT_LABEL, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TN_TOAST_ANNOUNCE_DELAY_MS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerActionHarness, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarCellHarness, TnCalendarComponent, TnCalendarHarness, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxGroupComponent, TnCheckboxGroupHarness, 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, TnFileInputComponent, TnFileInputHarness, TnFilePickerComponent, TnFilePickerHarness, TnFilePickerPopupComponent, TnFormErrorsComponent, TnFormErrorsHarness, TnFormFieldComponent, TnFormFieldHarness, TnFormListComponent, TnFormListHarness, TnFormListItemComponent, TnFormListItemHarness, 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, TnNestedTreeDataSource, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, TnRadioGroupComponent, TnRadioGroupHarness, TnRadioHarness, TnRowActionsDefDirective, TnSelectComponent, TnSelectHarness, TnSelectionListComponent, TnSidePanelActionDirective, TnSidePanelComponent, TnSidePanelHarness, TnSidePanelHeaderActionDirective, TnSlideToggleComponent, TnSlideToggleHarness, TnSliderComponent, TnSliderThumbDirective, TnSliderWithLabelDirective, TnSpinnerComponent, TnSpriteLoaderService, TnStepComponent, TnStepperComponent, TnStepperHarness, TnStepperNextDirective, TnStepperPreviousDirective, TnTabComponent, TnTabHarness, TnTabPanelComponent, TnTabPanelHarness, TnTableColumnDirective, TnTableComponent, TnTableHarness, TnTablePagerComponent, TnTablePagerHarness, TnTableTesting, TnTabsComponent, TnTabsHarness, TnTestIdDirective, TnTheme, TnThemeService, TnTimeInputComponent, TnToastComponent, TnToastMock, TnToastPosition, TnToastRef, TnToastService, TnToastTesting, TnToastType, TnTooltipComponent, TnTooltipDirective, TnTooltipHarness, TnTooltipTesting, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeHarness, TnTreeNodeComponent, TnTreeNodeHarness, TnTreeNodeOutletDirective, TnTreeVirtualScrollNodeOutletDirective, TnTreeVirtualScrollViewComponent, TnTreeVirtualScrollViewHarness, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, allowsCurrentDirectorySelection, canonicalizeSize, composeTestId, controlTestId, createFlatTreeControl, createLucideLibrary, createNestedTreeControl, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, defaultTreeItemSize, formatSize, formatSizeExact, formatSizeForEditing, getSelectableTypes, injectTnCalendarIntl, injectTnFallbackName, injectTnFormFieldAria, injectTnLabels, isPathWithinRoot, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, normalizeRootPath, optionTestId, parseLabelMarkup, parseSize, parseSizeParts, registerLucideIcons, resolveErrorMessage, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
30014
+ export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, THEME_MAP, THEME_STORAGE_KEY, TN_AUTOCOMPLETE_DEFAULT_LABELS, TN_AUTOCOMPLETE_LABELS, TN_BRANDED_SPINNER_DEFAULT_LABEL, TN_CALENDAR_INTL, TN_CALENDAR_INTL_DEFAULTS, TN_CHIP_INPUT_DEFAULT_LABELS, TN_CHIP_INPUT_LABELS, TN_DEFAULT_FALLBACK_LABELS, TN_DIALOG_CHROME_LABELS, TN_DIALOG_DEFAULT_CHROME_LABELS, TN_DIALOG_SHELL_DEFAULT_LABEL, TN_DRAWER_CONTENT_LABEL, TN_DRAWER_DEFAULT_LABEL, TN_FALLBACK_LABELS, TN_FORM_FIELD_CONTEXT, TN_FORM_FIELD_DISMISSIBLE_ERRORS, TN_FORM_FIELD_ERRORS, TN_FORM_LIST_CONTEXT, TN_PARTICLE_PROGRESS_BAR_DEFAULT_LABEL, TN_PROGRESS_BAR_DEFAULT_LABEL, TN_RADIO_GROUP, TN_SELECT_DEFAULT_LABELS, TN_SELECT_LABELS, TN_SIDE_PANEL_CONTENT_LABEL, TN_SIDE_PANEL_DEFAULT_LABEL, TN_SIDE_PANEL_OVERFLOW_TOLERANCE_PX, TN_SPINNER_DEFAULT_LABEL, TN_STEPPER_STATUS_COMPLETED, TN_STEPPER_STATUS_ERROR, TN_TABLE_DEFAULT_LABELS, TN_TABLE_LABELS, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TABLE_SCROLL_REGION_LABEL, TN_TAB_PANEL_CONTENT_LABEL, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TN_TOAST_ANNOUNCE_DELAY_MS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerActionHarness, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarCellHarness, TnCalendarComponent, TnCalendarHarness, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxGroupComponent, TnCheckboxGroupHarness, 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, TnFileInputComponent, TnFileInputHarness, TnFilePickerComponent, TnFilePickerHarness, TnFilePickerPopupComponent, TnFormErrorsComponent, TnFormErrorsHarness, TnFormFieldComponent, TnFormFieldHarness, TnFormListComponent, TnFormListHarness, TnFormListItemComponent, TnFormListItemHarness, 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, TnNestedTreeDataSource, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, TnRadioGroupComponent, TnRadioGroupHarness, TnRadioHarness, TnRowActionsDefDirective, TnSelectComponent, TnSelectHarness, TnSelectionListComponent, TnSidePanelActionDirective, TnSidePanelComponent, TnSidePanelHarness, TnSidePanelHeaderActionDirective, TnSlideToggleComponent, TnSlideToggleHarness, TnSliderComponent, TnSliderThumbDirective, TnSliderWithLabelDirective, TnSpinnerComponent, TnSpriteLoaderService, TnStepComponent, TnStepperComponent, TnStepperHarness, TnStepperNextDirective, TnStepperPreviousDirective, TnTabComponent, TnTabHarness, TnTabPanelComponent, TnTabPanelHarness, TnTableColumnDirective, TnTableComponent, TnTableHarness, TnTablePagerComponent, TnTablePagerHarness, TnTableTesting, TnTabsComponent, TnTabsHarness, TnTestIdDirective, TnTheme, TnThemeService, TnTimeInputComponent, TnToastComponent, TnToastMock, TnToastPosition, TnToastRef, TnToastService, TnToastTesting, TnToastType, TnTooltipComponent, TnTooltipDirective, TnTooltipHarness, TnTooltipTesting, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeHarness, TnTreeNodeComponent, TnTreeNodeHarness, TnTreeNodeOutletDirective, TnTreeVirtualScrollNodeOutletDirective, TnTreeVirtualScrollViewComponent, TnTreeVirtualScrollViewHarness, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, allowsCurrentDirectorySelection, canonicalizeSize, composeTestId, controlTestId, createFlatTreeControl, createLucideLibrary, createNestedTreeControl, createShortcut, createTnOptionsDataSource, defaultSpriteBasePath, defaultSpriteConfigPath, defaultTreeItemSize, formatSize, formatSizeExact, formatSizeForEditing, getSelectableTypes, injectTnCalendarIntl, injectTnFallbackName, injectTnFormFieldAria, injectTnLabels, isPathWithinRoot, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, normalizeRootPath, optionTestId, parseLabelMarkup, parseSize, parseSizeParts, registerLucideIcons, resolveErrorMessage, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
28634
30015
  //# sourceMappingURL=truenas-ui-components.mjs.map