@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,
|
|
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
|
|
784
|
-
*
|
|
785
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
1386
|
+
const all = this.resolvedOptions();
|
|
819
1387
|
const customFilter = this.filterFn();
|
|
820
1388
|
const max = this.maxResults();
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1513
|
+
const count = this.resolvedOptions().length;
|
|
885
1514
|
this.isOpen();
|
|
886
|
-
this.
|
|
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.
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1071
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
1179
|
-
*
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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]=\"
|
|
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]=\"
|
|
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
|
-
*
|
|
7795
|
-
*
|
|
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
|
|
7824
|
-
//
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
/**
|
|
8047
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
17483
|
-
|
|
18637
|
+
const selectAll = !this.isAllSelected();
|
|
18638
|
+
if (selectAll) {
|
|
18639
|
+
this.selection.select(...this.data());
|
|
17484
18640
|
}
|
|
17485
18641
|
else {
|
|
17486
|
-
this.selection.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
22751
|
-
.
|
|
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}
|
|
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}
|
|
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
|
-
.
|
|
23308
|
-
.
|
|
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}
|
|
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}
|
|
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
|