@truenas/ui-components 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
+ });
7793
8658
  /**
7794
- * Unified option list. Value-mode `options` win; otherwise string-mode
7795
- * `suggestions` are lifted into `{ label: s, value: s }`.
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" }] : []));
8673
+ /**
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;
@@ -16591,6 +17665,35 @@ class TnTableComponent {
16591
17665
  dataSource = input([], ...(ngDevMode ? [{ debugName: "dataSource" }] : []));
16592
17666
  displayedColumns = input([], ...(ngDevMode ? [{ debugName: "displayedColumns" }] : []));
16593
17667
  trackBy = input(undefined, ...(ngDevMode ? [{ debugName: "trackBy" }] : []));
17668
+ /**
17669
+ * Identity function for SELECTION. When set, the table tracks selected rows by the
17670
+ * key this returns rather than by object reference, and the selection survives a
17671
+ * `dataSource` change: paging, sorting, filtering, or a background reload that hands
17672
+ * back freshly-built row objects. Rows selected on a page the table is no longer
17673
+ * showing stay selected and keep coming back in `selectionChange`.
17674
+ *
17675
+ * Without it the table keeps its historical behaviour — any `dataSource` reference
17676
+ * change clears the selection and emits an empty `selectionChange` — because
17677
+ * reference-keyed selections cannot be recognised in a new array and would otherwise
17678
+ * leave the header checkbox counting rows that are no longer on screen.
17679
+ *
17680
+ * This is deliberately separate from {@link trackBy}, which is a rendering concern
17681
+ * (`@for` identity, index-based by default) and is free to stay so on a table whose
17682
+ * selection has to be stable. Pass a key that identifies the ROW, not its position —
17683
+ * and bind a stable member rather than writing the function in the template, since
17684
+ * Angular's expression grammar has no arrow functions:
17685
+ * `[selectionKey]="rowKey"` for a `rowKey = (row: User) => row.id` on the host.
17686
+ *
17687
+ * The select-all checkbox stays scoped to the visible page — it selects and clears
17688
+ * the rows the table is currently showing, and `isAllSelected` reflects only those,
17689
+ * so it never claims to have selected rows the user cannot see.
17690
+ *
17691
+ * Retention is unconditional, and it has to be: the table sees one page and cannot
17692
+ * tell a row that moved off-screen from one that was destroyed. A consumer whose rows
17693
+ * can disappear for good owns that check — reconcile `selectionChange` against the
17694
+ * full list it holds, and call {@link clearSelection} once a batch action is done.
17695
+ */
17696
+ selectionKey = input(undefined, ...(ngDevMode ? [{ debugName: "selectionKey" }] : []));
16594
17697
  emptyMessage = input('No data available', ...(ngDevMode ? [{ debugName: "emptyMessage" }] : []));
16595
17698
  /**
16596
17699
  * Optional second line under `emptyMessage`, for empty states that carry both a headline and an
@@ -16844,8 +17947,29 @@ class TnTableComponent {
16844
17947
  static instanceCount = 0;
16845
17948
  instanceId = `tn-table-${TnTableComponent.instanceCount++}`;
16846
17949
  // --- Selection state ---
17950
+ /**
17951
+ * The rows of the CURRENT `data()` that are selected. With `selectionKey` set this is
17952
+ * the visible slice of {@link selectedByKey} — it is what the row checkboxes and the
17953
+ * select-all state read, so both stay scoped to the page on screen.
17954
+ */
16847
17955
  selection = new SelectionModel(true, []);
17956
+ /**
17957
+ * The whole selection, keyed by `selectionKey`, including rows that are no longer in
17958
+ * `data()`. Empty (and unused) when no `selectionKey` is set.
17959
+ *
17960
+ * It holds the row objects, not just the keys, so `selectionChange` can hand a
17961
+ * consumer the rows it selected on an earlier page — a batch action has to act on
17962
+ * rows, and the table is the only thing that ever saw them.
17963
+ */
17964
+ selectedByKey = new Map();
17965
+ /** Number of selected rows present in the current `data()` — see {@link selection}. */
16848
17966
  selectionCount = signal(0, ...(ngDevMode ? [{ debugName: "selectionCount" }] : []));
17967
+ /**
17968
+ * The array last handed to `selectionChange`, so a reconcile that leaves the selection
17969
+ * exactly as the consumer already sees it can stay quiet — re-running because
17970
+ * `selectionKey`'s identity changed is not news.
17971
+ */
17972
+ lastEmittedSelection = [];
16849
17973
  initialized = false;
16850
17974
  // Column def map as a computed signal
16851
17975
  columnDefMap = computed(() => {
@@ -16859,15 +17983,46 @@ class TnTableComponent {
16859
17983
  return map;
16860
17984
  }, ...(ngDevMode ? [{ debugName: "columnDefMap" }] : []));
16861
17985
  constructor() {
16862
- // Clear selection and expansion when data reference changes
17986
+ // Collapse every open detail row when the data reference changes: the rows the
17987
+ // expanded set holds belong to the array that is going away. This reads `data()`
17988
+ // and nothing else, so an unrelated input — `selectionKey` above all, which a
17989
+ // consumer may swap or re-create on any change-detection pass — cannot close what
17990
+ // the user opened as a side effect of a selection concern.
16863
17991
  effect(() => {
16864
17992
  this.data();
16865
- if (this.initialized) {
16866
- this.selection.clear();
16867
- this.selectionCount.set(0);
16868
- this.expandedRows.set(new Set());
16869
- this.selectionChange.emit([]);
17993
+ if (!this.initialized) {
17994
+ return;
16870
17995
  }
17996
+ this.expandedRows.set(new Set());
17997
+ });
17998
+ // Reconcile the retained selection against the rows now on screen. This has to
17999
+ // react to `selectionKey` as well as to `data()` — a key being taken away must be
18000
+ // noticed promptly — so the emit is guarded by whether the selection the consumer
18001
+ // last saw actually changed, and a key swapped for an equivalent one stays silent.
18002
+ effect(() => {
18003
+ const rows = this.data();
18004
+ const selectionKey = this.selectionKey();
18005
+ if (!this.initialized) {
18006
+ return;
18007
+ }
18008
+ if (!selectionKey) {
18009
+ // Also drop anything retained under a key, so a table whose `selectionKey` is
18010
+ // taken away cannot resurrect those rows if it is given one again.
18011
+ this.clearSelection();
18012
+ return;
18013
+ }
18014
+ // Re-point every retained selection at the row object the new data carries, so a
18015
+ // reload that rebuilt its rows leaves the consumer holding live references rather
18016
+ // than the stale ones it selected. Rows the new data does not carry (another page,
18017
+ // or filtered out) keep the reference they were selected with.
18018
+ for (const row of rows) {
18019
+ const key = selectionKey(row);
18020
+ if (this.selectedByKey.has(key)) {
18021
+ this.selectedByKey.set(key, row);
18022
+ }
18023
+ }
18024
+ this.syncSelectionToData(rows, selectionKey);
18025
+ this.emitSelectionIfChanged();
16871
18026
  });
16872
18027
  // Clear expanded rows when expandable is toggled off
16873
18028
  effect(() => {
@@ -17035,7 +18190,7 @@ class TnTableComponent {
17035
18190
  ngOnInit() {
17036
18191
  this.initialized = true;
17037
18192
  this.destroyRef.onDestroy(() => {
17038
- this.selection.clear();
18193
+ this.clearSelection();
17039
18194
  this.resizeObserver?.disconnect();
17040
18195
  });
17041
18196
  }
@@ -17479,23 +18634,103 @@ class TnTableComponent {
17479
18634
  this.toggleRowSelection(row);
17480
18635
  }
17481
18636
  toggleSelectAll() {
17482
- if (this.isAllSelected()) {
17483
- this.selection.clear();
18637
+ const selectAll = !this.isAllSelected();
18638
+ if (selectAll) {
18639
+ this.selection.select(...this.data());
17484
18640
  }
17485
18641
  else {
17486
- this.selection.select(...this.data());
18642
+ this.selection.clear();
18643
+ }
18644
+ // Only the visible rows move: select-all is scoped to the page, so rows retained
18645
+ // from elsewhere are neither selected nor dropped by it.
18646
+ const selectionKey = this.selectionKey();
18647
+ if (selectionKey) {
18648
+ for (const row of this.data()) {
18649
+ const key = selectionKey(row);
18650
+ if (selectAll) {
18651
+ this.selectedByKey.set(key, row);
18652
+ }
18653
+ else {
18654
+ this.selectedByKey.delete(key);
18655
+ }
18656
+ }
17487
18657
  }
17488
18658
  this.selectionCount.set(this.selection.selected.length);
17489
- this.selectionChange.emit(this.selection.selected);
18659
+ this.emitSelection();
17490
18660
  }
17491
18661
  toggleRowSelection(row) {
17492
18662
  this.selection.toggle(row);
18663
+ const selectionKey = this.selectionKey();
18664
+ if (selectionKey) {
18665
+ const key = selectionKey(row);
18666
+ if (this.selection.isSelected(row)) {
18667
+ this.selectedByKey.set(key, row);
18668
+ }
18669
+ else {
18670
+ this.selectedByKey.delete(key);
18671
+ }
18672
+ }
17493
18673
  this.selectionCount.set(this.selection.selected.length);
17494
- this.selectionChange.emit(this.selection.selected);
18674
+ this.emitSelection();
17495
18675
  }
17496
18676
  isRowSelected(row) {
18677
+ const selectionKey = this.selectionKey();
18678
+ if (selectionKey) {
18679
+ // Read the keyed store rather than the model: a row object rebuilt by a reload is
18680
+ // a different reference, and it should render checked before the reconciling
18681
+ // effect has had a chance to re-point the model at it.
18682
+ return this.selectedByKey.has(selectionKey(row));
18683
+ }
17497
18684
  return this.selection.isSelected(row);
17498
18685
  }
18686
+ /**
18687
+ * Drops the whole selection, including rows retained from pages the table is not
18688
+ * showing. Use it after a batch action consumes the selection.
18689
+ *
18690
+ * Emits an empty `selectionChange` if anything was selected. The caller knows it
18691
+ * cleared, but `selectionChange` is the only way anything else can: a toolbar bound to
18692
+ * a mirrored copy of the selection a layer up would otherwise stay stale until the next
18693
+ * `dataSource` change happened to correct it. Clearing {@link selection} directly is not
18694
+ * enough once `selectionKey` is set: the retained rows would survive and come back on
18695
+ * the next reconcile.
18696
+ */
18697
+ clearSelection() {
18698
+ this.selection.clear();
18699
+ this.selectedByKey.clear();
18700
+ this.selectionCount.set(0);
18701
+ this.emitSelectionIfChanged();
18702
+ }
18703
+ /** Re-applies the retained selection to the rows currently on screen. */
18704
+ syncSelectionToData(rows, selectionKey) {
18705
+ this.selection.clear();
18706
+ const selected = rows.filter((row) => this.selectedByKey.has(selectionKey(row)));
18707
+ if (selected.length) {
18708
+ this.selection.select(...selected);
18709
+ }
18710
+ this.selectionCount.set(this.selection.selected.length);
18711
+ }
18712
+ /** What `selectionChange` carries: the whole selection when keyed, the page's otherwise. */
18713
+ emittedSelection() {
18714
+ return this.selectionKey() ? [...this.selectedByKey.values()] : this.selection.selected;
18715
+ }
18716
+ /** Emits the current selection and records it. For the paths that always change it. */
18717
+ emitSelection() {
18718
+ this.lastEmittedSelection = this.emittedSelection();
18719
+ this.selectionChange.emit(this.lastEmittedSelection);
18720
+ }
18721
+ /**
18722
+ * Emits only if the selection differs from the one last emitted, by row identity — for
18723
+ * the paths that may be re-run without anything having moved.
18724
+ */
18725
+ emitSelectionIfChanged() {
18726
+ const next = this.emittedSelection();
18727
+ const previous = this.lastEmittedSelection;
18728
+ if (next.length === previous.length && next.every((row, index) => row === previous[index])) {
18729
+ return;
18730
+ }
18731
+ this.lastEmittedSelection = next;
18732
+ this.selectionChange.emit(next);
18733
+ }
17499
18734
  // --- Card-mode computeds ---
17500
18735
  /**
17501
18736
  * The column rendered as the card title. The first `displayedColumns` entry
@@ -17627,7 +18862,7 @@ class TnTableComponent {
17627
18862
  return row[column];
17628
18863
  }
17629
18864
  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: [
18865
+ 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 }, 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
18866
  trigger('detailExpand', [
17632
18867
  state('collapsed,void', style({ height: '0px', minHeight: '0', overflow: 'hidden' })),
17633
18868
  state('expanded', style({ height: '*' })),
@@ -17664,7 +18899,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
17664
18899
  '[attr.role]': 'scrollKeyboardReachable() ? "group" : null',
17665
18900
  '[attr.aria-label]': 'scrollKeyboardReachable() ? resolvedScrollRegionLabel() : null',
17666
18901
  }, 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"] }] } });
18902
+ }], 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 }] }], 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
18903
 
17669
18904
  /**
17670
18905
  * Harness for interacting with `tn-table` in tests.
@@ -18821,7 +20056,12 @@ class TnTablePagerComponent {
18821
20056
  pageSize: this.pageSize(),
18822
20057
  };
18823
20058
  this.lastPushedPagination = pagination;
18824
- provider.setPagination(pagination);
20059
+ // The provider gets its OWN object. Handing it this one aliases the echo guard to
20060
+ // the provider's live pagination, and a provider that re-paginates in place — a
20061
+ // filter that resets to page 1, say — silently rewrites what we think we pushed.
20062
+ // The guard then reads the provider's own change back as our echo and leaves the
20063
+ // pager showing a page the provider is no longer serving.
20064
+ provider.setPagination({ ...pagination });
18825
20065
  }
18826
20066
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
18827
20067
  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 });
@@ -22744,11 +23984,18 @@ class TnDateInputComponent {
22744
23984
  offsetY: -8,
22745
23985
  },
22746
23986
  ];
23987
+ // Rigid dimensions, like tn-select and tn-file-picker. With flexible
23988
+ // dimensions on, CDK sizes the bounding box to the whole space between the
23989
+ // trigger and the viewport edge and lets the pane grow into it, so the
23990
+ // calendar (a block whose month table is `width: 100%`) stretched across
23991
+ // the screen. Rigid + push keeps the pane content-sized and fully visible.
22747
23992
  const positionStrategy = this.overlay
22748
23993
  .position()
22749
23994
  .flexibleConnectedTo(this.wrapperEl())
22750
- .withPositions(positions)
22751
- .withPush(false);
23995
+ .withFlexibleDimensions(false)
23996
+ .withViewportMargin(8)
23997
+ .withPush(true)
23998
+ .withPositions(positions);
22752
23999
  this.overlayRef = this.overlay.create({
22753
24000
  positionStrategy,
22754
24001
  scrollStrategy: this.overlay.scrollStrategies.reposition(),
@@ -22772,7 +24019,7 @@ class TnDateInputComponent {
22772
24019
  useExisting: forwardRef(() => TnDateInputComponent),
22773
24020
  multi: true
22774
24021
  }
22775
- ], viewQueries: [{ propertyName: "monthRef", first: true, predicate: ["monthInput"], descendants: true, isSignal: true }, { propertyName: "dayRef", first: true, predicate: ["dayInput"], descendants: true, isSignal: true }, { propertyName: "yearRef", first: true, predicate: ["yearInput"], descendants: true, isSignal: true }, { propertyName: "calendarTemplate", first: true, predicate: ["calendarTemplate"], descendants: true, isSignal: true }, { propertyName: "wrapperEl", first: true, predicate: ["wrapper"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-date-input-container\" tnTestIdType=\"date-input\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-input-wrapper\" style=\"padding-right: 40px;\">\n <!-- Date segments MM/DD/YYYY -->\n <div class=\"tn-date-segment-group\">\n <input\n #monthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('month')\"\n (blur)=\"onSegmentBlur('month')\"\n (keydown)=\"onSegmentKeydown($event, 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #dayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('day')\"\n (blur)=\"onSegmentBlur('day')\"\n (keydown)=\"onSegmentKeydown($event, 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #yearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('year')\"\n (blur)=\"onSegmentBlur('year')\"\n (keydown)=\"onSegmentKeydown($event, 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-input-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"false\"\n [selected]=\"value()\"\n (selectedChange)=\"onDateSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-input-container{position:relative;display:flex;align-items:center}.tn-date-input-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-input-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-input-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-input-toggle:disabled{cursor:not-allowed;opacity:.5}:host ::ng-deep .tn-datepicker-overlay .tn-calendar{background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;min-width:380px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}:host ::ng-deep .tn-datepicker-overlay .tn-calendar .tn-calendar-content{padding:0}\n"], dependencies: [{ kind: "component", type: TnCalendarComponent, selector: "tn-calendar", inputs: ["startView", "selected", "minDate", "maxDate", "dateFilter", "markedDates", "activeDate", "locale", "rangeMode", "selectedRange"], outputs: ["selectedChange", "activeDateChange", "viewChanged", "selectedRangeChange"] }, { kind: "ngmodule", type: OverlayModule }, { kind: "ngmodule", type: PortalModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
24022
+ ], viewQueries: [{ propertyName: "monthRef", first: true, predicate: ["monthInput"], descendants: true, isSignal: true }, { propertyName: "dayRef", first: true, predicate: ["dayInput"], descendants: true, isSignal: true }, { propertyName: "yearRef", first: true, predicate: ["yearInput"], descendants: true, isSignal: true }, { propertyName: "calendarTemplate", first: true, predicate: ["calendarTemplate"], descendants: true, isSignal: true }, { propertyName: "wrapperEl", first: true, predicate: ["wrapper"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-date-input-container\" tnTestIdType=\"date-input\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-input-wrapper\" style=\"padding-right: 40px;\">\n <!-- Date segments MM/DD/YYYY -->\n <div class=\"tn-date-segment-group\">\n <input\n #monthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('month')\"\n (blur)=\"onSegmentBlur('month')\"\n (keydown)=\"onSegmentKeydown($event, 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #dayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('day')\"\n (blur)=\"onSegmentBlur('day')\"\n (keydown)=\"onSegmentKeydown($event, 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #yearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('year')\"\n (blur)=\"onSegmentBlur('year')\"\n (keydown)=\"onSegmentKeydown($event, 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-input-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"false\"\n [selected]=\"value()\"\n (selectedChange)=\"onDateSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-input-container{position:relative;display:flex;align-items:center}.tn-date-input-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-input-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-input-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-input-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"], dependencies: [{ kind: "component", type: TnCalendarComponent, selector: "tn-calendar", inputs: ["startView", "selected", "minDate", "maxDate", "dateFilter", "markedDates", "activeDate", "locale", "rangeMode", "selectedRange"], outputs: ["selectedChange", "activeDateChange", "viewChanged", "selectedRangeChange"] }, { kind: "ngmodule", type: OverlayModule }, { kind: "ngmodule", type: PortalModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
22776
24023
  }
22777
24024
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnDateInputComponent, decorators: [{
22778
24025
  type: Component,
@@ -22784,7 +24031,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
22784
24031
  }
22785
24032
  ], host: {
22786
24033
  'class': 'tn-date-input'
22787
- }, template: "<div class=\"tn-date-input-container\" tnTestIdType=\"date-input\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-input-wrapper\" style=\"padding-right: 40px;\">\n <!-- Date segments MM/DD/YYYY -->\n <div class=\"tn-date-segment-group\">\n <input\n #monthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('month')\"\n (blur)=\"onSegmentBlur('month')\"\n (keydown)=\"onSegmentKeydown($event, 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #dayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('day')\"\n (blur)=\"onSegmentBlur('day')\"\n (keydown)=\"onSegmentKeydown($event, 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #yearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('year')\"\n (blur)=\"onSegmentBlur('year')\"\n (keydown)=\"onSegmentKeydown($event, 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-input-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"false\"\n [selected]=\"value()\"\n (selectedChange)=\"onDateSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-input-container{position:relative;display:flex;align-items:center}.tn-date-input-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-input-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-input-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-input-toggle:disabled{cursor:not-allowed;opacity:.5}:host ::ng-deep .tn-datepicker-overlay .tn-calendar{background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;min-width:380px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}:host ::ng-deep .tn-datepicker-overlay .tn-calendar .tn-calendar-content{padding:0}\n"] }]
24034
+ }, template: "<div class=\"tn-date-input-container\" tnTestIdType=\"date-input\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-input-wrapper\" style=\"padding-right: 40px;\">\n <!-- Date segments MM/DD/YYYY -->\n <div class=\"tn-date-segment-group\">\n <input\n #monthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('month')\"\n (blur)=\"onSegmentBlur('month')\"\n (keydown)=\"onSegmentKeydown($event, 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #dayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('day')\"\n (blur)=\"onSegmentBlur('day')\"\n (keydown)=\"onSegmentKeydown($event, 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #yearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('year')\"\n (blur)=\"onSegmentBlur('year')\"\n (keydown)=\"onSegmentKeydown($event, 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-input-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"false\"\n [selected]=\"value()\"\n (selectedChange)=\"onDateSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-input-container{position:relative;display:flex;align-items:center}.tn-date-input-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-input-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-input-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-input-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"] }]
22788
24035
  }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], dateFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateFilter", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], monthRef: [{ type: i0.ViewChild, args: ['monthInput', { isSignal: true }] }], dayRef: [{ type: i0.ViewChild, args: ['dayInput', { isSignal: true }] }], yearRef: [{ type: i0.ViewChild, args: ['yearInput', { isSignal: true }] }], calendarTemplate: [{ type: i0.ViewChild, args: ['calendarTemplate', { isSignal: true }] }], wrapperEl: [{ type: i0.ViewChild, args: ['wrapper', { isSignal: true }] }] } });
22789
24036
 
22790
24037
  /**
@@ -23301,11 +24548,18 @@ class TnDateRangeInputComponent {
23301
24548
  offsetY: -8,
23302
24549
  },
23303
24550
  ];
24551
+ // Rigid dimensions, like tn-select and tn-file-picker. With flexible
24552
+ // dimensions on, CDK sizes the bounding box to the whole space between the
24553
+ // trigger and the viewport edge and lets the pane grow into it, so the
24554
+ // calendar (a block whose month table is `width: 100%`) stretched across
24555
+ // the screen. Rigid + push keeps the pane content-sized and fully visible.
23304
24556
  const positionStrategy = this.overlay
23305
24557
  .position()
23306
24558
  .flexibleConnectedTo(this.wrapperEl())
23307
- .withPositions(positions)
23308
- .withPush(false);
24559
+ .withFlexibleDimensions(false)
24560
+ .withViewportMargin(8)
24561
+ .withPush(true)
24562
+ .withPositions(positions);
23309
24563
  this.overlayRef = this.overlay.create({
23310
24564
  positionStrategy,
23311
24565
  scrollStrategy: this.overlay.scrollStrategies.reposition(),
@@ -23329,7 +24583,7 @@ class TnDateRangeInputComponent {
23329
24583
  useExisting: forwardRef(() => TnDateRangeInputComponent),
23330
24584
  multi: true
23331
24585
  }
23332
- ], viewQueries: [{ propertyName: "startMonthRef", first: true, predicate: ["startMonthInput"], descendants: true, isSignal: true }, { propertyName: "startDayRef", first: true, predicate: ["startDayInput"], descendants: true, isSignal: true }, { propertyName: "startYearRef", first: true, predicate: ["startYearInput"], descendants: true, isSignal: true }, { propertyName: "endMonthRef", first: true, predicate: ["endMonthInput"], descendants: true, isSignal: true }, { propertyName: "endDayRef", first: true, predicate: ["endDayInput"], descendants: true, isSignal: true }, { propertyName: "endYearRef", first: true, predicate: ["endYearInput"], descendants: true, isSignal: true }, { propertyName: "calendarTemplate", first: true, predicate: ["calendarTemplate"], descendants: true, isSignal: true }, { propertyName: "wrapperEl", first: true, predicate: ["wrapper"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-date-range-container\" tnTestIdType=\"date-range\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-range-wrapper\" style=\"padding-right: 40px;\">\n <!-- Start date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #startMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'month')\"\n (blur)=\"onSegmentBlur('start', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'day')\"\n (blur)=\"onSegmentBlur('start', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'year')\"\n (blur)=\"onSegmentBlur('start', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'year')\">\n </div>\n\n <span class=\"tn-date-range-separator\">\u2013</span>\n\n <!-- End date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #endMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'month')\"\n (blur)=\"onSegmentBlur('end', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'day')\"\n (blur)=\"onSegmentBlur('end', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'year')\"\n (blur)=\"onSegmentBlur('end', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-range-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"true\"\n [selectedRange]=\"value()\"\n (selectedRangeChange)=\"onRangeSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-range-container{position:relative;display:flex;align-items:center}.tn-date-range-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-range-separator{padding:0 .25em;-webkit-user-select:none;user-select:none;color:var(--tn-fg2, #666);flex-shrink:0}.tn-date-range-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-range-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-range-toggle:disabled{cursor:not-allowed;opacity:.5}:host ::ng-deep .tn-datepicker-overlay .tn-calendar{background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;min-width:380px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}:host ::ng-deep .tn-datepicker-overlay .tn-calendar .tn-calendar-content{padding:0}\n"], dependencies: [{ kind: "component", type: TnCalendarComponent, selector: "tn-calendar", inputs: ["startView", "selected", "minDate", "maxDate", "dateFilter", "markedDates", "activeDate", "locale", "rangeMode", "selectedRange"], outputs: ["selectedChange", "activeDateChange", "viewChanged", "selectedRangeChange"] }, { kind: "ngmodule", type: OverlayModule }, { kind: "ngmodule", type: PortalModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
24586
+ ], viewQueries: [{ propertyName: "startMonthRef", first: true, predicate: ["startMonthInput"], descendants: true, isSignal: true }, { propertyName: "startDayRef", first: true, predicate: ["startDayInput"], descendants: true, isSignal: true }, { propertyName: "startYearRef", first: true, predicate: ["startYearInput"], descendants: true, isSignal: true }, { propertyName: "endMonthRef", first: true, predicate: ["endMonthInput"], descendants: true, isSignal: true }, { propertyName: "endDayRef", first: true, predicate: ["endDayInput"], descendants: true, isSignal: true }, { propertyName: "endYearRef", first: true, predicate: ["endYearInput"], descendants: true, isSignal: true }, { propertyName: "calendarTemplate", first: true, predicate: ["calendarTemplate"], descendants: true, isSignal: true }, { propertyName: "wrapperEl", first: true, predicate: ["wrapper"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-date-range-container\" tnTestIdType=\"date-range\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-range-wrapper\" style=\"padding-right: 40px;\">\n <!-- Start date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #startMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'month')\"\n (blur)=\"onSegmentBlur('start', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'day')\"\n (blur)=\"onSegmentBlur('start', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'year')\"\n (blur)=\"onSegmentBlur('start', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'year')\">\n </div>\n\n <span class=\"tn-date-range-separator\">\u2013</span>\n\n <!-- End date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #endMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'month')\"\n (blur)=\"onSegmentBlur('end', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'day')\"\n (blur)=\"onSegmentBlur('end', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'year')\"\n (blur)=\"onSegmentBlur('end', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-range-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"true\"\n [selectedRange]=\"value()\"\n (selectedRangeChange)=\"onRangeSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-range-container{position:relative;display:flex;align-items:center}.tn-date-range-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-range-separator{padding:0 .25em;-webkit-user-select:none;user-select:none;color:var(--tn-fg2, #666);flex-shrink:0}.tn-date-range-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-range-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-range-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"], dependencies: [{ kind: "component", type: TnCalendarComponent, selector: "tn-calendar", inputs: ["startView", "selected", "minDate", "maxDate", "dateFilter", "markedDates", "activeDate", "locale", "rangeMode", "selectedRange"], outputs: ["selectedChange", "activeDateChange", "viewChanged", "selectedRangeChange"] }, { kind: "ngmodule", type: OverlayModule }, { kind: "ngmodule", type: PortalModule }, { kind: "ngmodule", type: A11yModule }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }] });
23333
24587
  }
23334
24588
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnDateRangeInputComponent, decorators: [{
23335
24589
  type: Component,
@@ -23341,7 +24595,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
23341
24595
  }
23342
24596
  ], host: {
23343
24597
  'class': 'tn-date-range-input'
23344
- }, template: "<div class=\"tn-date-range-container\" tnTestIdType=\"date-range\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-range-wrapper\" style=\"padding-right: 40px;\">\n <!-- Start date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #startMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'month')\"\n (blur)=\"onSegmentBlur('start', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'day')\"\n (blur)=\"onSegmentBlur('start', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'year')\"\n (blur)=\"onSegmentBlur('start', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'year')\">\n </div>\n\n <span class=\"tn-date-range-separator\">\u2013</span>\n\n <!-- End date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #endMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'month')\"\n (blur)=\"onSegmentBlur('end', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'day')\"\n (blur)=\"onSegmentBlur('end', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'year')\"\n (blur)=\"onSegmentBlur('end', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-range-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"true\"\n [selectedRange]=\"value()\"\n (selectedRangeChange)=\"onRangeSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-range-container{position:relative;display:flex;align-items:center}.tn-date-range-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-range-separator{padding:0 .25em;-webkit-user-select:none;user-select:none;color:var(--tn-fg2, #666);flex-shrink:0}.tn-date-range-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-range-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-range-toggle:disabled{cursor:not-allowed;opacity:.5}:host ::ng-deep .tn-datepicker-overlay .tn-calendar{background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;min-width:380px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}:host ::ng-deep .tn-datepicker-overlay .tn-calendar .tn-calendar-content{padding:0}\n"] }]
24598
+ }, template: "<div class=\"tn-date-range-container\" tnTestIdType=\"date-range\" [tnTestId]=\"testId()\">\n <div #wrapper ixInput class=\"tn-date-range-wrapper\" style=\"padding-right: 40px;\">\n <!-- Start date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #startMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'month')\"\n (blur)=\"onSegmentBlur('start', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'day')\"\n (blur)=\"onSegmentBlur('start', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #startYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('start', 'year')\"\n (blur)=\"onSegmentBlur('start', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'start', 'year')\">\n </div>\n\n <span class=\"tn-date-range-separator\">\u2013</span>\n\n <!-- End date segments -->\n <div class=\"tn-date-segment-group\">\n <input\n #endMonthInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-month\"\n placeholder=\"MM\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'month')\"\n (blur)=\"onSegmentBlur('end', 'month')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'month')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endDayInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-day\"\n placeholder=\"DD\"\n maxlength=\"2\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'day')\"\n (blur)=\"onSegmentBlur('end', 'day')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'day')\">\n <span class=\"tn-date-segment-separator\">/</span>\n <input\n #endYearInput\n type=\"text\"\n class=\"tn-date-segment tn-date-segment-year\"\n placeholder=\"YYYY\"\n maxlength=\"4\"\n [disabled]=\"isDisabled()\"\n (focus)=\"onSegmentFocus('end', 'year')\"\n (blur)=\"onSegmentBlur('end', 'year')\"\n (keydown)=\"onSegmentKeydown($event, 'end', 'year')\">\n </div>\n\n <button\n type=\"button\"\n class=\"tn-date-range-toggle\"\n aria-label=\"Open calendar\"\n [disabled]=\"isDisabled()\"\n (click)=\"openDatepicker()\">\n <span aria-hidden=\"true\">\uD83D\uDCC5</span>\n </button>\n </div>\n\n <ng-template #calendarTemplate>\n <tn-calendar\n class=\"tn-calendar\"\n [startView]=\"'month'\"\n [rangeMode]=\"true\"\n [selectedRange]=\"value()\"\n (selectedRangeChange)=\"onRangeSelected($event)\" />\n </ng-template>\n</div>\n", styles: [":host{display:block;width:100%}.tn-date-range-container{position:relative;display:flex;align-items:center}.tn-date-range-wrapper{display:flex;align-items:center;width:100%;position:relative}.tn-date-segment-group{display:flex;align-items:center}.tn-date-segment{background:transparent;border:none;outline:none;font:inherit;color:inherit;padding:0;min-width:0;text-align:center;width:2.6ch}.tn-date-segment::placeholder{color:var(--tn-alt-fg1, #999);opacity:1}.tn-date-segment:focus{outline:none;background:var(--tn-bg2, rgba(0, 0, 0, .05));border-radius:2px}.tn-date-segment:focus::placeholder{opacity:0}.tn-date-segment.tn-date-segment-year{width:4ch}.tn-date-segment-separator{padding:0 2px;-webkit-user-select:none;user-select:none;color:var(--tn-alt-fg1, #999)}.tn-date-range-separator{padding:0 .25em;-webkit-user-select:none;user-select:none;color:var(--tn-fg2, #666);flex-shrink:0}.tn-date-range-toggle{position:absolute;right:8px;z-index:2;pointer-events:auto;background:transparent;border:none;cursor:pointer;padding:4px;font-size:1rem}.tn-date-range-toggle:hover{background:var(--tn-bg2, #f0f0f0);border-radius:4px}.tn-date-range-toggle:disabled{cursor:not-allowed;opacity:.5}.tn-calendar{box-sizing:content-box;width:calc(7 * var(--calendar-cell-size));background:var(--tn-bg1, white);border:1px solid var(--tn-lines, #e0e0e0);border-radius:8px;box-shadow:0 4px 12px #00000026;padding:24px;--calendar-cell-size: 48px;--calendar-header-height: 44px;--calendar-cell-font-size: 1rem;--calendar-header-font-size: 14px}.tn-calendar ::ng-deep .tn-calendar-content{padding:0}\n"] }]
23345
24599
  }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], startMonthRef: [{ type: i0.ViewChild, args: ['startMonthInput', { isSignal: true }] }], startDayRef: [{ type: i0.ViewChild, args: ['startDayInput', { isSignal: true }] }], startYearRef: [{ type: i0.ViewChild, args: ['startYearInput', { isSignal: true }] }], endMonthRef: [{ type: i0.ViewChild, args: ['endMonthInput', { isSignal: true }] }], endDayRef: [{ type: i0.ViewChild, args: ['endDayInput', { isSignal: true }] }], endYearRef: [{ type: i0.ViewChild, args: ['endYearInput', { isSignal: true }] }], calendarTemplate: [{ type: i0.ViewChild, args: ['calendarTemplate', { isSignal: true }] }], wrapperEl: [{ type: i0.ViewChild, args: ['wrapper', { isSignal: true }] }] } });
23346
24600
 
23347
24601
  /**
@@ -25751,7 +27005,7 @@ class TnSidePanelComponent {
25751
27005
  return;
25752
27006
  }
25753
27007
  guard()
25754
- .pipe(take(1), takeUntilDestroyed(this.destroyRef))
27008
+ .pipe(take$1(1), takeUntilDestroyed(this.destroyRef))
25755
27009
  .subscribe((canClose) => {
25756
27010
  if (canClose) {
25757
27011
  this.open.set(false);
@@ -26697,7 +27951,7 @@ class TnFilePickerPopupComponent {
26697
27951
  return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()} ${timePart}`;
26698
27952
  }
26699
27953
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
26700
- 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" }] });
27954
+ 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", "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" }] });
26701
27955
  }
26702
27956
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, decorators: [{
26703
27957
  type: Component,
@@ -28616,5 +29870,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
28616
29870
  * Generated bundle index. Do not edit.
28617
29871
  */
28618
29872
 
28619
- 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 };
29873
+ 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 };
28620
29874
  //# sourceMappingURL=truenas-ui-components.mjs.map