@truenas/ui-components 0.4.12 → 0.5.1

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.
@@ -2838,7 +2838,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
2838
2838
  */
2839
2839
  const INTERACTIVE_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]';
2840
2840
  class TnTooltipDirective {
2841
- message = input('', { ...(ngDevMode ? { debugName: "message" } : {}), alias: 'tnTooltip' });
2841
+ // Nullable by contract: consumers bind expressions like [tnTooltip]="reason ?? null",
2842
+ // and under strictTemplates a string-only input would reject them at compile time.
2843
+ // The transform normalises at the boundary so every read site still sees a string.
2844
+ message = input('', { ...(ngDevMode ? { debugName: "message" } : {}), alias: 'tnTooltip',
2845
+ transform: (value) => value ?? '' });
2842
2846
  position = input('above', { ...(ngDevMode ? { debugName: "position" } : {}), alias: 'tnTooltipPosition' });
2843
2847
  disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : {}), alias: 'tnTooltipDisabled' });
2844
2848
  showDelay = input(0, { ...(ngDevMode ? { debugName: "showDelay" } : {}), alias: 'tnTooltipShowDelay' });
@@ -11244,10 +11248,51 @@ class TnTableColumnDirective {
11244
11248
  name = input.required({ ...(ngDevMode ? { debugName: "name" } : {}), alias: 'tnColumnDef' });
11245
11249
  sortable = input(false, ...(ngDevMode ? [{ debugName: "sortable" }] : []));
11246
11250
  width = input(undefined, ...(ngDevMode ? [{ debugName: "width" }] : []));
11251
+ /**
11252
+ * Human-readable label for the column. Used as the default header text in the
11253
+ * regular table layout AND the default field label in card mode, so a single
11254
+ * annotation serves both — prefer this over a text-only `tnHeaderCellDef`.
11255
+ * A `tnHeaderCellDef` template still overrides the header; `cardLabel` still
11256
+ * overrides the card field label. Falls back to the column `name` when unset.
11257
+ */
11258
+ label = input(undefined, ...(ngDevMode ? [{ debugName: "label" }] : []));
11259
+ /**
11260
+ * Relative importance of this column in card mode (see `mobileLayout` on
11261
+ * `tn-table`). Higher numbers render first; fields ranked beyond
11262
+ * `cardPrimaryCount` fold under a "More fields" disclosure. Defaults to `0`,
11263
+ * in which case columns fall back to their `displayedColumns` order. Has no
11264
+ * effect in the regular (wide) table layout.
11265
+ */
11266
+ cardPriority = input(0, ...(ngDevMode ? [{ debugName: "cardPriority" }] : []));
11267
+ /**
11268
+ * Marks this column as the card's title in card mode — rendered prominently
11269
+ * in the card header alongside any row actions, never as a label/value field.
11270
+ * If no column sets this, the first displayed column that isn't `cardHidden` is
11271
+ * used, falling back to the first displayed column when every column is hidden —
11272
+ * a column deliberately kept off the card is not promoted to its most prominent
11273
+ * slot.
11274
+ */
11275
+ cardTitle = input(false, ...(ngDevMode ? [{ debugName: "cardTitle" }] : []));
11276
+ /**
11277
+ * When true, this column is omitted entirely from the card layout (e.g. a
11278
+ * redundant id, or a column whose meaning is already conveyed by the title).
11279
+ * Ignored in the regular table layout.
11280
+ */
11281
+ cardHidden = input(false, ...(ngDevMode ? [{ debugName: "cardHidden" }] : []));
11282
+ /**
11283
+ * Overrides the field label shown for this column in card mode. Defaults to the
11284
+ * column's `label`, then to the column name.
11285
+ *
11286
+ * A `tnHeaderCellDef` template is NOT used here — it is an `ng-template`, not
11287
+ * text, so there is nothing to read out. A column whose header comes only from
11288
+ * that template renders its bare column name as the card field label; set
11289
+ * `label` (or `cardLabel`) to give the card something readable.
11290
+ */
11291
+ cardLabel = input(undefined, ...(ngDevMode ? [{ debugName: "cardLabel" }] : []));
11247
11292
  headerTemplate = contentChild(TnHeaderCellDefDirective, { ...(ngDevMode ? { debugName: "headerTemplate" } : {}), read: TemplateRef });
11248
11293
  cellTemplate = contentChild(TnCellDefDirective, { ...(ngDevMode ? { debugName: "cellTemplate" } : {}), read: TemplateRef });
11249
11294
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTableColumnDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
11250
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.0", type: TnTableColumnDirective, isStandalone: true, selector: "[tnColumnDef]", inputs: { name: { classPropertyName: "name", publicName: "tnColumnDef", isSignal: true, isRequired: true, transformFunction: null }, sortable: { classPropertyName: "sortable", publicName: "sortable", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "headerTemplate", first: true, predicate: TnHeaderCellDefDirective, descendants: true, read: TemplateRef, isSignal: true }, { propertyName: "cellTemplate", first: true, predicate: TnCellDefDirective, descendants: true, read: TemplateRef, isSignal: true }], exportAs: ["tnColumnDef"], ngImport: i0 });
11295
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.1.0", type: TnTableColumnDirective, isStandalone: true, selector: "[tnColumnDef]", inputs: { name: { classPropertyName: "name", publicName: "tnColumnDef", isSignal: true, isRequired: true, transformFunction: null }, sortable: { classPropertyName: "sortable", publicName: "sortable", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, cardPriority: { classPropertyName: "cardPriority", publicName: "cardPriority", isSignal: true, isRequired: false, transformFunction: null }, cardTitle: { classPropertyName: "cardTitle", publicName: "cardTitle", isSignal: true, isRequired: false, transformFunction: null }, cardHidden: { classPropertyName: "cardHidden", publicName: "cardHidden", isSignal: true, isRequired: false, transformFunction: null }, cardLabel: { classPropertyName: "cardLabel", publicName: "cardLabel", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "headerTemplate", first: true, predicate: TnHeaderCellDefDirective, descendants: true, read: TemplateRef, isSignal: true }, { propertyName: "cellTemplate", first: true, predicate: TnCellDefDirective, descendants: true, read: TemplateRef, isSignal: true }], exportAs: ["tnColumnDef"], ngImport: i0 });
11251
11296
  }
11252
11297
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTableColumnDirective, decorators: [{
11253
11298
  type: Directive,
@@ -11256,7 +11301,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11256
11301
  standalone: true,
11257
11302
  exportAs: 'tnColumnDef',
11258
11303
  }]
11259
- }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "tnColumnDef", required: true }] }], sortable: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortable", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], headerTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnHeaderCellDefDirective), { ...{
11304
+ }], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "tnColumnDef", required: true }] }], sortable: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortable", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], cardPriority: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardPriority", required: false }] }], cardTitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardTitle", required: false }] }], cardHidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardHidden", required: false }] }], cardLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "cardLabel", required: false }] }], headerTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnHeaderCellDefDirective), { ...{
11260
11305
  read: TemplateRef,
11261
11306
  }, isSignal: true }] }], cellTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TnCellDefDirective), { ...{ read: TemplateRef }, isSignal: true }] }] } });
11262
11307
  /**
@@ -11285,6 +11330,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11285
11330
  standalone: true,
11286
11331
  }]
11287
11332
  }] });
11333
+ /**
11334
+ * Directive to define a row's action controls (e.g. edit/delete buttons).
11335
+ *
11336
+ * Place inside `tn-table` to render a trailing actions column, pinned to the right edge
11337
+ * in `scroll` mode (see `mobileLayout` on `tn-table`) and scrolling with the rest of the
11338
+ * table above `cardBreakpoint`. In card mode the same template is rendered inline beside
11339
+ * the card title, so row actions stay reachable at every viewport width. The template
11340
+ * receives the row as its implicit context.
11341
+ *
11342
+ * @example
11343
+ * ```html
11344
+ * <tn-table [dataSource]="data" [displayedColumns]="columns">
11345
+ * ...column defs...
11346
+ * <ng-template tnRowActionsDef let-row>
11347
+ * <tn-icon-button name="mat-edit" ariaLabel="Edit" (click)="edit(row)" />
11348
+ * <tn-icon-button name="mat-delete" ariaLabel="Delete" (click)="remove(row)" />
11349
+ * </ng-template>
11350
+ * </tn-table>
11351
+ * ```
11352
+ */
11353
+ class TnRowActionsDefDirective {
11354
+ template = inject((TemplateRef));
11355
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnRowActionsDefDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
11356
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.0", type: TnRowActionsDefDirective, isStandalone: true, selector: "[tnRowActionsDef]", ngImport: i0 });
11357
+ }
11358
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnRowActionsDefDirective, decorators: [{
11359
+ type: Directive,
11360
+ args: [{
11361
+ selector: '[tnRowActionsDef]',
11362
+ standalone: true,
11363
+ }]
11364
+ }] });
11288
11365
 
11289
11366
  /**
11290
11367
  * Animation duration for detail row expand/collapse.
@@ -11307,6 +11384,16 @@ function getExpandDuration() {
11307
11384
  }
11308
11385
  class TnTableComponent {
11309
11386
  destroyRef = inject(DestroyRef);
11387
+ elementRef = inject((ElementRef));
11388
+ injector = inject(Injector);
11389
+ /**
11390
+ * Element that had focus when `loading` went true, so it can be restored afterwards.
11391
+ *
11392
+ * `inert` blurs whatever is focused inside it and refuses focus while set, so without this
11393
+ * a keyboard user who pressed Enter on a sortable header — the flow that *starts* most
11394
+ * loads — was dropped to `<body>` and had to tab back from the top of the document.
11395
+ */
11396
+ focusBeforeLoading = null;
11310
11397
  // --- Core inputs ---
11311
11398
  dataSource = input([], ...(ngDevMode ? [{ debugName: "dataSource" }] : []));
11312
11399
  displayedColumns = input([], ...(ngDevMode ? [{ debugName: "displayedColumns" }] : []));
@@ -11374,6 +11461,12 @@ class TnTableComponent {
11374
11461
  * When true, shows a spinner overlay over the table. Existing rows remain
11375
11462
  * visible (dimmed) so reloads don't cause layout jumps; if there are no rows
11376
11463
  * yet, the spinner replaces the empty state.
11464
+ *
11465
+ * The rendered surfaces are `inert` while this is true, so they leave the tab order *and*
11466
+ * the accessibility tree — a screen-reader user hears the overlay's "Loading..." status
11467
+ * rather than the stale rows. That is deliberate: `pointer-events` alone left every row,
11468
+ * header and projected control activatable by keyboard against data being replaced.
11469
+ * Focus is captured on the way in and restored on the way out.
11377
11470
  */
11378
11471
  loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
11379
11472
  /** Accessible label announced while loading. */
@@ -11382,6 +11475,16 @@ class TnTableComponent {
11382
11475
  * When true, rows become keyboard-focusable (tabindex=0) and clicking or
11383
11476
  * pressing Enter/Space emits `rowClick`. Use this for "click row to view
11384
11477
  * details" patterns. Independent of `selectable` (checkbox) and `expandable`.
11478
+ *
11479
+ * Accessibility limitation in card mode: a card is `role="listitem"`, and the
11480
+ * roles that would announce it as activatable (`button`, `option`) have
11481
+ * presentational children, which would hide the card's own checkbox, row
11482
+ * actions and "Details" toggle from assistive tech. So a clickable card is
11483
+ * focusable and responds to Enter/Space, but is not announced as a control.
11484
+ * Active state is conveyed with `aria-current`, and expansion with
11485
+ * `aria-expanded` when `expandOnRowClick` is set. If an action must be
11486
+ * discoverable by screen-reader users, project an explicit control through
11487
+ * `[tnRowActionsDef]` rather than relying on card activation alone.
11385
11488
  */
11386
11489
  clickable = input(false, ...(ngDevMode ? [{ debugName: "clickable" }] : []));
11387
11490
  /**
@@ -11389,6 +11492,12 @@ class TnTableComponent {
11389
11492
  * chevron. Requires `clickable` — that is what makes rows activatable at all — and `expandable`;
11390
11493
  * rows the `isRowExpandable` predicate rejects are unaffected, since `toggleRowExpansion` gates
11391
11494
  * on it. `rowClick` still emits, so a consumer can both expand and react to the click.
11495
+ *
11496
+ * Applies in card mode too, where activating the card toggles its detail section. Both controls
11497
+ * report the state: the card carries `aria-expanded` while it is the trigger (`listitem` does
11498
+ * permit it, unlike `aria-selected`), and the "Details" button carries it unconditionally,
11499
+ * because that button is what a screen-reader user reaches by tabbing. The redundancy is
11500
+ * deliberate — see the comment above the button in the template.
11392
11501
  */
11393
11502
  expandOnRowClick = input(false, ...(ngDevMode ? [{ debugName: "expandOnRowClick" }] : []));
11394
11503
  /**
@@ -11443,9 +11552,52 @@ class TnTableComponent {
11443
11552
  if (!perColumn || !this.fixedLayout()) {
11444
11553
  return null;
11445
11554
  }
11446
- return `calc(${perColumn} * ${this.effectiveDisplayedColumns().length})`;
11555
+ // The actions column adds its own fixed width rather than a `minColumnWidth` share, because
11556
+ // that is exactly what it claims under `fixedLayout` — and the cell is `border-box`, so the
11557
+ // value added here is its real outer width. (`--tn-table-select-width` and
11558
+ // `--tn-table-expand-width` are treated as ordinary shares instead: they over-reserve
11559
+ // slightly, which is the behavior this component has always had and has tests for. Unifying
11560
+ // all three belongs in its own change.)
11561
+ //
11562
+ // No `var()` fallback on purpose. A duplicated default here is a second source of truth for a
11563
+ // number the stylesheet owns, and it drifted the moment that default changed — leaving the
11564
+ // exact value whose shortfall clipped the actions column sitting in code labelled "the
11565
+ // default". `:host` always defines the property, so the only way to reach a fallback is to
11566
+ // unset it deliberately, and an invalid calc() (no floor) is a more debuggable outcome than a
11567
+ // floor computed from a stale constant.
11568
+ const columns = `${perColumn} * ${this.effectiveDisplayedColumns().length}`;
11569
+ return this.rowActionsDef()
11570
+ ? `calc(${columns} + var(--tn-table-actions-width))`
11571
+ : `calc(${columns})`;
11447
11572
  }, ...(ngDevMode ? [{ debugName: "resolvedMinWidth" }] : []));
11573
+ // --- Responsive (card) inputs ---
11574
+ /**
11575
+ * How the table adapts when its container is narrower than `cardBreakpoint`.
11576
+ * See {@link TnTableMobileLayout}. Defaults to `scroll`, which preserves the
11577
+ * existing horizontal-scroll behavior; set to `cards` to opt into the stacked
11578
+ * card layout.
11579
+ */
11580
+ mobileLayout = input('scroll', ...(ngDevMode ? [{ debugName: "mobileLayout" }] : []));
11581
+ /**
11582
+ * Container width (px) below which `mobileLayout` takes effect. The component
11583
+ * observes its own host width (via `ResizeObserver`), so this responds to the
11584
+ * available container — a table in a narrow sidebar adapts on a wide screen.
11585
+ *
11586
+ * Measured against the host's **content box** — its border and padding are excluded — so a
11587
+ * `[bordered]` table crosses the threshold at the same content width as an unbordered one.
11588
+ */
11589
+ cardBreakpoint = input(640, ...(ngDevMode ? [{ debugName: "cardBreakpoint" }] : []));
11590
+ /**
11591
+ * Number of fields shown directly on each card before the rest fold under a
11592
+ * "More fields" disclosure. The title column is not counted. Defaults to `3`.
11593
+ */
11594
+ cardPrimaryCount = input(3, ...(ngDevMode ? [{ debugName: "cardPrimaryCount" }] : []));
11448
11595
  // --- Outputs ---
11596
+ /**
11597
+ * Emits the resulting sort state whenever the user changes it, from either layout.
11598
+ * Clearing a sort emits `{ column: '', direction: '' }` rather than naming the column
11599
+ * that was cleared — see {@link TnSortEvent}.
11600
+ */
11449
11601
  sortChange = output();
11450
11602
  selectionChange = output();
11451
11603
  /** Emits the row when a clickable row is activated (click or Enter/Space). */
@@ -11460,6 +11612,11 @@ class TnTableComponent {
11460
11612
  // --- Content queries ---
11461
11613
  columnDefs = contentChildren(TnTableColumnDirective, ...(ngDevMode ? [{ debugName: "columnDefs" }] : []));
11462
11614
  detailRowDef = contentChild(TnDetailRowDefDirective, ...(ngDevMode ? [{ debugName: "detailRowDef" }] : []));
11615
+ rowActionsDef = contentChild(TnRowActionsDefDirective, ...(ngDevMode ? [{ debugName: "rowActionsDef" }] : []));
11616
+ // --- Responsive state ---
11617
+ /** Observed host width in px; drives the switch into card mode. */
11618
+ containerWidth = signal(Infinity, ...(ngDevMode ? [{ debugName: "containerWidth" }] : []));
11619
+ resizeObserver;
11463
11620
  // --- Sort state ---
11464
11621
  /**
11465
11622
  * Sorted column and direction. Two-way bindable (`[(sortColumn)]`), so a consumer that owns the
@@ -11475,6 +11632,9 @@ class TnTableComponent {
11475
11632
  * approach could address this.
11476
11633
  */
11477
11634
  expandedRows = signal(new Set(), ...(ngDevMode ? [{ debugName: "expandedRows" }] : []));
11635
+ // Per-instance prefix for generated DOM ids, so two tables on a page can't collide.
11636
+ static instanceCount = 0;
11637
+ instanceId = `tn-table-${TnTableComponent.instanceCount++}`;
11478
11638
  // --- Selection state ---
11479
11639
  selection = new SelectionModel(true, []);
11480
11640
  selectionCount = signal(0, ...(ngDevMode ? [{ debugName: "selectionCount" }] : []));
@@ -11535,14 +11695,147 @@ class TnTableComponent {
11535
11695
  this.expandedRows.set(next);
11536
11696
  }
11537
11697
  });
11698
+ // Save focus on the way into `loading`, restore it on the way out. The restore is
11699
+ // deferred to after the next render because the surfaces only drop `inert` when the
11700
+ // template updates, and focusing an element still inside an inert subtree is a no-op.
11701
+ effect(() => {
11702
+ const host = this.elementRef.nativeElement;
11703
+ if (typeof document === 'undefined') {
11704
+ return;
11705
+ }
11706
+ if (this.loading()) {
11707
+ const active = document.activeElement;
11708
+ // `<body>` is what `inert` leaves behind, not evidence the user moved on. Signals are
11709
+ // glitch-free, so a false -> true flip inside one task (a store that clears loading on
11710
+ // a cache hit and re-sets it for the network request) runs this effect once, seeing
11711
+ // only `true` — and overwriting a still-valid saved element with null there would lose
11712
+ // the focus permanently, which is the symptom this whole effect exists to prevent.
11713
+ if (active && active !== document.body) {
11714
+ this.focusBeforeLoading = host.contains(active) ? active : null;
11715
+ }
11716
+ return;
11717
+ }
11718
+ const previous = this.focusBeforeLoading;
11719
+ if (!previous) {
11720
+ return;
11721
+ }
11722
+ this.focusBeforeLoading = null;
11723
+ afterNextRender(() => {
11724
+ // Never steal focus back from wherever the user moved on to.
11725
+ if (document.activeElement !== document.body) {
11726
+ return;
11727
+ }
11728
+ if (previous.isConnected) {
11729
+ previous.focus();
11730
+ if (document.activeElement === previous) {
11731
+ return;
11732
+ }
11733
+ // Focus was refused. `loading()` discriminates why: true means a second load
11734
+ // re-applied `inert` before this hook ran, so keep the element for next time.
11735
+ // False means it is simply no longer focusable — a `<th>` reused for a
11736
+ // non-sortable column (the header `@for` tracks `$index`), a projected action
11737
+ // that came back `disabled` — and re-arming there would retry the same dead
11738
+ // element on every load, parking the user on `<body>` indefinitely. Reading the
11739
+ // signal here is untracked, so it costs nothing.
11740
+ if (this.loading()) {
11741
+ this.focusBeforeLoading = previous;
11742
+ return;
11743
+ }
11744
+ }
11745
+ // The remembered element is gone: a reload dropped the focused row under an
11746
+ // id-based `trackBy`, or the container flipped to cards mid-load. Keep focus in the
11747
+ // region rather than leaving the user at the top of the document.
11748
+ if (!host.hasAttribute('tabindex')) {
11749
+ host.setAttribute('tabindex', '-1');
11750
+ }
11751
+ host.focus();
11752
+ }, { injector: this.injector });
11753
+ });
11754
+ // Measure the host width to drive card/scroll mode. The initial read is
11755
+ // taken in `afterNextRender` (guaranteed post-layout, so we get the real
11756
+ // width rather than a pre-layout 0), then a `ResizeObserver` keeps it in
11757
+ // sync as the container resizes. Both are browser-only — `afterNextRender`
11758
+ // does not run during SSR and `ResizeObserver` is feature-detected.
11759
+ afterNextRender(() => {
11760
+ const host = this.elementRef.nativeElement;
11761
+ this.measureContainer(host);
11762
+ if (typeof ResizeObserver !== 'undefined') {
11763
+ this.resizeObserver = new ResizeObserver((entries) => {
11764
+ const width = entries[0]?.contentRect.width;
11765
+ if (typeof width === 'number') {
11766
+ // `|| Infinity` for the same reason `measureContainer` applies it: a 0 width
11767
+ // means unmeasurable, not narrow. An element inside a `display: none`
11768
+ // container reports 0x0, so without this a table in a tab that gets hidden
11769
+ // flips to card mode and tears down anything keyed off `isCardMode()` — the
11770
+ // open `<details>` state, for one — for a resize that never happened.
11771
+ this.containerWidth.set(width || Infinity);
11772
+ }
11773
+ });
11774
+ this.resizeObserver.observe(host);
11775
+ }
11776
+ });
11777
+ }
11778
+ /**
11779
+ * Reads the host's current **content-box** width into `containerWidth`.
11780
+ *
11781
+ * Content box specifically, to match the `ResizeObserver` callback's `contentRect`. The
11782
+ * initial read used `getBoundingClientRect()` — the border box — so the two paths measured
11783
+ * different quantities, and `[bordered]` puts a 1px border on each side: a bordered table
11784
+ * whose content box is 639px rendered as a table on first paint (641 is not < 640) and
11785
+ * flipped to cards on the observer's first callback, with no resize having happened. Host
11786
+ * padding widens the gap further.
11787
+ */
11788
+ measureContainer(host) {
11789
+ // The computed `width` is the *used* content-box width — content box even under
11790
+ // `box-sizing: border-box`, already net of the border and of any vertical scrollbar
11791
+ // gutter, and untransformed. That matches `contentRect.width` down to the fraction,
11792
+ // which is the point: `:host` sets `overflow-x: auto` (which promotes `overflow-y` to
11793
+ // auto), so on a platform with classic scrollbars a border-box width measured ~15px
11794
+ // wider here than the observer did, and `getBoundingClientRect()` reports the
11795
+ // *transformed* size, so a table inside a scaled dialog mis-measured as well.
11796
+ //
11797
+ // `clientWidth` minus horizontal padding gets all of that right too, but it is
11798
+ // integer-rounded, and the rounding is not neutral at the boundary: a 639.6px content
11799
+ // box against the default 640 breakpoint reads as 640 here and 639.6 on the observer's
11800
+ // first callback, so the table renders for a frame and then flips to cards with no
11801
+ // resize having happened — the first-paint flip this measurement exists to remove.
11802
+ //
11803
+ // Only a `px` value is a used width, and the unit check is load-bearing rather than
11804
+ // defensive. `width` resolves to the *used* value only for an element that has a layout
11805
+ // box; without one it resolves to the computed value, and `:host` declares `width: 100%`
11806
+ // — so a table built inside a `display: none` container (an inactive tab body, a
11807
+ // collapsed panel) resolves to the string '100%'. Parsed bare that is the number 100,
11808
+ // which is under every sane `cardBreakpoint`: a container that was never measured would
11809
+ // render as cards. Rejecting the non-px value falls through to `clientWidth`, which is 0
11810
+ // there, and on to the 0 -> Infinity guard below — the same answer the `ResizeObserver`
11811
+ // callback gives a 0x0 contentRect. jsdom takes this path too: with no rule matching the
11812
+ // host it resolves `width` to the empty string rather than filling in an initial value
11813
+ // (checked against jsdom 26.1.0, the version jest-environment-jsdom resolves here).
11814
+ const hostStyle = getComputedStyle(host);
11815
+ const resolvedWidth = hostStyle.width;
11816
+ const usedWidth = resolvedWidth.endsWith('px') ? parseFloat(resolvedWidth) : NaN;
11817
+ let contentWidth;
11818
+ if (Number.isFinite(usedWidth)) {
11819
+ contentWidth = usedWidth;
11820
+ }
11821
+ else {
11822
+ const padding = parseFloat(hostStyle.paddingLeft || '0') + parseFloat(hostStyle.paddingRight || '0');
11823
+ contentWidth = host.clientWidth - (Number.isNaN(padding) ? 0 : padding);
11824
+ }
11825
+ this.containerWidth.set(contentWidth > 0 ? contentWidth : Infinity);
11538
11826
  }
11539
11827
  ngOnInit() {
11540
11828
  this.initialized = true;
11541
- // Clean up SelectionModel on destroy
11542
11829
  this.destroyRef.onDestroy(() => {
11543
11830
  this.selection.clear();
11831
+ this.resizeObserver?.disconnect();
11544
11832
  });
11545
11833
  }
11834
+ // --- Responsive computeds ---
11835
+ /** True when the layout should collapse rows into cards. */
11836
+ isCardMode = computed(() => this.mobileLayout() === 'cards' && this.containerWidth() < this.cardBreakpoint(), ...(ngDevMode ? [{ debugName: "isCardMode" }] : []));
11837
+ /** True when the layout should keep the table but pin edge columns and scroll. */
11838
+ isScrollMode = computed(() => this.mobileLayout() === 'scroll' && this.containerWidth() < this.cardBreakpoint(), ...(ngDevMode ? [{ debugName: "isScrollMode" }] : []));
11546
11839
  // --- Computed ---
11547
11840
  data = computed(() => {
11548
11841
  const source = this.dataSource();
@@ -11561,6 +11854,13 @@ class TnTableComponent {
11561
11854
  }
11562
11855
  return cols;
11563
11856
  }, ...(ngDevMode ? [{ debugName: "effectiveDisplayedColumns" }] : []));
11857
+ /**
11858
+ * Every column the table actually renders, including the trailing actions column, which
11859
+ * `effectiveDisplayedColumns` does not track because it comes from a content template rather
11860
+ * than `displayedColumns`. Used for the detail row's `colspan`, so a detail row spans the full
11861
+ * width no matter which structural columns are on.
11862
+ */
11863
+ totalColumnCount = computed(() => this.effectiveDisplayedColumns().length + (this.rowActionsDef() ? 1 : 0), ...(ngDevMode ? [{ debugName: "totalColumnCount" }] : []));
11564
11864
  isAllSelected = computed(() => {
11565
11865
  const numSelected = this.selectionCount();
11566
11866
  const numRows = this.data().length;
@@ -11583,13 +11883,15 @@ class TnTableComponent {
11583
11883
  if (!colDef?.sortable()) {
11584
11884
  return;
11585
11885
  }
11586
- if (this.sortColumn() === column) {
11587
- const current = this.sortDirection();
11588
- if (current === 'asc') {
11589
- this.sortDirection.set('desc');
11590
- }
11591
- else if (current === 'desc') {
11592
- this.sortDirection.set('');
11886
+ // `&& this.sortDirection()` matters: a column set with an empty direction is *not* sorted, and
11887
+ // that state is reachable through the two-way `[(sortColumn)]` binding — a consumer restoring
11888
+ // only the column lands in it. Without the guard neither inner branch matched, so the click
11889
+ // mutated nothing and still emitted a "cleared" event, leaving a header the user could click
11890
+ // forever with no arrow to explain it. Folding it into the not-sorted branch starts a fresh
11891
+ // ascending sort, which is what clicking an unsorted header does everywhere else.
11892
+ if (this.sortColumn() === column && this.sortDirection()) {
11893
+ this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : '');
11894
+ if (!this.sortDirection()) {
11593
11895
  this.sortColumn.set('');
11594
11896
  }
11595
11897
  }
@@ -11597,8 +11899,12 @@ class TnTableComponent {
11597
11899
  this.sortColumn.set(column);
11598
11900
  this.sortDirection.set('asc');
11599
11901
  }
11902
+ // Emits the *resulting* sort state, so clearing a sort reports `{ column: '', direction: '' }`
11903
+ // rather than naming the column that was just cleared. Card mode's `setSortColumn('')` has
11904
+ // always emitted the empty column, and a consumer keying off `event.column` should not get a
11905
+ // different answer depending on which layout the container width happens to be showing.
11600
11906
  this.sortChange.emit({
11601
- column: this.sortColumn() || column,
11907
+ column: this.sortColumn(),
11602
11908
  direction: this.sortDirection(),
11603
11909
  });
11604
11910
  }
@@ -11649,6 +11955,21 @@ class TnTableComponent {
11649
11955
  isRowExpandTrigger(row) {
11650
11956
  return this.expandOnRowClick() && this.clickable() && this.canExpandRow(row);
11651
11957
  }
11958
+ /**
11959
+ * Card-mode counterpart of {@link isRowExpandTrigger}, additionally requiring a detail
11960
+ * template. Card mode renders no expansion affordance at all without one, so `aria-expanded`
11961
+ * on the card would advertise a state change that produces nothing.
11962
+ *
11963
+ * Table mode deliberately keeps the looser check: its rows are asserted to carry
11964
+ * `aria-expanded` from `expandOnRowClick` alone, and that predates this layout.
11965
+ */
11966
+ isCardExpandTrigger(row) {
11967
+ return this.isRowExpandTrigger(row) && !!this.detailRowDef();
11968
+ }
11969
+ /** DOM id for a row's detail panel, so the expand trigger can point `aria-controls` at it. */
11970
+ detailPanelId(rowIndex) {
11971
+ return `${this.instanceId}-detail-${rowIndex}`;
11972
+ }
11652
11973
  // --- Active row ---
11653
11974
  isRowActive(row) {
11654
11975
  if (this.activeWhen()?.(row)) {
@@ -11658,25 +11979,47 @@ class TnTableComponent {
11658
11979
  return active !== null && active === row;
11659
11980
  }
11660
11981
  // --- Row click ---
11661
- onRowClick(row) {
11982
+ /**
11983
+ * @param row The activated row.
11984
+ * @param event The originating DOM event, when there is one. Optional so a consumer (or a
11985
+ * test) can still activate a row programmatically; the control-target guard only applies
11986
+ * to real events, which is the only case where a projected control can be the source.
11987
+ */
11988
+ onRowClick(row, event) {
11662
11989
  if (!this.clickable()) {
11663
11990
  return;
11664
11991
  }
11992
+ // Mirrors `onCardClick`. Only the select, expand and actions cells stop `click`
11993
+ // themselves, so a control projected into an ordinary `tnCellDef` — a checkbox, a
11994
+ // link — used to activate the row as well: `rowClick` fired and, under
11995
+ // `expandOnRowClick`, the detail panel toggled out from under the user. Card mode
11996
+ // never did that, so the same consumer template behaved differently either side of
11997
+ // `cardBreakpoint`.
11998
+ if (event && this.isRowControlTarget(event)) {
11999
+ return;
12000
+ }
11665
12001
  if (this.expandOnRowClick()) {
11666
12002
  this.toggleRowExpansion(row);
11667
12003
  }
11668
12004
  this.rowClick.emit(row);
11669
12005
  }
11670
- onRowDoubleClick(row) {
12006
+ /** @param event See {@link onRowClick} — same guard, same optionality. */
12007
+ onRowDoubleClick(row, event) {
11671
12008
  if (!this.clickable()) {
11672
12009
  return;
11673
12010
  }
12011
+ if (event && this.isRowControlTarget(event)) {
12012
+ return;
12013
+ }
11674
12014
  this.rowDoubleClick.emit(row);
11675
12015
  }
11676
12016
  onRowKeydown(event, row) {
11677
12017
  if (!this.clickable()) {
11678
12018
  return;
11679
12019
  }
12020
+ if (this.isRowControlTarget(event)) {
12021
+ return;
12022
+ }
11680
12023
  if (event.key === 'Enter' || event.key === ' ') {
11681
12024
  event.preventDefault();
11682
12025
  if (this.expandOnRowClick()) {
@@ -11685,6 +12028,135 @@ class TnTableComponent {
11685
12028
  this.rowClick.emit(row);
11686
12029
  }
11687
12030
  }
12031
+ // --- Card activation ---
12032
+ // Cards reuse `rowClick`, but the card header embeds its own controls
12033
+ // (selection checkbox, row actions, "more"/detail toggles). Activating the
12034
+ // card must ignore clicks/keys that originate from those controls so a tap on
12035
+ // an action button doesn't also fire `rowClick`.
12036
+ onCardClick(event, row) {
12037
+ if (!this.clickable() || this.isCardControlTarget(event)) {
12038
+ return;
12039
+ }
12040
+ if (this.expandOnRowClick()) {
12041
+ this.toggleRowExpansion(row);
12042
+ }
12043
+ this.rowClick.emit(row);
12044
+ }
12045
+ /** Card-mode counterpart of {@link onRowDoubleClick}, with the same guard. */
12046
+ onCardDoubleClick(event, row) {
12047
+ if (!this.clickable() || this.isCardControlTarget(event)) {
12048
+ return;
12049
+ }
12050
+ this.rowDoubleClick.emit(row);
12051
+ }
12052
+ onCardKeydown(event, row) {
12053
+ if (!this.clickable()) {
12054
+ return;
12055
+ }
12056
+ if (event.key !== 'Enter' && event.key !== ' ') {
12057
+ return;
12058
+ }
12059
+ if (this.isCardControlTarget(event)) {
12060
+ return;
12061
+ }
12062
+ event.preventDefault();
12063
+ if (this.expandOnRowClick()) {
12064
+ this.toggleRowExpansion(row);
12065
+ }
12066
+ this.rowClick.emit(row);
12067
+ }
12068
+ /** Anything focusable or otherwise interactive, regardless of layout. */
12069
+ static INTERACTIVE_SELECTOR = 'a[href], button, input, select, textarea, summary,'
12070
+ + ' [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])';
12071
+ /**
12072
+ * Whether an event originated on a control *within* the activation surface rather than on
12073
+ * the surface itself.
12074
+ *
12075
+ * @param event The DOM event; its `currentTarget` is the activation surface.
12076
+ * @param containerSelectors Layout-specific wrappers that count as controls.
12077
+ */
12078
+ isControlTarget(event, containerSelectors) {
12079
+ const target = event.target;
12080
+ const selector = containerSelectors
12081
+ ? `${TnTableComponent.INTERACTIVE_SELECTOR}, ${containerSelectors}`
12082
+ : TnTableComponent.INTERACTIVE_SELECTOR;
12083
+ const match = target?.closest(selector);
12084
+ // The row/card itself is focusable when `clickable`, so it matches the focusable clause.
12085
+ // It is the activation surface, not a control within it.
12086
+ return !!match && match !== event.currentTarget;
12087
+ }
12088
+ /**
12089
+ * Interactive elements plus the card's own controls and its projected detail panel.
12090
+ *
12091
+ * A class allowlist is not enough: the detail panel renders consumer content inside the
12092
+ * clickable card, so an allowlist lets a click on a projected button bubble up and fire
12093
+ * `rowClick` — and with `expandOnRowClick`, collapse the very panel being used. Table mode
12094
+ * never had that problem, because there the detail row is a sibling `<tr>` outside the
12095
+ * clickable row.
12096
+ *
12097
+ * The card's own field values are deliberately not interactive, so they still activate the
12098
+ * card whether primary or folded under "More fields".
12099
+ */
12100
+ isCardControlTarget(event) {
12101
+ return this.isControlTarget(event, '.tn-table__card-actions, .tn-table__card-select, .tn-table__card-more-summary,'
12102
+ + ' .tn-table__card-detail-toggle, .tn-table__card-detail');
12103
+ }
12104
+ /**
12105
+ * Table-mode counterpart. The cells stop `click` themselves, but nothing stopped `keydown`,
12106
+ * so Enter on a projected action button bubbled to the row and got `preventDefault()`d —
12107
+ * which *is* how Enter activates a `<button>`, so the consumer's handler never ran and
12108
+ * `rowClick` fired for the row instead. Card mode has always guarded this; the two layouts
12109
+ * disagreed about what a projected control does under the keyboard.
12110
+ */
12111
+ isRowControlTarget(event) {
12112
+ return this.isControlTarget(event, '.tn-table__select-cell, .tn-table__expand-cell, .tn-table__actions-cell');
12113
+ }
12114
+ /**
12115
+ * Header-cell activation, guarded like the row and the card.
12116
+ *
12117
+ * A `<th>` is an activation surface too, and a control projected through
12118
+ * `tnHeaderCellDef` broke both ways without this: clicking a filter button re-sorted the
12119
+ * table as well as running the button's own handler, and Space on a checkbox in a
12120
+ * *non-sortable* header was swallowed entirely — `onSortClick` returned early on the
12121
+ * missing `sortable()`, but the template's `preventDefault()` ran regardless, so the
12122
+ * control was simply dead.
12123
+ *
12124
+ * @param column The column the header belongs to.
12125
+ * @param event The originating DOM event.
12126
+ */
12127
+ onSortHeaderClick(column, event) {
12128
+ if (this.isControlTarget(event, '')) {
12129
+ return;
12130
+ }
12131
+ if (!this.getColumnDef(column)?.sortable()) {
12132
+ return;
12133
+ }
12134
+ this.onSortClick(column);
12135
+ }
12136
+ /**
12137
+ * Keyboard counterpart of {@link onSortHeaderClick}.
12138
+ *
12139
+ * Takes `Event` rather than `KeyboardEvent` because Angular types `$event` as `Event` for
12140
+ * the `keydown.enter` / `keydown.space` pseudo-events; narrowing happens here.
12141
+ */
12142
+ onSortHeaderKeydown(column, event) {
12143
+ if (this.isControlTarget(event, '')) {
12144
+ return;
12145
+ }
12146
+ if (!this.getColumnDef(column)?.sortable()) {
12147
+ return;
12148
+ }
12149
+ // Only swallow the key once we know this header owns it, so Space on a projected
12150
+ // control still reaches that control.
12151
+ if (event.key === ' ') {
12152
+ event.preventDefault();
12153
+ }
12154
+ this.onSortClick(column);
12155
+ }
12156
+ /** Handles the card-mode sort `<select>` change. */
12157
+ onSortSelectChange(event) {
12158
+ this.setSortColumn(event.target.value);
12159
+ }
11688
12160
  // --- Selection methods ---
11689
12161
  toggleSelectAll() {
11690
12162
  if (this.isAllSelected()) {
@@ -11704,15 +12176,138 @@ class TnTableComponent {
11704
12176
  isRowSelected(row) {
11705
12177
  return this.selection.isSelected(row);
11706
12178
  }
12179
+ // --- Card-mode computeds ---
12180
+ /**
12181
+ * The column rendered as the card title. The first `displayedColumns` entry
12182
+ * whose def sets `cardTitle`, else the first column not marked `cardHidden` —
12183
+ * a column the consumer asked to keep off the card must not be promoted to its
12184
+ * most prominent slot. Falls back to the first displayed column only when every
12185
+ * column is hidden, since a card still needs a title.
12186
+ */
12187
+ cardTitleColumn = computed(() => {
12188
+ const cols = this.displayedColumns();
12189
+ const explicit = cols.find((c) => this.getColumnDef(c)?.cardTitle());
12190
+ const visible = cols.find((c) => !this.getColumnDef(c)?.cardHidden());
12191
+ return explicit ?? visible ?? cols[0] ?? '';
12192
+ }, ...(ngDevMode ? [{ debugName: "cardTitleColumn" }] : []));
12193
+ /**
12194
+ * Columns rendered as label/value fields in a card, ordered by descending
12195
+ * `cardPriority` (ties keep `displayedColumns` order). Excludes the title column
12196
+ * and any `cardHidden` columns.
12197
+ */
12198
+ cardFieldColumns = computed(() => {
12199
+ const title = this.cardTitleColumn();
12200
+ const fields = this.displayedColumns()
12201
+ .map((name, index) => ({ name, index }))
12202
+ .filter(({ name }) => name !== title && !this.getColumnDef(name)?.cardHidden());
12203
+ fields.sort((a, b) => {
12204
+ const pa = this.getColumnDef(a.name)?.cardPriority() ?? 0;
12205
+ const pb = this.getColumnDef(b.name)?.cardPriority() ?? 0;
12206
+ return pb - pa || a.index - b.index;
12207
+ });
12208
+ return fields.map((f) => f.name);
12209
+ }, ...(ngDevMode ? [{ debugName: "cardFieldColumns" }] : []));
12210
+ /** Fields shown directly on the card (up to `cardPrimaryCount`). */
12211
+ cardPrimaryColumns = computed(() => this.cardFieldColumns().slice(0, this.cardPrimaryCount()), ...(ngDevMode ? [{ debugName: "cardPrimaryColumns" }] : []));
12212
+ /** Fields tucked behind the "More fields" disclosure. */
12213
+ cardSecondaryColumns = computed(() => this.cardFieldColumns().slice(this.cardPrimaryCount()), ...(ngDevMode ? [{ debugName: "cardSecondaryColumns" }] : []));
12214
+ /**
12215
+ * Displayed columns that are sortable — populates the card-mode sort menu.
12216
+ *
12217
+ * `cardHidden` columns are excluded, because this menu *is* part of the card layout
12218
+ * and `cardHidden` promises the column is "omitted entirely" from it. Offering one
12219
+ * here would let a user reorder the cards by a value no card shows, with the
12220
+ * direction toggle giving no clue what changed — and such a column often has no
12221
+ * readable label either, since `getCardLabel()` falls back to the raw column name
12222
+ * when the header lives only in a `tnHeaderCellDef` template.
12223
+ *
12224
+ * The title column stays eligible: it is excluded from the *fields*, but it is
12225
+ * displayed, so sorting by it is meaningful.
12226
+ */
12227
+ sortableColumns = computed(() => {
12228
+ const listed = this.displayedColumns().filter((c) => {
12229
+ const def = this.getColumnDef(c);
12230
+ return !!def?.sortable() && !def.cardHidden();
12231
+ });
12232
+ // Reconciled against the rendered option set, not against one reason for exclusion.
12233
+ // Whatever the active column is, it must have an `<option>`: with none matching, the
12234
+ // browser resets the picker to "Unsorted" while the direction toggle still renders an
12235
+ // arrow beside it, and picking "Unsorted" fires no `change` — it is already the
12236
+ // selected option — so the sort becomes unclearable from card mode. `cardHidden` was
12237
+ // only one way in; `sortColumn` is a two-way `model()`, and a dynamic
12238
+ // `displayedColumns` (a column-visibility toggle) can drop a sorted column or leave
12239
+ // one that was never `sortable()`.
12240
+ const active = this.sortColumn();
12241
+ return active && !listed.includes(active) ? [...listed, active] : listed;
12242
+ }, ...(ngDevMode ? [{ debugName: "sortableColumns" }] : []));
12243
+ /**
12244
+ * Whether the active sort column is one the table can actually sort by.
12245
+ *
12246
+ * `sortableColumns` rescues an active column into the menu so it stays clearable, even
12247
+ * when it is `cardHidden`, absent from `displayedColumns`, or never `sortable()`. That
12248
+ * escape hatch must not hand card mode a capability table mode refuses: clicking a
12249
+ * non-sortable header does nothing, so the direction toggle is hidden — and
12250
+ * {@link toggleSortDirection} refuses — for the same column.
12251
+ */
12252
+ canSortActiveColumn = computed(() => {
12253
+ const column = this.sortColumn();
12254
+ return !!column && !!this.getColumnDef(column)?.sortable();
12255
+ }, ...(ngDevMode ? [{ debugName: "canSortActiveColumn" }] : []));
12256
+ // --- Card-mode sort ---
12257
+ /**
12258
+ * Sets (or clears, when passed `''`) the active sort column for card mode.
12259
+ * Switching columns resets to ascending, and clearing emits an empty `column` —
12260
+ * both matching {@link onSortClick}, since the same `sortChange` contract should
12261
+ * not depend on which layout the container width happens to be showing.
12262
+ */
12263
+ setSortColumn(column) {
12264
+ if (!column) {
12265
+ this.sortColumn.set('');
12266
+ this.sortDirection.set('');
12267
+ }
12268
+ else {
12269
+ const changed = this.sortColumn() !== column;
12270
+ this.sortColumn.set(column);
12271
+ if (changed || this.sortDirection() === '') {
12272
+ this.sortDirection.set('asc');
12273
+ }
12274
+ }
12275
+ this.sortChange.emit({ column: this.sortColumn(), direction: this.sortDirection() });
12276
+ }
12277
+ /**
12278
+ * Flips the active sort direction between ascending and descending.
12279
+ *
12280
+ * Starts from ascending, so a column carrying an empty direction — reachable by restoring only
12281
+ * `sortColumn` through its two-way binding — sorts ascending rather than skipping straight to
12282
+ * descending. Same reading of "empty direction means not sorted" as {@link onSortClick}.
12283
+ */
12284
+ toggleSortDirection() {
12285
+ // Guarded, not just hidden: the control is gone from the template for a non-sortable
12286
+ // active column, and the method refuses too, so the API can't reorder by a column the
12287
+ // table header would ignore.
12288
+ if (!this.canSortActiveColumn()) {
12289
+ return;
12290
+ }
12291
+ this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
12292
+ this.sortChange.emit({ column: this.sortColumn(), direction: this.sortDirection() });
12293
+ }
11707
12294
  // --- Column helpers ---
11708
12295
  getColumnDef(columnName) {
11709
12296
  return this.columnDefMap().get(columnName);
11710
12297
  }
12298
+ /**
12299
+ * Field label for a column in card mode. Precedence: `cardLabel` override →
12300
+ * shared `label` → the column name.
12301
+ */
12302
+ getCardLabel(column) {
12303
+ const def = this.getColumnDef(column);
12304
+ return def?.cardLabel() ?? def?.label() ?? column;
12305
+ }
11711
12306
  getCellValue(row, column) {
11712
12307
  return row[column];
11713
12308
  }
11714
12309
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11715
- 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 }, 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 }, 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()", "style.--tn-table-active-bg": "activeBg()", "style.--tn-table-active-indicator": "activeIndicator()" }, classAttribute: "tn-table" }, queries: [{ propertyName: "columnDefs", predicate: TnTableColumnDirective, isSignal: true }, { propertyName: "detailRowDef", first: true, predicate: TnDetailRowDefDirective, descendants: true, isSignal: true }], hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "<table class=\"tn-table__table\" [style.min-width]=\"resolvedMinWidth()\">\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 <th class=\"tn-table__header-cell tn-table__select-cell\"\n role=\"checkbox\"\n tabindex=\"0\"\n [attr.aria-checked]=\"isAllSelected()\"\n (click)=\"toggleSelectAll()\"\n (keydown.enter)=\"toggleSelectAll()\"\n (keydown.space)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">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 : null\n \"\n [attr.tabindex]=\"getColumnDef(column)?.sortable() ? 0 : null\"\n [attr.data-column]=\"column\"\n (click)=\"getColumnDef(column)?.sortable() && onSortClick(column)\"\n (keydown.enter)=\"onSortClick(column)\"\n (keydown.space)=\"onSortClick(column); $event.preventDefault()\">\n <span class=\"tn-table__sort-container\">\n <span class=\"tn-table__header-text\">\n @if (getColumnDef(column)?.headerTemplate(); as tmpl) {\n <ng-container [ngTemplateOutlet]=\"tmpl\" />\n } @else {\n {{ column }}\n }\n </span>\n @if (getColumnDef(column)?.sortable()) {\n <tn-icon\n class=\"tn-table__sort-icon\"\n size=\"sm\"\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 </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\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-expanded]=\"isRowExpandTrigger(row) ? isRowExpanded(row) : null\"\n (click)=\"onRowClick(row)\"\n (dblclick)=\"onRowDoubleClick(row)\"\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)=\"toggleRowSelection(row); $event.stopPropagation()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(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-label]=\"isRowExpanded(row) ? 'Collapse row' : 'Expand row'\"\n (click)=\"toggleRowExpansion(row)\">\n <tn-icon\n class=\"tn-table__expand-icon\"\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 </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.colspan]=\"effectiveDisplayedColumns().length\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </td>\n </tr>\n }\n }\n </tbody>\n</table>\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}:host(.tn-table--fixed-layout) table{table-layout:fixed;width:100%}:host(.tn-table--bordered){border:1px solid var(--tn-lines);border-radius:4px}.tn-table__table{width:100%;border-collapse:collapse;border-spacing:0;background-color:var(--tn-bg2);border-radius:4px;overflow:hidden}.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:color-mix(in srgb,var(--tn-topbar) 85%,var(--tn-topbar-txt))}.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{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}.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{width:48px;padding:0 8px 0 16px;cursor:pointer}.tn-table__checkbox{pointer-events:none}.tn-table__expand-cell{width:48px;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__loading-overlay{position:absolute;inset:0;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;z-index:1;border-radius:4px}.tn-table--loading .tn-table__table{pointer-events:none;-webkit-user-select:none;user-select:none}.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}@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__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: [
12310
+ 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 }, 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()" }, 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 The handler goes on THIS wrapper, not on <tn-checkbox>: the checkbox is\n `pointer-events: none` (see `__checkbox` in the stylesheet), so it and its\n subtree are outside hit testing and a real click lands here instead. A\n handler bound on the checkbox is never in the event path \u2014 which is how the\n label -> div change silently killed mouse and touch selection while keyboard\n kept working, since Space on the hidden input dispatches its own bubbling\n click. Table mode binds the same handler on its <td> for this reason.\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)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\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 aria-label=\"Sort by\"\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()\">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 \"Sort ascending\" 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' ? 'Sort descending' : 'Sort ascending'\"\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)=\"toggleRowSelection(row); $event.preventDefault(); $event.stopPropagation()\">\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 </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>More fields</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>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 <th class=\"tn-table__header-cell tn-table__select-cell\"\n role=\"checkbox\"\n tabindex=\"0\"\n [attr.aria-checked]=\"isAllSelected()\"\n (click)=\"toggleSelectAll()\"\n (keydown.enter)=\"toggleSelectAll()\"\n (keydown.space)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">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 <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 @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\">Actions</span>\n </th>\n }\n </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\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-expanded]=\"isRowExpandTrigger(row) ? isRowExpanded(row) : 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)=\"toggleRowSelection(row); $event.stopPropagation()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(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-label]=\"isRowExpanded(row) ? 'Collapse row' : 'Expand row'\"\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.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:color-mix(in srgb,var(--tn-topbar) 85%,var(--tn-topbar-txt))}.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__checkbox{pointer-events:none}.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: [
11716
12311
  trigger('detailExpand', [
11717
12312
  state('collapsed,void', style({ height: '0px', minHeight: '0', overflow: 'hidden' })),
11718
12313
  state('expanded', style({ height: '*' })),
@@ -11733,10 +12328,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11733
12328
  '[class.tn-table--bordered]': 'bordered()',
11734
12329
  '[class.tn-table--fixed-layout]': 'fixedLayout()',
11735
12330
  '[class.tn-table--loading]': 'loading()',
12331
+ // `--cards` is a state hook only: card mode is styled through the
12332
+ // `__cards*` element classes, not from the host. It is kept as the
12333
+ // documented way for consumers and tests to detect the active layout
12334
+ // without reaching for internal element classes. `--scroll` is both a
12335
+ // hook and the selector the pinned-column rules key off.
12336
+ '[class.tn-table--cards]': 'isCardMode()',
12337
+ '[class.tn-table--scroll]': 'isScrollMode()',
11736
12338
  '[style.--tn-table-active-bg]': 'activeBg()',
11737
12339
  '[style.--tn-table-active-indicator]': 'activeIndicator()',
11738
- }, template: "<table class=\"tn-table__table\" [style.min-width]=\"resolvedMinWidth()\">\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 <th class=\"tn-table__header-cell tn-table__select-cell\"\n role=\"checkbox\"\n tabindex=\"0\"\n [attr.aria-checked]=\"isAllSelected()\"\n (click)=\"toggleSelectAll()\"\n (keydown.enter)=\"toggleSelectAll()\"\n (keydown.space)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">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 : null\n \"\n [attr.tabindex]=\"getColumnDef(column)?.sortable() ? 0 : null\"\n [attr.data-column]=\"column\"\n (click)=\"getColumnDef(column)?.sortable() && onSortClick(column)\"\n (keydown.enter)=\"onSortClick(column)\"\n (keydown.space)=\"onSortClick(column); $event.preventDefault()\">\n <span class=\"tn-table__sort-container\">\n <span class=\"tn-table__header-text\">\n @if (getColumnDef(column)?.headerTemplate(); as tmpl) {\n <ng-container [ngTemplateOutlet]=\"tmpl\" />\n } @else {\n {{ column }}\n }\n </span>\n @if (getColumnDef(column)?.sortable()) {\n <tn-icon\n class=\"tn-table__sort-icon\"\n size=\"sm\"\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 </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\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-expanded]=\"isRowExpandTrigger(row) ? isRowExpanded(row) : null\"\n (click)=\"onRowClick(row)\"\n (dblclick)=\"onRowDoubleClick(row)\"\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)=\"toggleRowSelection(row); $event.stopPropagation()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(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-label]=\"isRowExpanded(row) ? 'Collapse row' : 'Expand row'\"\n (click)=\"toggleRowExpansion(row)\">\n <tn-icon\n class=\"tn-table__expand-icon\"\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 </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.colspan]=\"effectiveDisplayedColumns().length\">\n <ng-container\n [ngTemplateOutlet]=\"detailRowDef()!.template\"\n [ngTemplateOutletContext]=\"{ $implicit: row }\" />\n </td>\n </tr>\n }\n }\n </tbody>\n</table>\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}:host(.tn-table--fixed-layout) table{table-layout:fixed;width:100%}:host(.tn-table--bordered){border:1px solid var(--tn-lines);border-radius:4px}.tn-table__table{width:100%;border-collapse:collapse;border-spacing:0;background-color:var(--tn-bg2);border-radius:4px;overflow:hidden}.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:color-mix(in srgb,var(--tn-topbar) 85%,var(--tn-topbar-txt))}.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{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}.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{width:48px;padding:0 8px 0 16px;cursor:pointer}.tn-table__checkbox{pointer-events:none}.tn-table__expand-cell{width:48px;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__loading-overlay{position:absolute;inset:0;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;z-index:1;border-radius:4px}.tn-table--loading .tn-table__table{pointer-events:none;-webkit-user-select:none;user-select:none}.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}@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__sort-icon,.tn-table__expand-icon,.tn-table__row{transition:none}.tn-table__detail-row{animation:none}}\n"] }]
11739
- }], 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 }] }], 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 }] }], 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 }] }], 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"] }] } });
12340
+ }, 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 The handler goes on THIS wrapper, not on <tn-checkbox>: the checkbox is\n `pointer-events: none` (see `__checkbox` in the stylesheet), so it and its\n subtree are outside hit testing and a real click lands here instead. A\n handler bound on the checkbox is never in the event path \u2014 which is how the\n label -> div change silently killed mouse and touch selection while keyboard\n kept working, since Space on the hidden input dispatches its own bubbling\n click. Table mode binds the same handler on its <td> for this reason.\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)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\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 aria-label=\"Sort by\"\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()\">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 \"Sort ascending\" 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' ? 'Sort descending' : 'Sort ascending'\"\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)=\"toggleRowSelection(row); $event.preventDefault(); $event.stopPropagation()\">\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 </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>More fields</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>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 <th class=\"tn-table__header-cell tn-table__select-cell\"\n role=\"checkbox\"\n tabindex=\"0\"\n [attr.aria-checked]=\"isAllSelected()\"\n (click)=\"toggleSelectAll()\"\n (keydown.enter)=\"toggleSelectAll()\"\n (keydown.space)=\"toggleSelectAll(); $event.preventDefault()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n label=\"Select all rows\"\n [hideLabel]=\"true\"\n [checked]=\"isAllSelected()\"\n [indeterminate]=\"isIndeterminate()\" />\n </th>\n } @else if (column === '__expand') {\n <th class=\"tn-table__header-cell tn-table__expand-cell\">\n <span class=\"cdk-visually-hidden\">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 <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 @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\">Actions</span>\n </th>\n }\n </tr>\n </thead>\n\n <!-- Data Rows -->\n <tbody class=\"tn-table__body\">\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-expanded]=\"isRowExpandTrigger(row) ? isRowExpanded(row) : 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)=\"toggleRowSelection(row); $event.stopPropagation()\">\n <tn-checkbox\n class=\"tn-table__checkbox\"\n [label]=\"'Select row ' + (rowIdx + 1)\"\n [hideLabel]=\"true\"\n [checked]=\"isRowSelected(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-label]=\"isRowExpanded(row) ? 'Collapse row' : 'Expand row'\"\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.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:color-mix(in srgb,var(--tn-topbar) 85%,var(--tn-topbar-txt))}.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__checkbox{pointer-events:none}.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"] }]
12341
+ }], 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 }] }], 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"] }] } });
11740
12342
 
11741
12343
  /**
11742
12344
  * Harness for interacting with `tn-table` in tests.
@@ -11770,18 +12372,59 @@ class TnTableHarness extends ComponentHarness {
11770
12372
  /**
11771
12373
  * Gets the number of data rows (excludes header and detail rows).
11772
12374
  *
11773
- * @returns Promise resolving to the row count.
12375
+ * Layout-aware: counts rendered cards in the card layout, so the count tracks what the
12376
+ * user sees rather than reporting 0 for a container that happens to be narrow.
12377
+ *
12378
+ * @returns Promise resolving to the row count, or the card count in the card layout.
11774
12379
  */
11775
12380
  async getRowCount() {
11776
- const rows = await this.locatorForAll('.tn-table__row')();
12381
+ // Counts cards in card mode. Answering 0 there was the silent-wrong-answer shape the
12382
+ // selection block rejects, and this is the harness's own headline example — a consumer
12383
+ // that narrows the container and asserts emptiness would pass vacuously.
12384
+ const selector = (await this.isCards()) ? '.tn-table__card' : '.tn-table__row';
12385
+ const rows = await this.locatorForAll(selector)();
11777
12386
  return rows.length;
11778
12387
  }
12388
+ /**
12389
+ * Throws when the rendered layout is `cards`, for queries that have no card equivalent.
12390
+ * The alternative is an empty array or a 0, which reads as "the table is empty" and
12391
+ * greens a test over a rendered card list — the failure mode the selection and
12392
+ * active-row methods were made layout-aware to avoid.
12393
+ */
12394
+ async assertTableLayout(method) {
12395
+ if (await this.isCards()) {
12396
+ throw new Error(`TnTableHarness.${method}() has no meaning in the card layout. `
12397
+ + 'Use the card API instead — getCardCount(), getCardTitle(), getCardFieldValue(), '
12398
+ + 'toggleCardDetail(), expandCardMoreFields(), getCardSortColumn(), '
12399
+ + 'getCardSortDirection(), toggleCardSortDirection(), clickCard(), '
12400
+ + 'doubleClickCard(), pressKeyOnCard(), isCardFocusable() — or widen the container '
12401
+ + 'above cardBreakpoint.');
12402
+ }
12403
+ }
12404
+ /**
12405
+ * Mirror of {@link assertTableLayout} for the card API. `getCardCount()` answering 0 in
12406
+ * table mode is the same silent-wrong-answer shape rejected for `getRowCount()`: a spec
12407
+ * that forgot to push a sub-breakpoint width would assert emptiness and pass over 20
12408
+ * rendered rows.
12409
+ */
12410
+ async assertCardLayout(method) {
12411
+ if (!(await this.isCards())) {
12412
+ throw new Error(`TnTableHarness.${method}() has no meaning in the table layout. `
12413
+ + 'Use the row API instead — getRowCount(), getCellText(), getRowTexts(), '
12414
+ + 'toggleRowExpansion(), clickSortHeader(), clickRow(), doubleClickRow(), '
12415
+ + 'pressKeyOnRow(), isRowFocusable() — or narrow the container below '
12416
+ + 'cardBreakpoint.');
12417
+ }
12418
+ }
11779
12419
  /**
11780
12420
  * Gets the text content of header cells (excludes sort icons).
11781
12421
  *
11782
12422
  * @returns Promise resolving to an array of header text strings.
12423
+ * @throws Error in the card layout, which renders no header row;
12424
+ * the thrown message names the card API.
11783
12425
  */
11784
12426
  async getHeaderTexts() {
12427
+ await this.assertTableLayout('getHeaderTexts');
11785
12428
  const textEls = await this.locatorForAll('.tn-table__header-text')();
11786
12429
  const texts = [];
11787
12430
  for (const el of textEls) {
@@ -11794,8 +12437,10 @@ class TnTableHarness extends ComponentHarness {
11794
12437
  *
11795
12438
  * @param rowIndex Zero-based index of the data row.
11796
12439
  * @returns Promise resolving to an array of cell text strings.
12440
+ * @throws Error in the card layout, which renders no rows; the thrown message names the card API.
11797
12441
  */
11798
12442
  async getRowTexts(rowIndex) {
12443
+ await this.assertTableLayout('getRowTexts');
11799
12444
  await this.assertRowExists(rowIndex);
11800
12445
  const cells = await this.locatorForAll(`.tn-table__row[data-row-index="${rowIndex}"] .tn-table__cell[data-column]`)();
11801
12446
  const texts = [];
@@ -11810,8 +12455,11 @@ class TnTableHarness extends ComponentHarness {
11810
12455
  * @param rowIndex Zero-based index of the data row.
11811
12456
  * @param columnName The column's data-column attribute value.
11812
12457
  * @returns Promise resolving to the cell text.
12458
+ * @throws Error in the card layout, which renders no cells;
12459
+ * the thrown message names the card API.
11813
12460
  */
11814
12461
  async getCellText(rowIndex, columnName) {
12462
+ await this.assertTableLayout('getCellText');
11815
12463
  await this.assertRowExists(rowIndex);
11816
12464
  const cell = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"] [data-column="${columnName}"]`)();
11817
12465
  return (await cell.text()).trim();
@@ -11820,8 +12468,10 @@ class TnTableHarness extends ComponentHarness {
11820
12468
  * Gets all row texts as a 2D string array (data-column cells only).
11821
12469
  *
11822
12470
  * @returns Promise resolving to an array of row text arrays.
12471
+ * @throws Error in the card layout, which renders no rows; the thrown message names the card API.
11823
12472
  */
11824
12473
  async getAllRowTexts() {
12474
+ await this.assertTableLayout('getAllRowTexts');
11825
12475
  const count = await this.getRowCount();
11826
12476
  const result = [];
11827
12477
  for (let i = 0; i < count; i++) {
@@ -11834,8 +12484,11 @@ class TnTableHarness extends ComponentHarness {
11834
12484
  * Clicks a sortable column header to cycle sort direction.
11835
12485
  *
11836
12486
  * @param columnName The column's data-column attribute value.
12487
+ * @throws Error in the card layout, which renders no column headers;
12488
+ * the thrown message names the card API.
11837
12489
  */
11838
12490
  async clickSortHeader(columnName) {
12491
+ await this.assertTableLayout('clickSortHeader');
11839
12492
  const header = await this.locatorFor(`th[data-column="${columnName}"]`)();
11840
12493
  await header.click();
11841
12494
  }
@@ -11844,59 +12497,105 @@ class TnTableHarness extends ComponentHarness {
11844
12497
  *
11845
12498
  * @param columnName The column's data-column attribute value.
11846
12499
  * @returns Promise resolving to true if the column is sortable.
12500
+ * @throws Error in the card layout, which renders no column headers;
12501
+ * the thrown message names the card API.
11847
12502
  */
11848
12503
  async isSortable(columnName) {
12504
+ await this.assertTableLayout('isSortable');
11849
12505
  const header = await this.locatorFor(`th[data-column="${columnName}"]`)();
11850
12506
  return header.hasClass('tn-table__header-cell--sortable');
11851
12507
  }
11852
12508
  /**
11853
12509
  * Gets the current sort direction for a column via aria-sort.
11854
12510
  *
12511
+ * `aria-sort="none"` — which a sortable-but-unsorted header now carries, so it is
12512
+ * discoverable as sortable — is normalised to null, keeping "not sorted" a single value
12513
+ * for callers. Only a non-sortable header has no attribute at all, and both answer null.
12514
+ *
11855
12515
  * @param columnName The column's data-column attribute value.
11856
12516
  * @returns Promise resolving to 'ascending', 'descending', or null.
12517
+ * @throws Error in the card layout, which renders no column headers;
12518
+ * the thrown message names the card API.
11857
12519
  */
11858
12520
  async getSortDirection(columnName) {
12521
+ await this.assertTableLayout('getSortDirection');
11859
12522
  const header = await this.locatorFor(`th[data-column="${columnName}"]`)();
11860
- return header.getAttribute('aria-sort');
12523
+ const sort = await header.getAttribute('aria-sort');
12524
+ return sort === 'none' ? null : sort;
11861
12525
  }
11862
12526
  // --- Selection ---
12527
+ //
12528
+ // Layout-aware, all four of them. Card mode renders no `.tn-table__row` at all —
12529
+ // selection lives in `.tn-table__card .tn-table__card-select` — so a row-based
12530
+ // locator either throws or, worse, answers 0 for a table with rows selected.
12531
+ // Consumers shouldn't have to branch on the rendered layout to ask "is this row
12532
+ // selected", so these resolve the right selectors themselves.
12533
+ //
12534
+ // The click target is the WRAPPER in both layouts (the `<td>`, or the card's
12535
+ // `<div>`): `.tn-table__checkbox` is `pointer-events: none`, so clicking the
12536
+ // checkbox itself exercises a path no user can take.
12537
+ /** True when the container is narrow enough that card mode is rendered. */
12538
+ async isCards() {
12539
+ return (await this.getLayoutMode()) === 'cards';
12540
+ }
11863
12541
  /**
11864
- * Clicks the select-all checkbox in the header.
12542
+ * Clicks the select-all control — the header checkbox in table mode, the card
12543
+ * toolbar's "Select all" in card mode.
11865
12544
  */
11866
12545
  async toggleSelectAll() {
11867
- const cell = await this.locatorFor('.tn-table__header-row .tn-table__select-cell')();
11868
- await cell.click();
12546
+ const selector = (await this.isCards())
12547
+ ? '.tn-table__cards-selectall'
12548
+ : '.tn-table__header-row .tn-table__select-cell';
12549
+ const target = await this.locatorFor(selector)();
12550
+ await target.click();
11869
12551
  }
11870
12552
  /**
11871
- * Toggles selection for a specific row by clicking its checkbox cell.
12553
+ * Toggles selection for a specific row (or its card) by clicking its checkbox
12554
+ * wrapper.
11872
12555
  *
11873
12556
  * @param rowIndex Zero-based index of the data row.
11874
12557
  */
11875
12558
  async toggleRowSelection(rowIndex) {
11876
- await this.assertRowExists(rowIndex);
11877
- const cell = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"] .tn-table__select-cell`)();
11878
- await cell.click();
12559
+ await this.assertIndexExists(rowIndex);
12560
+ const selector = (await this.isCards())
12561
+ ? `.tn-table__card[data-row-index="${rowIndex}"] .tn-table__card-select`
12562
+ : `.tn-table__row[data-row-index="${rowIndex}"] .tn-table__select-cell`;
12563
+ const target = await this.locatorFor(selector)();
12564
+ await target.click();
11879
12565
  }
11880
12566
  /**
11881
- * Checks if a specific row's checkbox is checked.
12567
+ * Checks if a specific row (or its card) is selected.
11882
12568
  *
11883
12569
  * @param rowIndex Zero-based index of the data row.
11884
12570
  * @returns Promise resolving to true if the row's checkbox is checked.
11885
12571
  */
11886
12572
  async isRowSelected(rowIndex) {
11887
- await this.assertRowExists(rowIndex);
11888
- const checkbox = await this.locatorFor(TnCheckboxHarness.with({
11889
- ancestor: `.tn-table__row[data-row-index="${rowIndex}"]`,
11890
- }))();
12573
+ await this.assertIndexExists(rowIndex);
12574
+ // Scoped to the select cell, matching `toggleRowSelection` and `getSelectedRowCount`.
12575
+ // Resolving against the whole row/card takes the first checkbox in document order, which
12576
+ // is the selection one only while `selectable` is on — with it off and a `tn-checkbox`
12577
+ // projected through `[tnRowActionsDef]`, this reported that checkbox's state instead of
12578
+ // failing to find a selection control.
12579
+ const ancestor = (await this.isCards())
12580
+ ? `.tn-table__card[data-row-index="${rowIndex}"] .tn-table__card-select`
12581
+ : `.tn-table__row[data-row-index="${rowIndex}"] .tn-table__select-cell`;
12582
+ const checkbox = await this.locatorFor(TnCheckboxHarness.with({ ancestor }))();
11891
12583
  return checkbox.isChecked();
11892
12584
  }
11893
12585
  /**
11894
- * Gets the count of currently selected rows.
12586
+ * Gets the count of currently selected rows, in either layout.
11895
12587
  *
11896
12588
  * @returns Promise resolving to the number of checked row checkboxes.
11897
12589
  */
11898
12590
  async getSelectedRowCount() {
11899
- const checkboxes = await this.locatorForAll(TnCheckboxHarness.with({ ancestor: '.tn-table__row' }))();
12591
+ // Scoped to the select cell, not the whole row/card: a checkbox projected through
12592
+ // `[tnRowActionsDef]` sits inside the row, and in card mode the detail panel is a
12593
+ // descendant of the card, so a checkbox in a detail template would inflate the count.
12594
+ // (Table mode escaped the second case only because its detail row is a sibling `<tr>`.)
12595
+ const ancestor = (await this.isCards())
12596
+ ? '.tn-table__card-select'
12597
+ : '.tn-table__row .tn-table__select-cell';
12598
+ const checkboxes = await this.locatorForAll(TnCheckboxHarness.with({ ancestor }))();
11900
12599
  let count = 0;
11901
12600
  for (const cb of checkboxes) {
11902
12601
  if (await cb.isChecked()) {
@@ -11910,8 +12609,11 @@ class TnTableHarness extends ComponentHarness {
11910
12609
  * Clicks the expand button for a specific row.
11911
12610
  *
11912
12611
  * @param rowIndex Zero-based index of the data row.
12612
+ * @throws Error in the card layout — use `toggleCardDetail()`;
12613
+ * the thrown message names the card API.
11913
12614
  */
11914
12615
  async toggleRowExpansion(rowIndex) {
12616
+ await this.assertTableLayout('toggleRowExpansion');
11915
12617
  await this.assertRowExists(rowIndex);
11916
12618
  const button = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"] .tn-table__expand-button`)();
11917
12619
  await button.click();
@@ -11921,8 +12623,10 @@ class TnTableHarness extends ComponentHarness {
11921
12623
  *
11922
12624
  * @param rowIndex Zero-based index of the data row.
11923
12625
  * @returns Promise resolving to true if the row has the expanded class.
12626
+ * @throws Error in the card layout, which renders no rows; the thrown message names the card API.
11924
12627
  */
11925
12628
  async isRowExpanded(rowIndex) {
12629
+ await this.assertTableLayout('isRowExpanded');
11926
12630
  await this.assertRowExists(rowIndex);
11927
12631
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
11928
12632
  return row.hasClass('tn-table__row--expanded');
@@ -11933,8 +12637,11 @@ class TnTableHarness extends ComponentHarness {
11933
12637
  *
11934
12638
  * @param rowIndex Zero-based index of the data row.
11935
12639
  * @returns Promise resolving to true if the row has an expand button.
12640
+ * @throws Error in the card layout, whose expand control is the card's Details button;
12641
+ * the thrown message names the card API.
11936
12642
  */
11937
12643
  async hasExpandControl(rowIndex) {
12644
+ await this.assertTableLayout('hasExpandControl');
11938
12645
  await this.assertRowExists(rowIndex);
11939
12646
  const button = await this.locatorForOptional(`.tn-table__row[data-row-index="${rowIndex}"] .tn-table__expand-button`)();
11940
12647
  return button !== null;
@@ -11944,8 +12651,10 @@ class TnTableHarness extends ComponentHarness {
11944
12651
  * Clicks a row (for tables with `clickable` enabled).
11945
12652
  *
11946
12653
  * @param rowIndex Zero-based index of the data row.
12654
+ * @throws Error in the card layout, which renders no rows; use {@link clickCard} there.
11947
12655
  */
11948
12656
  async clickRow(rowIndex) {
12657
+ await this.assertTableLayout('clickRow');
11949
12658
  await this.assertRowExists(rowIndex);
11950
12659
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
11951
12660
  await row.click();
@@ -11956,8 +12665,10 @@ class TnTableHarness extends ComponentHarness {
11956
12665
  * clicks first; this helper dispatches only the `dblclick` event.
11957
12666
  *
11958
12667
  * @param rowIndex Zero-based index of the data row.
12668
+ * @throws Error in the card layout, which renders no rows; use {@link doubleClickCard} there.
11959
12669
  */
11960
12670
  async doubleClickRow(rowIndex) {
12671
+ await this.assertTableLayout('doubleClickRow');
11961
12672
  await this.assertRowExists(rowIndex);
11962
12673
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
11963
12674
  await row.dispatchEvent('dblclick');
@@ -11967,8 +12678,10 @@ class TnTableHarness extends ComponentHarness {
11967
12678
  *
11968
12679
  * @param rowIndex Zero-based index of the data row.
11969
12680
  * @param key Which key to press — Enter or Space.
12681
+ * @throws Error in the card layout, which renders no rows; use {@link pressKeyOnCard} there.
11970
12682
  */
11971
12683
  async pressKeyOnRow(rowIndex, key) {
12684
+ await this.assertTableLayout('pressKeyOnRow');
11972
12685
  await this.assertRowExists(rowIndex);
11973
12686
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
11974
12687
  await row.focus();
@@ -11983,8 +12696,10 @@ class TnTableHarness extends ComponentHarness {
11983
12696
  * Checks if a row is keyboard-focusable (tabindex=0).
11984
12697
  *
11985
12698
  * @param rowIndex Zero-based index of the data row.
12699
+ * @throws Error in the card layout, which renders no rows; use {@link isCardFocusable} there.
11986
12700
  */
11987
12701
  async isRowFocusable(rowIndex) {
12702
+ await this.assertTableLayout('isRowFocusable');
11988
12703
  await this.assertRowExists(rowIndex);
11989
12704
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
11990
12705
  return (await row.getAttribute('tabindex')) === '0';
@@ -12003,10 +12718,17 @@ class TnTableHarness extends ComponentHarness {
12003
12718
  /**
12004
12719
  * Checks if a data row is currently marked active.
12005
12720
  *
12006
- * @param rowIndex Zero-based index of the data row.
12007
- * @returns Promise resolving to true if the row has the active class.
12721
+ * Layout-aware: reads the card's active class in the card layout, which marks the same
12722
+ * state with `aria-current` rather than the row's `aria-selected`.
12723
+ *
12724
+ * @param rowIndex Zero-based index of the data row (or card).
12725
+ * @returns Promise resolving to true if the row — or card — has the active class.
12008
12726
  */
12009
12727
  async isRowActive(rowIndex) {
12728
+ if (await this.isCards()) {
12729
+ const card = await this.locatorFor(`.tn-table__card[data-row-index="${rowIndex}"]`)();
12730
+ return card.hasClass('tn-table__card--active');
12731
+ }
12010
12732
  await this.assertRowExists(rowIndex);
12011
12733
  const row = await this.locatorFor(`.tn-table__row[data-row-index="${rowIndex}"]`)();
12012
12734
  return row.hasClass('tn-table__row--active');
@@ -12017,11 +12739,19 @@ class TnTableHarness extends ComponentHarness {
12017
12739
  * @returns Promise resolving to the active row index or null.
12018
12740
  */
12019
12741
  async getActiveRowIndex() {
12020
- const row = await this.locatorForOptional('.tn-table__row--active')();
12021
- if (!row) {
12742
+ // Layout-aware for the same reason as the selection block: card mode marks the active
12743
+ // row as `.tn-table__card--active` and renders no `.tn-table__row` at all, so a
12744
+ // row-only locator answered null — "nothing is active" — over a visibly active card.
12745
+ // Silent wrong answers are worse than a throw; this one could green a test after a
12746
+ // resize.
12747
+ const selector = (await this.isCards())
12748
+ ? '.tn-table__card--active'
12749
+ : '.tn-table__row--active';
12750
+ const active = await this.locatorForOptional(selector)();
12751
+ if (!active) {
12022
12752
  return null;
12023
12753
  }
12024
- const attr = await row.getAttribute('data-row-index');
12754
+ const attr = await active.getAttribute('data-row-index');
12025
12755
  return attr === null ? null : Number(attr);
12026
12756
  }
12027
12757
  /**
@@ -12029,8 +12759,11 @@ class TnTableHarness extends ComponentHarness {
12029
12759
  *
12030
12760
  * @param detailIndex Zero-based index among currently visible detail rows.
12031
12761
  * @returns Promise resolving to the detail row text.
12762
+ * @throws Error in the card layout — use the card detail panel;
12763
+ * the thrown message names the card API.
12032
12764
  */
12033
12765
  async getDetailRowContent(detailIndex) {
12766
+ await this.assertTableLayout('getDetailRowContent');
12034
12767
  const detailRows = await this.locatorForAll('.tn-table__detail-row')();
12035
12768
  if (detailIndex >= detailRows.length) {
12036
12769
  throw new Error(`Detail row index ${detailIndex} out of bounds (${detailRows.length} detail rows)`);
@@ -12040,21 +12773,355 @@ class TnTableHarness extends ComponentHarness {
12040
12773
  /**
12041
12774
  * Gets the count of currently expanded detail rows.
12042
12775
  *
12043
- * @returns Promise resolving to the number of visible detail rows.
12776
+ * Layout-aware: counts open card detail panels in the card layout, which renders the same
12777
+ * expansion state without a detail `<tr>`.
12778
+ *
12779
+ * @returns Promise resolving to the number of visible detail rows, or open card detail
12780
+ * panels in the card layout.
12044
12781
  */
12045
12782
  async getExpandedRowCount() {
12783
+ if (await this.isCards()) {
12784
+ const panels = await this.locatorForAll('.tn-table__card-detail')();
12785
+ return panels.length;
12786
+ }
12046
12787
  const detailRows = await this.locatorForAll('.tn-table__detail-row')();
12047
12788
  return detailRows.length;
12048
12789
  }
12790
+ // --- Card layout (responsive) ---
12791
+ /**
12792
+ * Reports the currently rendered layout: `cards` when the container is narrow
12793
+ * enough that `mobileLayout="cards"` has taken effect, otherwise `table`.
12794
+ *
12795
+ * @returns Promise resolving to 'cards' or 'table'.
12796
+ */
12797
+ async getLayoutMode() {
12798
+ const cards = await this.locatorForOptional('.tn-table__cards')();
12799
+ return cards ? 'cards' : 'table';
12800
+ }
12801
+ /**
12802
+ * Gets the number of rendered cards (card layout only).
12803
+ *
12804
+ * @returns Promise resolving to the card count.
12805
+ */
12806
+ async getCardCount() {
12807
+ await this.assertCardLayout('getCardCount');
12808
+ const cards = await this.locatorForAll('.tn-table__card')();
12809
+ return cards.length;
12810
+ }
12811
+ /**
12812
+ * Gets the title text of a card.
12813
+ *
12814
+ * @param cardIndex Zero-based index of the card.
12815
+ * @returns Promise resolving to the card title text.
12816
+ */
12817
+ async getCardTitle(cardIndex) {
12818
+ await this.assertCardLayout('getCardTitle');
12819
+ const title = await this.locatorFor(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-title`)();
12820
+ return (await title.text()).trim();
12821
+ }
12822
+ /**
12823
+ * Gets the value text of a field within a card, by column name. The field
12824
+ * may be a primary field or one tucked under "More fields".
12825
+ *
12826
+ * @param cardIndex Zero-based index of the card.
12827
+ * @param columnName The column's data-column attribute value.
12828
+ * @returns Promise resolving to the field's value text.
12829
+ */
12830
+ async getCardFieldValue(cardIndex, columnName) {
12831
+ await this.assertCardLayout('getCardFieldValue');
12832
+ const value = await this.locatorFor(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-field[data-column="${columnName}"] .tn-table__card-field-value`)();
12833
+ return (await value.text()).trim();
12834
+ }
12835
+ /**
12836
+ * Gets the column names of the fields shown directly on a card (i.e. not
12837
+ * those hidden behind the "More fields" disclosure).
12838
+ *
12839
+ * @param cardIndex Zero-based index of the card.
12840
+ * @returns Promise resolving to an array of column names.
12841
+ */
12842
+ async getCardPrimaryFieldColumns(cardIndex) {
12843
+ await this.assertCardLayout('getCardPrimaryFieldColumns');
12844
+ const fields = await this.locatorForAll(`.tn-table__card[data-row-index="${cardIndex}"] > .tn-table__card-fields > .tn-table__card-field[data-column]`)();
12845
+ const columns = [];
12846
+ for (const field of fields) {
12847
+ const col = await field.getAttribute('data-column');
12848
+ if (col !== null) {
12849
+ columns.push(col);
12850
+ }
12851
+ }
12852
+ return columns;
12853
+ }
12854
+ /**
12855
+ * Expands the "More fields" disclosure on a card to reveal lower-priority
12856
+ * fields. No-op if the card has no secondary fields.
12857
+ *
12858
+ * @param cardIndex Zero-based index of the card.
12859
+ */
12860
+ async expandCardMoreFields(cardIndex) {
12861
+ await this.assertCardLayout('expandCardMoreFields');
12862
+ const disclosure = await this.locatorForOptional(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-more`)();
12863
+ if (!disclosure) {
12864
+ return;
12865
+ }
12866
+ // Idempotent, because the name and the doc promise an expand while `<summary>` toggles.
12867
+ // A second call used to collapse it — and under a real WebDriver, where `text()` returns
12868
+ // only visible text, `getCardFieldValue()` on a folded field would then come back empty.
12869
+ if (await disclosure.getProperty('open')) {
12870
+ return;
12871
+ }
12872
+ const summary = await this.locatorFor(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-more-summary`)();
12873
+ await summary.click();
12874
+ }
12875
+ /**
12876
+ * Toggles a card's detail section (card layout equivalent of row expansion).
12877
+ *
12878
+ * @param cardIndex Zero-based index of the card.
12879
+ */
12880
+ async toggleCardDetail(cardIndex) {
12881
+ await this.assertCardLayout('toggleCardDetail');
12882
+ const toggle = await this.locatorFor(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-detail-toggle`)();
12883
+ await toggle.click();
12884
+ }
12885
+ /**
12886
+ * Gets the currently selected sort column in the card-layout sort menu, or
12887
+ * `''` when unsorted. Returns null if the sort menu isn't rendered.
12888
+ *
12889
+ * @returns Promise resolving to the selected column name, '', or null.
12890
+ */
12891
+ async getCardSortColumn() {
12892
+ const select = await this.locatorForOptional('.tn-table__cards-sort-select')();
12893
+ if (!select) {
12894
+ return null;
12895
+ }
12896
+ return select.getProperty('value');
12897
+ }
12898
+ /**
12899
+ * Gets the active sort direction in the card layout, read from the direction toggle's
12900
+ * `data-sort-direction`. Deliberately not derived from the button's `aria-label`: matching
12901
+ * display text would silently report one direction forever if the wording or locale changed.
12902
+ *
12903
+ * @returns Promise resolving to 'asc' or 'desc'; `''` both when sorted by nothing and when the
12904
+ * active column isn't sortable (the toggle isn't rendered in either case, and this reads the
12905
+ * rendered control — so a non-sortable active column reports `''` even though the component's
12906
+ * `sortDirection()` holds a value); or null when the card sort menu isn't rendered at all
12907
+ * (table layout, or no sortable columns) — the same null-vs-empty distinction
12908
+ * {@link getCardSortColumn} makes.
12909
+ */
12910
+ async getCardSortDirection() {
12911
+ const menu = await this.locatorForOptional('.tn-table__cards-sort')();
12912
+ if (!menu) {
12913
+ return null;
12914
+ }
12915
+ const button = await this.locatorForOptional('.tn-table__cards-sort-dir')();
12916
+ if (!button) {
12917
+ return '';
12918
+ }
12919
+ const direction = await button.getAttribute('data-sort-direction');
12920
+ // Report the empty direction rather than rounding it up to 'asc'. A column set with
12921
+ // no direction is not sorted, and claiming otherwise made this getter contradict its
12922
+ // own contract — and hid the fact that the button rendered a direction for it.
12923
+ if (direction === 'asc' || direction === 'desc') {
12924
+ return direction;
12925
+ }
12926
+ return '';
12927
+ }
12928
+ /**
12929
+ * Clicks the sort-direction toggle in the card-layout sort menu. No-op when the toggle
12930
+ * isn't rendered, which is three cases: the table layout is showing (the whole card sort
12931
+ * menu is absent — so a call made before the container narrowed does nothing and reports
12932
+ * success), no active sort column, or an active column that isn't `sortable()` — card mode
12933
+ * deliberately declines to reorder by a column whose table header ignores clicks.
12934
+ */
12935
+ async toggleCardSortDirection() {
12936
+ const button = await this.locatorForOptional('.tn-table__cards-sort-dir')();
12937
+ if (button) {
12938
+ await button.click();
12939
+ }
12940
+ }
12941
+ // --- Card activation ---
12942
+ //
12943
+ // Mirrors of the four clickable-row methods. Without them the row methods' `@throws` sent a
12944
+ // card-mode spec to a card API that could not activate anything, and the only way through
12945
+ // was raw DOM — which is what the harness exists to spare a consumer.
12946
+ /**
12947
+ * Clicks a card body (card mirror of {@link clickRow}, for tables with `clickable`).
12948
+ *
12949
+ * Targets the card's title rather than the card box, because the card box's centre can sit
12950
+ * on a projected row action or the selection checkbox, and the activation guard ignores
12951
+ * clicks that originate on a control — the click would land and nothing would happen.
12952
+ *
12953
+ * @param cardIndex Zero-based index of the card.
12954
+ * @throws Error in the table layout; use {@link clickRow} there.
12955
+ */
12956
+ async clickCard(cardIndex) {
12957
+ await this.assertCardLayout('clickCard');
12958
+ await this.assertIndexExists(cardIndex);
12959
+ const target = await this.cardActivationTarget(cardIndex);
12960
+ await target.click();
12961
+ }
12962
+ /**
12963
+ * Double-clicks a card, triggering `rowDoubleClick` (card mirror of
12964
+ * {@link doubleClickRow}). As there, only the `dblclick` event is dispatched — a real
12965
+ * double-click also fires two single clicks first.
12966
+ *
12967
+ * Dispatched on the card rather than on the title {@link clickCard} aims at, because a
12968
+ * synthesised event does not bubble: sent to the title it would never reach the card's
12969
+ * handler. There is no cost to it here — the target is set explicitly, so unlike a real
12970
+ * pointer click it cannot land on a projected control.
12971
+ *
12972
+ * @param cardIndex Zero-based index of the card.
12973
+ * @throws Error in the table layout; use {@link doubleClickRow} there.
12974
+ */
12975
+ async doubleClickCard(cardIndex) {
12976
+ await this.assertCardLayout('doubleClickCard');
12977
+ await this.assertIndexExists(cardIndex);
12978
+ const card = await this.cardAt(cardIndex);
12979
+ await card.dispatchEvent('dblclick');
12980
+ }
12981
+ /**
12982
+ * Sends a keyboard event to a card (Enter/Space activate clickable cards). Card mirror of
12983
+ * {@link pressKeyOnRow}; the key goes to the card itself, which is the element that carries
12984
+ * `tabindex` and the keydown handler.
12985
+ *
12986
+ * @param cardIndex Zero-based index of the card.
12987
+ * @param key Which key to press — Enter or Space.
12988
+ * @throws Error in the table layout; use {@link pressKeyOnRow} there.
12989
+ */
12990
+ async pressKeyOnCard(cardIndex, key) {
12991
+ await this.assertCardLayout('pressKeyOnCard');
12992
+ await this.assertIndexExists(cardIndex);
12993
+ const card = await this.cardAt(cardIndex);
12994
+ await card.focus();
12995
+ if (key === 'enter') {
12996
+ await card.sendKeys(TestKey.ENTER);
12997
+ }
12998
+ else {
12999
+ await card.sendKeys(' ');
13000
+ }
13001
+ }
13002
+ /**
13003
+ * Checks if a card is keyboard-focusable (tabindex=0). Card mirror of
13004
+ * {@link isRowFocusable}.
13005
+ *
13006
+ * @param cardIndex Zero-based index of the card.
13007
+ * @throws Error in the table layout; use {@link isRowFocusable} there.
13008
+ */
13009
+ async isCardFocusable(cardIndex) {
13010
+ await this.assertCardLayout('isCardFocusable');
13011
+ await this.assertIndexExists(cardIndex);
13012
+ const card = await this.cardAt(cardIndex);
13013
+ return (await card.getAttribute('tabindex')) === '0';
13014
+ }
12049
13015
  // --- Internal helpers ---
12050
- async assertRowExists(rowIndex) {
13016
+ /** The card box itself — the element carrying `tabindex`, the handlers and the ARIA state. */
13017
+ async cardAt(cardIndex) {
13018
+ return this.locatorFor(`.tn-table__card[data-row-index="${cardIndex}"]`)();
13019
+ }
13020
+ /** The surface pointer activation is aimed at — see {@link clickCard} for why not the card. */
13021
+ async cardActivationTarget(cardIndex) {
13022
+ const title = await this.locatorForOptional(`.tn-table__card[data-row-index="${cardIndex}"] .tn-table__card-title`)();
13023
+ return title ?? (await this.cardAt(cardIndex));
13024
+ }
13025
+ /**
13026
+ * Bounds check for the layout-aware methods. Counts whatever the rendered layout shows, via
13027
+ * `getRowCount()` — unlike {@link assertRowExists}, which deliberately counts rows only,
13028
+ * because its callers refuse card mode outright.
13029
+ */
13030
+ async assertIndexExists(index) {
12051
13031
  const count = await this.getRowCount();
12052
- if (rowIndex >= count) {
12053
- throw new Error(`Row index ${rowIndex} out of bounds (${count} rows)`);
13032
+ if (index >= count) {
13033
+ const noun = (await this.isCards()) ? 'cards' : 'rows';
13034
+ throw new Error(`Row index ${index} out of bounds (${count} ${noun})`);
13035
+ }
13036
+ }
13037
+ async assertRowExists(rowIndex) {
13038
+ // Counts `.tn-table__row` directly rather than calling `getRowCount()`, which is
13039
+ // layout-aware: in card mode that counts cards, so this guard passed for every valid
13040
+ // index and stopped catching the very mismatch it exists for. Row-only methods pair it
13041
+ // with `assertTableLayout()`, which reports the layout mismatch first.
13042
+ const rows = await this.locatorForAll('.tn-table__row')();
13043
+ if (rowIndex >= rows.length) {
13044
+ throw new Error(`Row index ${rowIndex} out of bounds (${rows.length} rows)`);
12054
13045
  }
12055
13046
  }
12056
13047
  }
12057
13048
 
13049
+ /**
13050
+ * Stand-in for `ResizeObserver`, which jsdom does not implement — so a `tn-table` under
13051
+ * test can never measure its own container and never leaves the table layout on its own.
13052
+ * Installing this lets a spec push a width through the component's real callback path
13053
+ * rather than reaching for its private state.
13054
+ *
13055
+ * Module-private on purpose. `MockResizeObserver` is a name a consumer's own test helpers
13056
+ * plausibly already define — this repo's own `tree-virtual-scroll-view.component.spec.ts`
13057
+ * declares one — and a wildcard import of the library would collide with it. Everything a
13058
+ * caller needs is reachable through {@link TnTableTesting}.
13059
+ */
13060
+ class MockResizeObserver {
13061
+ cb;
13062
+ static instances = [];
13063
+ constructor(cb) {
13064
+ this.cb = cb;
13065
+ MockResizeObserver.instances.push(this);
13066
+ }
13067
+ observe() { }
13068
+ unobserve() { }
13069
+ disconnect() { }
13070
+ /** Fires the observed callback with `width` as the element's content-box width. */
13071
+ emitWidth(width) {
13072
+ this.cb([{ contentRect: { width } }], this);
13073
+ }
13074
+ }
13075
+ /**
13076
+ * Testing utilities for `tn-table`'s container-driven responsive layout.
13077
+ *
13078
+ * Part of the public API because `TnTableHarness`'s layout-mismatch messages tell the caller
13079
+ * to widen or narrow the container past `cardBreakpoint`, and jsdom implements no
13080
+ * `ResizeObserver` — so without a stand-in a consumer's own spec has no supported way to
13081
+ * follow that advice.
13082
+ *
13083
+ * Namespaced rather than exported as loose functions, matching `TnIconTesting` and
13084
+ * `TnToastTesting`: these names live at the top level of the package, and `installObserver`
13085
+ * or `emitContainerWidth` on their own give no hint that they concern tables.
13086
+ *
13087
+ * @example
13088
+ * ```typescript
13089
+ * let restoreResizeObserver: () => void;
13090
+ *
13091
+ * beforeEach(() => { restoreResizeObserver = TnTableTesting.installResizeObserver(); });
13092
+ * afterEach(() => { restoreResizeObserver(); });
13093
+ *
13094
+ * // ...then, inside a test:
13095
+ * TnTableTesting.emitContainerWidth(320);
13096
+ * fixture.detectChanges();
13097
+ * ```
13098
+ */
13099
+ const TnTableTesting = {
13100
+ /**
13101
+ * Swaps `globalThis.ResizeObserver` for a stand-in and clears any instances left by a
13102
+ * previous test.
13103
+ *
13104
+ * @returns A function that restores the original global — call it in `afterEach`.
13105
+ */
13106
+ installResizeObserver() {
13107
+ const original = globalThis.ResizeObserver;
13108
+ MockResizeObserver.instances = [];
13109
+ globalThis.ResizeObserver = MockResizeObserver;
13110
+ return () => {
13111
+ globalThis.ResizeObserver = original;
13112
+ };
13113
+ },
13114
+ /**
13115
+ * Pushes `width` through every observer the component under test registered, as its
13116
+ * content-box width — the same quantity `measureContainer` reads on the initial pass.
13117
+ *
13118
+ * @param width Content-box width in px. A 0 reads as unmeasurable, not narrow.
13119
+ */
13120
+ emitContainerWidth(width) {
13121
+ MockResizeObserver.instances.forEach((observer) => observer.emitWidth(width));
13122
+ },
13123
+ };
13124
+
12058
13125
  /** English defaults used when no `TN_TABLE_PAGER_LABELS` provider is registered. */
12059
13126
  const TN_TABLE_PAGER_DEFAULT_LABELS = {
12060
13127
  itemsPerPage: 'Items per page',
@@ -19458,7 +20525,7 @@ class TnFilePickerPopupComponent {
19458
20525
  return `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()} ${timePart}`;
19459
20526
  }
19460
20527
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
19461
- 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);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, #d32f2f)}.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, #d32f2f) 10%,transparent);border-left:3px solid var(--tn-error, #d32f2f);border-radius:3px;font-size:1rem;color:var(--tn-error, #d32f2f)}.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;color:var(--tn-fg1);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", "selectable", "expandable", "bordered", "isRowExpandable", "activeRow", "activeWhen", "activeBg", "activeIndicator", "loading", "loadingMessage", "clickable", "expandOnRowClick", "singleExpand", "fixedLayout", "minColumnWidth", "minWidth", "sortColumn", "sortDirection"], outputs: ["sortChange", "selectionChange", "rowClick", "rowDoubleClick", "sortColumnChange", "sortDirectionChange"] }, { kind: "directive", type: TnTableColumnDirective, selector: "[tnColumnDef]", inputs: ["tnColumnDef", "sortable", "width"], 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" }] });
20528
+ 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);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, #d32f2f)}.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, #d32f2f) 10%,transparent);border-left:3px solid var(--tn-error, #d32f2f);border-radius:3px;font-size:1rem;color:var(--tn-error, #d32f2f)}.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;color:var(--tn-fg1);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", "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", "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" }] });
19462
20529
  }
19463
20530
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnFilePickerPopupComponent, decorators: [{
19464
20531
  type: Component,
@@ -21314,5 +22381,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
21314
22381
  * Generated bundle index. Do not edit.
21315
22382
  */
21316
22383
 
21317
- export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, THEME_MAP, THEME_STORAGE_KEY, TN_CALENDAR_INTL, TN_CALENDAR_INTL_DEFAULTS, TN_FORM_FIELD_CONTEXT, TN_FORM_FIELD_ERRORS, TN_RADIO_GROUP, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarCellHarness, TnCalendarComponent, TnCalendarHarness, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxHarness, TnCheckboxLabelDirective, TnChipComponent, TnChipHarness, TnChipInputComponent, TnChipInputHarness, TnConfirmDialogComponent, TnDateInputComponent, TnDateInputHarness, TnDateRangeInputComponent, TnDateRangeInputHarness, TnDetailRowDefDirective, TnDialog, TnDialogHarness, TnDialogShellComponent, TnDialogTesting, TnDividerComponent, TnDividerDirective, TnDrawerComponent, TnDrawerContainerComponent, TnDrawerContainerHarness, TnDrawerContentComponent, TnDrawerHarness, TnEmptyComponent, TnEmptyHarness, TnExpansionPanelComponent, TnExpansionPanelHarness, TnFileInputComponent, TnFileInputHarness, TnFilePickerComponent, TnFilePickerHarness, TnFilePickerPopupComponent, TnFormFieldComponent, TnFormFieldHarness, TnFormSectionComponent, TnFormSectionHarness, TnHeaderCellDefDirective, TnIconButtonComponent, TnIconButtonHarness, TnIconComponent, TnIconHarness, TnIconRegistryService, TnIconTesting, TnInputComponent, TnInputDirective, TnInputHarness, TnKeyboardShortcutComponent, TnKeyboardShortcutService, TnListAvatarDirective, TnListComponent, TnListIconDirective, TnListItemComponent, TnListItemLineDirective, TnListItemPrimaryDirective, TnListItemSecondaryDirective, TnListItemTitleDirective, TnListItemTrailingDirective, TnListOptionComponent, TnListSubheaderComponent, TnMenuActivateHoverDirective, TnMenuComponent, TnMenuHarness, TnMenuItemComponent, TnMenuTesting, TnMenuTriggerDirective, TnMonthViewComponent, TnMultiYearViewComponent, TnNestedTreeDataSource, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, TnRadioGroupComponent, TnRadioGroupHarness, TnRadioHarness, 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, TnTabsComponent, TnTabsHarness, TnTestIdDirective, TnTheme, TnThemeService, TnTimeInputComponent, TnToastComponent, TnToastMock, TnToastPosition, TnToastRef, TnToastService, TnToastTesting, TnToastType, TnTooltipComponent, TnTooltipDirective, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeHarness, TnTreeNodeComponent, TnTreeNodeHarness, TnTreeNodeOutletDirective, TnTreeVirtualScrollNodeOutletDirective, TnTreeVirtualScrollViewComponent, TnTreeVirtualScrollViewHarness, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, allowsCurrentDirectorySelection, composeTestId, controlTestId, createFlatTreeControl, createLucideLibrary, createNestedTreeControl, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, defaultTreeItemSize, formatSize, getSelectableTypes, injectTnCalendarIntl, injectTnFormFieldAria, isPathWithinRoot, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, normalizeRootPath, optionTestId, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
22384
+ export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, THEME_MAP, THEME_STORAGE_KEY, TN_CALENDAR_INTL, TN_CALENDAR_INTL_DEFAULTS, TN_FORM_FIELD_CONTEXT, TN_FORM_FIELD_ERRORS, TN_RADIO_GROUP, TN_TABLE_PAGER_DEFAULT_LABELS, TN_TABLE_PAGER_LABELS, TN_TEST_ATTR, TN_THEME_DEFINITIONS, TnAutocompleteComponent, TnAutocompleteHarness, TnBannerActionDirective, TnBannerComponent, TnBannerHarness, TnBrandedSpinnerComponent, TnButtonComponent, TnButtonHarness, TnButtonToggleComponent, TnButtonToggleGroupComponent, TnButtonToggleGroupHarness, TnButtonToggleHarness, TnCalendarCellHarness, TnCalendarComponent, TnCalendarHarness, TnCalendarHeaderComponent, TnCardComponent, TnCardFooterActionsDirective, TnCardHeaderActionsDirective, TnCardHeaderDirective, TnCellDefDirective, TnCheckboxComponent, TnCheckboxHarness, TnCheckboxLabelDirective, TnChipComponent, TnChipHarness, TnChipInputComponent, TnChipInputHarness, TnConfirmDialogComponent, TnDateInputComponent, TnDateInputHarness, TnDateRangeInputComponent, TnDateRangeInputHarness, TnDetailRowDefDirective, TnDialog, TnDialogHarness, TnDialogShellComponent, TnDialogTesting, TnDividerComponent, TnDividerDirective, TnDrawerComponent, TnDrawerContainerComponent, TnDrawerContainerHarness, TnDrawerContentComponent, TnDrawerHarness, TnEmptyComponent, TnEmptyHarness, TnExpansionPanelComponent, TnExpansionPanelHarness, TnFileInputComponent, TnFileInputHarness, TnFilePickerComponent, TnFilePickerHarness, TnFilePickerPopupComponent, TnFormFieldComponent, TnFormFieldHarness, TnFormSectionComponent, TnFormSectionHarness, TnHeaderCellDefDirective, TnIconButtonComponent, TnIconButtonHarness, TnIconComponent, TnIconHarness, TnIconRegistryService, TnIconTesting, TnInputComponent, TnInputDirective, TnInputHarness, TnKeyboardShortcutComponent, TnKeyboardShortcutService, TnListAvatarDirective, TnListComponent, TnListIconDirective, TnListItemComponent, TnListItemLineDirective, TnListItemPrimaryDirective, TnListItemSecondaryDirective, TnListItemTitleDirective, TnListItemTrailingDirective, TnListOptionComponent, TnListSubheaderComponent, TnMenuActivateHoverDirective, TnMenuComponent, TnMenuHarness, TnMenuItemComponent, TnMenuTesting, TnMenuTriggerDirective, TnMonthViewComponent, TnMultiYearViewComponent, 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, TnTreeComponent, TnTreeFlatDataSource, TnTreeFlattener, TnTreeHarness, TnTreeNodeComponent, TnTreeNodeHarness, TnTreeNodeOutletDirective, TnTreeVirtualScrollNodeOutletDirective, TnTreeVirtualScrollViewComponent, TnTreeVirtualScrollViewHarness, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, allowsCurrentDirectorySelection, composeTestId, controlTestId, createFlatTreeControl, createLucideLibrary, createNestedTreeControl, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, defaultTreeItemSize, formatSize, getSelectableTypes, injectTnCalendarIntl, injectTnFormFieldAria, isPathWithinRoot, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, normalizeRootPath, optionTestId, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
21318
22385
  //# sourceMappingURL=truenas-ui-components.mjs.map