@truenas/ui-components 0.3.20 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,19 @@
1
1
  import { Overlay, OverlayPositionBuilder, OverlayModule } from '@angular/cdk/overlay';
2
2
  import { TemplatePortal, ComponentPortal, PortalModule } from '@angular/cdk/portal';
3
3
  import * as i0 from '@angular/core';
4
- import { input, computed, ViewEncapsulation, ChangeDetectionStrategy, Component, inject, Injector, InjectionToken, Renderer2, ElementRef, effect, Directive, ViewContainerRef, output, viewChild, signal, untracked, isDevMode, forwardRef, model, afterNextRender, Injectable, contentChildren, Pipe, HostListener, linkedSignal, contentChild, TemplateRef, ChangeDetectorRef, DestroyRef, isSignal, IterableDiffers, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
4
+ import { input, computed, ViewEncapsulation, ChangeDetectionStrategy, Component, inject, Injector, InjectionToken, Renderer2, ElementRef, effect, Directive, ViewContainerRef, output, viewChild, signal, untracked, isDevMode, forwardRef, model, afterNextRender, Injectable, contentChildren, Pipe, HostListener, linkedSignal, contentChild, TemplateRef, ChangeDetectorRef, DestroyRef, isSignal, IterableDiffers, booleanAttribute, ApplicationRef, EnvironmentInjector, createComponent, PLATFORM_ID } from '@angular/core';
5
5
  import * as i1$3 from '@angular/forms';
6
6
  import { NgControl, NG_VALUE_ACCESSOR, FormsModule, Validators } from '@angular/forms';
7
7
  import { ComponentHarness, HarnessPredicate, TestKey } from '@angular/cdk/testing';
8
8
  import * as i1 from '@angular/cdk/a11y';
9
9
  import { A11yModule, FocusMonitor } from '@angular/cdk/a11y';
10
10
  import * as i1$2 from '@angular/common';
11
- import { DOCUMENT, NgTemplateOutlet, CommonModule, isPlatformBrowser } from '@angular/common';
11
+ import { DOCUMENT, NgTemplateOutlet, CommonModule, AsyncPipe, isPlatformBrowser } from '@angular/common';
12
12
  import { mdiCheckCircle, mdiAlertCircle, mdiAlert, mdiInformation, mdiOpenInNew, mdiHelpCircle, mdiDotsVertical, mdiClose, mdiFolderOpen, mdiLock, mdiLoading, mdiFolderPlus, mdiFolderNetwork, mdiHarddisk, mdiDatabase, mdiFile, mdiFolder } from '@mdi/js';
13
13
  import * as i1$1 from '@angular/platform-browser';
14
14
  import { DomSanitizer } from '@angular/platform-browser';
15
15
  import { HttpClient } from '@angular/common/http';
16
- import { firstValueFrom, BehaviorSubject, merge, Subject, take } from 'rxjs';
16
+ import { firstValueFrom, BehaviorSubject, merge, animationFrameScheduler, asapScheduler, Subject, take } from 'rxjs';
17
17
  import { RouterLink } from '@angular/router';
18
18
  import { filesize } from 'filesize';
19
19
  import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
@@ -23,11 +23,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
23
23
  import { trigger, state, transition, style, animate } from '@angular/animations';
24
24
  import { SelectionModel, DataSource } from '@angular/cdk/collections';
25
25
  import * as i2 from '@angular/cdk/tree';
26
- import { CdkTree, CdkTreeModule, CdkTreeNode, CDK_TREE_NODE_OUTLET_NODE, CdkTreeNodeOutlet, CdkNestedTreeNode } from '@angular/cdk/tree';
26
+ import { CdkTree, CdkTreeModule, CdkTreeNode, CDK_TREE_NODE_OUTLET_NODE, CdkTreeNodeOutlet, CdkNestedTreeNode, CdkTreeNodeOutletContext } from '@angular/cdk/tree';
27
27
  export { FlatTreeControl } from '@angular/cdk/tree';
28
- import { map } from 'rxjs/operators';
28
+ import { map, auditTime } from 'rxjs/operators';
29
+ import { CdkVirtualScrollViewport, CdkVirtualScrollableWindow, CdkFixedSizeVirtualScroll, CdkVirtualForOf, ScrollingModule } from '@angular/cdk/scrolling';
29
30
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
30
- import { ScrollingModule } from '@angular/cdk/scrolling';
31
31
 
32
32
  class TnSpinnerComponent {
33
33
  mode = input('indeterminate', ...(ngDevMode ? [{ debugName: "mode" }] : []));
@@ -8729,6 +8729,16 @@ class TnSelectComponent {
8729
8729
  /** Label of the empty option rendered when `allowEmpty` is set. */
8730
8730
  emptyLabel = input('--', ...(ngDevMode ? [{ debugName: "emptyLabel" }] : []));
8731
8731
  disabled = input(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
8732
+ /**
8733
+ * When `true` (multiple mode only), renders a "select all" row at the top of
8734
+ * the dropdown that toggles every selectable (non-disabled) option on/off in
8735
+ * one click. Mirrors webui ix-select's `[showSelectAll]`. Ignored in single
8736
+ * mode. Its checkbox reflects the aggregate state: checked when all are
8737
+ * selected, indeterminate when only some are.
8738
+ */
8739
+ showSelectAll = input(false, ...(ngDevMode ? [{ debugName: "showSelectAll" }] : []));
8740
+ /** Label of the select-all row rendered when `showSelectAll` is set. */
8741
+ selectAllLabel = input('Select All', ...(ngDevMode ? [{ debugName: "selectAllLabel" }] : []));
8732
8742
  testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
8733
8743
  /** Test-id base, falling back to the bound control name when `testId` is unset. */
8734
8744
  resolvedTestId = controlTestId(this.testId);
@@ -8816,18 +8826,23 @@ class TnSelectComponent {
8816
8826
  */
8817
8827
  navigableOptions = computed(() => {
8818
8828
  const result = [];
8829
+ // The select-all row is the first navigable entry when shown, so
8830
+ // ArrowDown from the closed trigger lands on it before the options.
8831
+ if (this.showSelectAllRow()) {
8832
+ result.push({ kind: 'select-all', id: this.selectAllId() });
8833
+ }
8819
8834
  const baseId = `tn-select-opt-${this.idNamespace()}`;
8820
8835
  let i = 0;
8821
8836
  for (const opt of this.displayOptions()) {
8822
8837
  if (!opt.disabled) {
8823
- result.push({ option: opt, id: `${baseId}-${i}` });
8838
+ result.push({ kind: 'option', option: opt, id: `${baseId}-${i}` });
8824
8839
  }
8825
8840
  i++;
8826
8841
  }
8827
8842
  for (const group of this.optionGroups()) {
8828
8843
  for (const opt of group.options) {
8829
8844
  if (!opt.disabled && !group.disabled) {
8830
- result.push({ option: opt, id: `${baseId}-${i}` });
8845
+ result.push({ kind: 'option', option: opt, id: `${baseId}-${i}` });
8831
8846
  }
8832
8847
  i++;
8833
8848
  }
@@ -8842,14 +8857,15 @@ class TnSelectComponent {
8842
8857
  }, ...(ngDevMode ? [{ debugName: "focusedOptionId" }] : []));
8843
8858
  /** Stable DOM id for an option; matches what navigableOptions() assigns. */
8844
8859
  optionId(option) {
8845
- const entry = this.navigableOptions().find((x) => x.option === option);
8860
+ const entry = this.navigableOptions().find((x) => x.kind === 'option' && x.option === option);
8846
8861
  return entry?.id ?? null;
8847
8862
  }
8848
8863
  /** Whether `option` is the keyboard-highlighted item. */
8849
8864
  isOptionFocused(option) {
8850
8865
  const idx = this.focusedIndex();
8851
8866
  const nav = this.navigableOptions();
8852
- return idx >= 0 && idx < nav.length && nav[idx].option === option;
8867
+ const entry = idx >= 0 && idx < nav.length ? nav[idx] : null;
8868
+ return entry?.kind === 'option' && entry.option === option;
8853
8869
  }
8854
8870
  /**
8855
8871
  * Test-id segments for an option row, consumed by `[tnTestId]` with
@@ -8934,7 +8950,7 @@ class TnSelectComponent {
8934
8950
  // otherwise leave it unset so the next ArrowDown lands on the first item.
8935
8951
  const selected = this.selectedValue();
8936
8952
  if (selected !== null && selected !== undefined) {
8937
- const idx = this.navigableOptions().findIndex((x) => this.compareValues(x.option.value, selected));
8953
+ const idx = this.navigableOptions().findIndex((x) => x.kind === 'option' && this.compareValues(x.option.value, selected));
8938
8954
  this.focusedIndex.set(idx);
8939
8955
  }
8940
8956
  else if (this.emptyOption()) {
@@ -9131,6 +9147,92 @@ class TnSelectComponent {
9131
9147
  hasAnyOptions = computed(() => {
9132
9148
  return this.options().length > 0 || this.optionGroups().length > 0;
9133
9149
  }, ...(ngDevMode ? [{ debugName: "hasAnyOptions" }] : []));
9150
+ /**
9151
+ * Values of every selectable (non-disabled) option, across ungrouped options
9152
+ * and enabled groups. This is the set the select-all row operates on —
9153
+ * disabled options and options in disabled groups are excluded because they
9154
+ * can't be toggled individually either.
9155
+ *
9156
+ * Values are deduped with `compareValues` so a value that appears both
9157
+ * ungrouped and inside a group isn't pushed twice — that would make
9158
+ * select-all diverge from `toggleOption()`, which never produces duplicates.
9159
+ */
9160
+ selectableValues = computed(() => {
9161
+ const values = [];
9162
+ const push = (value) => {
9163
+ if (!values.some((v) => this.compareValues(v, value))) {
9164
+ values.push(value);
9165
+ }
9166
+ };
9167
+ for (const opt of this.options()) {
9168
+ if (!opt.disabled) {
9169
+ push(opt.value);
9170
+ }
9171
+ }
9172
+ for (const group of this.optionGroups()) {
9173
+ if (group.disabled) {
9174
+ continue;
9175
+ }
9176
+ for (const opt of group.options) {
9177
+ if (!opt.disabled) {
9178
+ push(opt.value);
9179
+ }
9180
+ }
9181
+ }
9182
+ return values;
9183
+ }, ...(ngDevMode ? [{ debugName: "selectableValues" }] : []));
9184
+ /** Whether the select-all row is shown (multiple mode, opted in, with options). */
9185
+ showSelectAllRow = computed(() => this.multiple() && this.showSelectAll() && this.selectableValues().length > 0, ...(ngDevMode ? [{ debugName: "showSelectAllRow" }] : []));
9186
+ /** Stable DOM id of the select-all row, for aria-activedescendant. */
9187
+ selectAllId = computed(() => `tn-select-selectall-${this.idNamespace()}`, ...(ngDevMode ? [{ debugName: "selectAllId" }] : []));
9188
+ /** True when every selectable option is currently selected. */
9189
+ allSelected = computed(() => {
9190
+ const selectable = this.selectableValues();
9191
+ if (selectable.length === 0) {
9192
+ return false;
9193
+ }
9194
+ const selected = this.selectedValues();
9195
+ return selectable.every((v) => selected.some((s) => this.compareValues(s, v)));
9196
+ }, ...(ngDevMode ? [{ debugName: "allSelected" }] : []));
9197
+ /** True when some — but not all — selectable options are selected. */
9198
+ selectAllIndeterminate = computed(() => {
9199
+ if (this.allSelected()) {
9200
+ return false;
9201
+ }
9202
+ const selected = this.selectedValues();
9203
+ return this.selectableValues().some((v) => selected.some((s) => this.compareValues(s, v)));
9204
+ }, ...(ngDevMode ? [{ debugName: "selectAllIndeterminate" }] : []));
9205
+ /** Test-id segments for the select-all row; mirrors ix-select's `[name, 'select-all']`. */
9206
+ selectAllTestIdParts() {
9207
+ return scopeTestId(this.resolvedTestId(), 'select-all');
9208
+ }
9209
+ /**
9210
+ * Toggles every selectable option: clears them all when they're all already
9211
+ * selected, otherwise selects them all. Preserves the multi-select "open"
9212
+ * behaviour — the dropdown stays open so the user can keep adjusting.
9213
+ *
9214
+ * Disabled-but-selected values (e.g. a disabled option pre-selected via
9215
+ * `writeValue`) are preserved on both paths: the user can't toggle those
9216
+ * rows individually, so select-all must not silently discard them either.
9217
+ */
9218
+ toggleSelectAll() {
9219
+ if (this.isDisabled()) {
9220
+ return;
9221
+ }
9222
+ const selectable = this.selectableValues();
9223
+ const preserved = this.selectedValues().filter((v) => !selectable.some((s) => this.compareValues(s, v)));
9224
+ const updated = this.allSelected() ? preserved : [...preserved, ...selectable];
9225
+ this.selectedValues.set(updated);
9226
+ this.onChange(updated);
9227
+ this.multiSelectionChange.emit(updated);
9228
+ this.cdr.markForCheck();
9229
+ }
9230
+ /** Whether the select-all row is the keyboard-highlighted item. */
9231
+ isSelectAllFocused() {
9232
+ const idx = this.focusedIndex();
9233
+ const nav = this.navigableOptions();
9234
+ return idx >= 0 && idx < nav.length && nav[idx].kind === 'select-all';
9235
+ }
9134
9236
  /** One-shot guard so the object-compare warning fires at most once per instance. */
9135
9237
  warnedAboutObjectCompare = false;
9136
9238
  /**
@@ -9274,7 +9376,13 @@ class TnSelectComponent {
9274
9376
  if (idx < 0 || idx >= nav.length) {
9275
9377
  return;
9276
9378
  }
9277
- this.onOptionClick(nav[idx].option);
9379
+ const entry = nav[idx];
9380
+ if (entry.kind === 'select-all') {
9381
+ this.toggleSelectAll();
9382
+ }
9383
+ else {
9384
+ this.onOptionClick(entry.option);
9385
+ }
9278
9386
  }
9279
9387
  scrollFocusedIntoView() {
9280
9388
  const id = this.focusedOptionId();
@@ -9297,13 +9405,13 @@ class TnSelectComponent {
9297
9405
  });
9298
9406
  }
9299
9407
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9300
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSelectComponent, isStandalone: true, selector: "tn-select", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, optionGroups: { classPropertyName: "optionGroups", publicName: "optionGroups", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, noOptionsLabel: { classPropertyName: "noOptionsLabel", publicName: "noOptionsLabel", isSignal: true, isRequired: false, transformFunction: null }, allowEmpty: { classPropertyName: "allowEmpty", publicName: "allowEmpty", isSignal: true, isRequired: false, transformFunction: null }, emptyLabel: { classPropertyName: "emptyLabel", publicName: "emptyLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", multiSelectionChange: "multiSelectionChange" }, providers: [
9408
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnSelectComponent, isStandalone: true, selector: "tn-select", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, optionGroups: { classPropertyName: "optionGroups", publicName: "optionGroups", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, noOptionsLabel: { classPropertyName: "noOptionsLabel", publicName: "noOptionsLabel", isSignal: true, isRequired: false, transformFunction: null }, allowEmpty: { classPropertyName: "allowEmpty", publicName: "allowEmpty", isSignal: true, isRequired: false, transformFunction: null }, emptyLabel: { classPropertyName: "emptyLabel", publicName: "emptyLabel", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, showSelectAll: { classPropertyName: "showSelectAll", publicName: "showSelectAll", isSignal: true, isRequired: false, transformFunction: null }, selectAllLabel: { classPropertyName: "selectAllLabel", publicName: "selectAllLabel", isSignal: true, isRequired: false, transformFunction: null }, testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, optionTestIdKey: { classPropertyName: "optionTestIdKey", publicName: "optionTestIdKey", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", multiSelectionChange: "multiSelectionChange" }, providers: [
9301
9409
  {
9302
9410
  provide: NG_VALUE_ACCESSOR,
9303
9411
  useExisting: forwardRef(() => TnSelectComponent),
9304
9412
  multi: true
9305
9413
  }
9306
- ], viewQueries: [{ propertyName: "triggerEl", first: true, predicate: ["triggerEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"ariaLabel() ?? placeholder()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular options) -->\n @if (!isFirst || displayOptions().length > 0) {\n <div\n class=\"tn-select-separator\"\n role=\"separator\">\n </div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ noOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, #dc3545)}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, #dc3545);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, #dc3545) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height)}.tn-select-options{overflow-y:auto;padding:.25rem 0;max-height:var(--tn-select-dropdown-max-height)}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"], dependencies: [{ kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9414
+ ], viewQueries: [{ propertyName: "triggerEl", first: true, predicate: ["triggerEl"], descendants: true, isSignal: true }, { propertyName: "dropdownTemplate", first: true, predicate: ["dropdownTemplate"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"ariaLabel() ?? placeholder()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Select-all row (multiple mode with showSelectAll). Toggles every\n selectable option; its checkbox reflects all/some/none. -->\n @if (showSelectAllRow()) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option tn-select-select-all\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"selectAllTestIdParts()\"\n [attr.id]=\"selectAllId()\"\n [class.selected]=\"allSelected()\"\n [class.focused]=\"isSelectAllFocused()\"\n [attr.aria-selected]=\"allSelected()\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"toggleSelectAll()\">\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"allSelected()\"\n [indeterminate]=\"selectAllIndeterminate()\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n {{ selectAllLabel() }}\n </div>\n <div class=\"tn-select-separator\" role=\"separator\"></div>\n }\n\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular options) -->\n @if (!isFirst || displayOptions().length > 0) {\n <div\n class=\"tn-select-separator\"\n role=\"separator\">\n </div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ noOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, #dc3545)}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, #dc3545);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, #dc3545) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height)}.tn-select-options{overflow-y:auto;padding:.25rem 0;max-height:var(--tn-select-dropdown-max-height)}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-select-all{font-weight:600}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"], dependencies: [{ kind: "component", type: TnCheckboxComponent, selector: "tn-checkbox", inputs: ["label", "hideLabel", "disabled", "required", "indeterminate", "testId", "error", "checked"], outputs: ["change"] }, { kind: "directive", type: TnTestIdDirective, selector: "[tnTestId]", inputs: ["tnTestId", "tnTestIdType"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9307
9415
  }
9308
9416
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSelectComponent, decorators: [{
9309
9417
  type: Component,
@@ -9313,8 +9421,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
9313
9421
  useExisting: forwardRef(() => TnSelectComponent),
9314
9422
  multi: true
9315
9423
  }
9316
- ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"ariaLabel() ?? placeholder()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular options) -->\n @if (!isFirst || displayOptions().length > 0) {\n <div\n class=\"tn-select-separator\"\n role=\"separator\">\n </div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ noOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, #dc3545)}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, #dc3545);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, #dc3545) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height)}.tn-select-options{overflow-y:auto;padding:.25rem 0;max-height:var(--tn-select-dropdown-max-height)}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"] }]
9317
- }], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], optionGroups: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionGroups", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], noOptionsLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "noOptionsLabel", required: false }] }], allowEmpty: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowEmpty", required: false }] }], emptyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], multiSelectionChange: [{ type: i0.Output, args: ["multiSelectionChange"] }], triggerEl: [{ type: i0.ViewChild, args: ['triggerEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
9424
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-select-container\">\n <!-- Select Trigger -->\n <div\n #triggerEl\n class=\"tn-select-trigger\"\n role=\"combobox\"\n tnTestIdType=\"select\"\n [tnTestId]=\"resolvedTestId()\"\n [attr.tabindex]=\"isDisabled() ? -1 : 0\"\n [class.disabled]=\"isDisabled()\"\n [class.open]=\"isOpen()\"\n [attr.aria-expanded]=\"isOpen()\"\n [attr.aria-haspopup]=\"'listbox'\"\n [attr.aria-controls]=\"isOpen() ? 'tn-select-dropdown-' + idNamespace() : null\"\n [attr.aria-label]=\"ariaLabel() ?? placeholder()\"\n [attr.aria-disabled]=\"isDisabled()\"\n [attr.aria-activedescendant]=\"isOpen() ? focusedOptionId() : null\"\n (click)=\"toggleDropdown()\"\n (keydown)=\"onKeydown($event)\">\n\n <!-- Display Text -->\n <span\n class=\"tn-select-text\"\n [class.placeholder]=\"multiple() ? selectedValues().length === 0 : (selectedValue() === null || selectedValue() === undefined)\">\n {{ displayText() }}\n </span>\n\n <!-- Dropdown Arrow -->\n <div class=\"tn-select-arrow\" [class.open]=\"isOpen()\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <polyline points=\"6,9 12,15 18,9\" />\n </svg>\n </div>\n </div>\n</div>\n\n<!-- Dropdown panel \u2014 portaled via CDK Overlay so it escapes parent\n overflow/clipping. The trigger keeps DOM focus throughout (combobox\n pattern); options use aria-activedescendant for virtual focus. -->\n<ng-template #dropdownTemplate>\n <div\n class=\"tn-select-dropdown\"\n role=\"listbox\"\n [attr.aria-multiselectable]=\"multiple()\"\n [attr.id]=\"'tn-select-dropdown-' + idNamespace()\">\n\n <!-- Options List -->\n <div class=\"tn-select-options\">\n <!-- Select-all row (multiple mode with showSelectAll). Toggles every\n selectable option; its checkbox reflects all/some/none. -->\n @if (showSelectAllRow()) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option tn-select-select-all\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"selectAllTestIdParts()\"\n [attr.id]=\"selectAllId()\"\n [class.selected]=\"allSelected()\"\n [class.focused]=\"isSelectAllFocused()\"\n [attr.aria-selected]=\"allSelected()\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"toggleSelectAll()\">\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"allSelected()\"\n [indeterminate]=\"selectAllIndeterminate()\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n {{ selectAllLabel() }}\n </div>\n <div class=\"tn-select-separator\" role=\"separator\"></div>\n }\n\n <!-- Regular Options (preceded by the synthetic empty option when allowEmpty is set) -->\n @for (option of displayOptions(); track $index) {\n <!-- Option activation lives on the trigger via aria-activedescendant; options have tabindex=\"-1\" and never receive DOM focus. -->\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [class.tn-select-empty-option]=\"isEmptyOption(option)\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n\n <!-- Option Groups -->\n @for (group of optionGroups(); track $index; let isFirst = $first) {\n <!-- Group Separator (not shown before first group if we have regular options) -->\n @if (!isFirst || displayOptions().length > 0) {\n <div\n class=\"tn-select-separator\"\n role=\"separator\">\n </div>\n }\n\n <div role=\"group\" [attr.aria-labelledby]=\"'tn-select-group-' + idNamespace() + '-' + $index\">\n <!-- Group Label -->\n <div\n class=\"tn-select-group-label\"\n [attr.id]=\"'tn-select-group-' + idNamespace() + '-' + $index\"\n [class.disabled]=\"group.disabled\">\n {{ group.label }}\n </div>\n\n <!-- Group Options -->\n @for (option of group.options; track $index) {\n <!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->\n <div\n class=\"tn-select-option\"\n role=\"option\"\n tabindex=\"-1\"\n tnTestIdType=\"option\"\n [tnTestId]=\"optionTestIdParts(option)\"\n [attr.id]=\"optionId(option)\"\n [class.selected]=\"isOptionSelected(option)\"\n [class.focused]=\"isOptionFocused(option)\"\n [class.disabled]=\"option.disabled || group.disabled\"\n [attr.aria-selected]=\"isOptionSelected(option)\"\n [attr.aria-disabled]=\"option.disabled || group.disabled\"\n (mousedown)=\"$event.preventDefault()\"\n (click)=\"onOptionClick(option, !!group.disabled)\">\n @if (multiple()) {\n <tn-checkbox\n class=\"tn-select-check\"\n label=\"\"\n [checked]=\"isOptionSelected(option)\"\n [disabled]=\"true\"\n [hideLabel]=\"true\" />\n }\n {{ option.label }}\n </div>\n }\n </div>\n }\n\n <!-- No Options Message -->\n @if (!hasAnyOptions()) {\n <div class=\"tn-select-no-options\">\n {{ noOptionsLabel() }}\n </div>\n }\n </div>\n </div>\n</ng-template>\n", styles: [".tn-select-container{position:relative;width:100%;font-family:var(--tn-font-family-body, \"Inter\"),sans-serif}.tn-select-label{display:block;margin-bottom:.5rem;font-size:1rem;font-weight:500;color:var(--tn-fg1, #333);line-height:1.4}.tn-select-label.required .required-asterisk{color:var(--tn-error, #dc3545);margin-left:.25rem}.tn-select-trigger{position:relative;display:flex;align-items:center;min-height:2.5rem;padding:.5rem 2.5rem .5rem .75rem;background-color:var(--tn-bg1, #ffffff);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;cursor:pointer;transition:all .15s ease-in-out;outline:none;box-sizing:border-box}.tn-select-trigger:hover:not(.disabled){border-color:var(--tn-primary, #007bff)}.tn-select-trigger:focus-visible{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.open{border-color:var(--tn-primary, #007bff);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-primary, #007bff) 25%,transparent)}.tn-select-trigger.error{border-color:var(--tn-error, #dc3545)}.tn-select-trigger.error:focus-visible,.tn-select-trigger.error.open{border-color:var(--tn-error, #dc3545);box-shadow:0 0 0 2px color-mix(in srgb,var(--tn-error, #dc3545) 25%,transparent)}.tn-select-trigger.disabled{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--tn-fg1, #212529)}.tn-select-text.placeholder{color:var(--tn-alt-fg1, #999)}.tn-select-arrow{position:absolute;right:.75rem;top:50%;transform:translateY(-50%);color:var(--tn-fg2, #6c757d);transition:transform .15s ease-in-out;pointer-events:none}.tn-select-arrow.open{transform:translateY(-50%) rotate(180deg)}.tn-select-arrow svg{display:block}.tn-select-dropdown{--tn-select-dropdown-max-height: 350px;background-color:var(--tn-bg2, #f5f5f5);border:1px solid var(--tn-lines, #d1d5db);border-radius:.375rem;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;max-height:var(--tn-select-dropdown-max-height)}.tn-select-options{overflow-y:auto;padding:.25rem 0;max-height:var(--tn-select-dropdown-max-height)}.tn-select-option{display:flex;align-items:center;padding:.5rem .75rem;overflow-wrap:anywhere;cursor:pointer;color:var(--tn-fg1, #212529);transition:background-color .15s ease-in-out;pointer-events:auto;position:relative;z-index:1001}.tn-select-option.selected{background-color:var(--tn-alt-bg1, #f8f9fa);color:var(--tn-fg1, #212529)}.tn-select-option:hover:not(.disabled):not(.focused){background-color:var(--tn-alt-bg2, #f8f9fa)}.tn-select-option.focused{background-color:var(--tn-alt-bg2, #e8f4fd);box-shadow:inset 2px 0 0 var(--tn-primary, #007bff)}.tn-select-option.disabled{color:var(--tn-fg2, #6c757d);cursor:not-allowed;opacity:.6}.tn-select-option.tn-select-empty-option{color:var(--tn-fg2, #6c757d)}.tn-select-select-all{font-weight:600}.tn-select-check{margin-right:.5rem;flex-shrink:0;pointer-events:none}.tn-select-separator{height:1px;background:var(--tn-lines, #e0e0e0);margin:.25rem 0}.tn-select-group-label{padding:.375rem .75rem;font-size:.75rem;font-weight:600;color:var(--tn-alt-fg1, #9ca3af);text-transform:uppercase;letter-spacing:.05em;cursor:default;-webkit-user-select:none;user-select:none}.tn-select-group-label.disabled{opacity:.6}.tn-select-no-options{padding:1rem .75rem;text-align:center;color:var(--tn-fg2, #6c757d);font-style:italic}.tn-select-error{margin-top:.25rem;font-size:.75rem;color:var(--tn-error, #dc3545)}@media(prefers-reduced-motion:reduce){.tn-select-trigger,.tn-select-option,.tn-select-arrow{transition:none}}@media(prefers-contrast:high){.tn-select-trigger{border-width:2px}.tn-select-option.selected{outline:2px solid var(--tn-fg1, #000);outline-offset:-2px}}\n"] }]
9425
+ }], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], optionGroups: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionGroups", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], noOptionsLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "noOptionsLabel", required: false }] }], allowEmpty: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowEmpty", required: false }] }], emptyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyLabel", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], showSelectAll: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSelectAll", required: false }] }], selectAllLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectAllLabel", required: false }] }], testId: [{ type: i0.Input, args: [{ isSignal: true, alias: "testId", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], optionTestIdKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionTestIdKey", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], multiSelectionChange: [{ type: i0.Output, args: ["multiSelectionChange"] }], triggerEl: [{ type: i0.ViewChild, args: ['triggerEl', { isSignal: true }] }], dropdownTemplate: [{ type: i0.ViewChild, args: ['dropdownTemplate', { isSignal: true }] }] } });
9318
9426
 
9319
9427
  /**
9320
9428
  * Harness for interacting with tn-select in tests.
@@ -9472,7 +9580,10 @@ class TnSelectHarness extends ComponentHarness {
9472
9580
  await this.open();
9473
9581
  // Dropdown panel is rendered in a CDK overlay (outside the host element),
9474
9582
  // so we search the document root rather than the harness-local subtree.
9475
- const options = await this.documentRootLocatorFactory().locatorForAll('.tn-select-option')();
9583
+ // Exclude the select-all row (`.tn-select-select-all`) — it's a bulk
9584
+ // action, not a choice; use `toggleSelectAll()` for it.
9585
+ const options = await this.documentRootLocatorFactory()
9586
+ .locatorForAll('.tn-select-option:not(.tn-select-select-all)')();
9476
9587
  for (const option of options) {
9477
9588
  const text = (await option.text()).trim();
9478
9589
  const matches = filter instanceof RegExp
@@ -9532,13 +9643,55 @@ class TnSelectHarness extends ComponentHarness {
9532
9643
  await this.open();
9533
9644
  // Dropdown panel is rendered in a CDK overlay (outside the host element),
9534
9645
  // so we search the document root rather than the harness-local subtree.
9535
- const options = await this.documentRootLocatorFactory().locatorForAll('.tn-select-option')();
9646
+ // The select-all row is excluded — it's a bulk action, not an option.
9647
+ const options = await this.documentRootLocatorFactory()
9648
+ .locatorForAll('.tn-select-option:not(.tn-select-select-all)')();
9536
9649
  const labels = [];
9537
9650
  for (const option of options) {
9538
9651
  labels.push((await option.text()).trim());
9539
9652
  }
9540
9653
  return labels;
9541
9654
  }
9655
+ /**
9656
+ * Toggles the select-all row (multiple mode with `showSelectAll`). Opens the
9657
+ * dropdown if needed. Throws when the select-all row isn't present.
9658
+ *
9659
+ * @returns Promise that resolves once the row has been clicked.
9660
+ *
9661
+ * @example
9662
+ * ```typescript
9663
+ * const select = await loader.getHarness(TnSelectHarness);
9664
+ * await select.toggleSelectAll(); // selects every option
9665
+ * await select.toggleSelectAll(); // clears them again
9666
+ * ```
9667
+ */
9668
+ async toggleSelectAll() {
9669
+ await this.open();
9670
+ // Rendered in the CDK overlay, outside the host subtree.
9671
+ const row = await this.documentRootLocatorFactory()
9672
+ .locatorForOptional('.tn-select-select-all')();
9673
+ if (!row) {
9674
+ await this.close();
9675
+ throw new Error('Select has no select-all row — set `multiple` and `showSelectAll`.');
9676
+ }
9677
+ await row.click();
9678
+ }
9679
+ /**
9680
+ * Whether the select-all checkbox reads as fully checked (all options
9681
+ * selected). Opens the dropdown if needed. Throws when the row isn't present.
9682
+ *
9683
+ * @returns Promise resolving to true when every option is selected.
9684
+ */
9685
+ async isSelectAllChecked() {
9686
+ await this.open();
9687
+ const row = await this.documentRootLocatorFactory()
9688
+ .locatorForOptional('.tn-select-select-all')();
9689
+ if (!row) {
9690
+ await this.close();
9691
+ throw new Error('Select has no select-all row — set `multiple` and `showSelectAll`.');
9692
+ }
9693
+ return row.hasClass('selected');
9694
+ }
9542
9695
  }
9543
9696
 
9544
9697
  /// <reference types="jest" />
@@ -11251,7 +11404,7 @@ class TnTablePagerComponent {
11251
11404
  provider.setPagination(pagination);
11252
11405
  }
11253
11406
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11254
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTablePagerComponent, isStandalone: true, selector: "tn-table-pager", inputs: { testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, currentPage: { classPropertyName: "currentPage", publicName: "currentPage", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, totalItems: { classPropertyName: "totalItems", publicName: "totalItems", isSignal: true, isRequired: false, transformFunction: null }, dataProvider: { classPropertyName: "dataProvider", publicName: "dataProvider", isSignal: true, isRequired: false, transformFunction: null }, itemsPerPageLabel: { classPropertyName: "itemsPerPageLabel", publicName: "itemsPerPageLabel", isSignal: true, isRequired: false, transformFunction: null }, ofLabel: { classPropertyName: "ofLabel", publicName: "ofLabel", isSignal: true, isRequired: false, transformFunction: null }, firstPageLabel: { classPropertyName: "firstPageLabel", publicName: "firstPageLabel", isSignal: true, isRequired: false, transformFunction: null }, previousPageLabel: { classPropertyName: "previousPageLabel", publicName: "previousPageLabel", isSignal: true, isRequired: false, transformFunction: null }, nextPageLabel: { classPropertyName: "nextPageLabel", publicName: "nextPageLabel", isSignal: true, isRequired: false, transformFunction: null }, lastPageLabel: { classPropertyName: "lastPageLabel", publicName: "lastPageLabel", isSignal: true, isRequired: false, transformFunction: null }, tablePaginationLabel: { classPropertyName: "tablePaginationLabel", publicName: "tablePaginationLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentPage: "currentPageChange", pageSize: "pageSizeChange", pageChange: "pageChange", pageSizeChange: "pageSizeChange" }, host: { attributes: { "role": "navigation" }, properties: { "attr.aria-label": "resolvedTablePaginationLabel()" }, classAttribute: "tn-table-pager" }, ngImport: i0, template: "<div class=\"tn-table-pager__page-size\">\n <span class=\"tn-table-pager__label\">{{ resolvedItemsPerPageLabel() }}:</span>\n <tn-select\n class=\"tn-table-pager__size-select\"\n [testId]=\"childTestId('page-size')\"\n [options]=\"pageSizeSelectOptions()\"\n [ariaLabel]=\"resolvedItemsPerPageLabel()\"\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange($event)\" />\n</div>\n\n<span class=\"tn-table-pager__range\">\n @if (effectiveTotalItems() === 0) {\n 0 {{ resolvedOfLabel() }} 0\n } @else if (lastItemOnPage() > firstItemOnPage()) {\n {{ firstItemOnPage() }} \u2013 {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n } @else {\n {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n }\n</span>\n\n<div class=\"tn-table-pager__buttons\">\n <tn-icon-button\n name=\"page-first\"\n library=\"mdi\"\n [testId]=\"childTestId('first-page')\"\n [ariaLabel]=\"resolvedFirstPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"goToPage(1)\" />\n <tn-icon-button\n name=\"chevron-left\"\n library=\"mdi\"\n [testId]=\"childTestId('previous-page')\"\n [ariaLabel]=\"resolvedPreviousPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"previousPage()\" />\n <tn-icon-button\n name=\"chevron-right\"\n library=\"mdi\"\n [testId]=\"childTestId('next-page')\"\n [ariaLabel]=\"resolvedNextPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"nextPage()\" />\n <tn-icon-button\n name=\"page-last\"\n library=\"mdi\"\n [testId]=\"childTestId('last-page')\"\n [ariaLabel]=\"resolvedLastPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"goToPage(totalPages())\" />\n</div>\n", styles: [":host{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:10px;background-color:var(--tn-bg2);border:1px solid var(--tn-lines);color:var(--tn-fg2)}.tn-table-pager__page-size{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.tn-table-pager__label{white-space:nowrap}.tn-table-pager__size-select{min-width:84px}.tn-table-pager__range{white-space:nowrap}.tn-table-pager__buttons{display:flex;align-items:center;gap:4px}@media(max-width:600px){:host{gap:8px}.tn-table-pager__page-size{gap:4px}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "library", "iconClass"], outputs: ["onClick"] }, { kind: "component", type: TnSelectComponent, selector: "tn-select", inputs: ["options", "optionGroups", "placeholder", "ariaLabel", "noOptionsLabel", "allowEmpty", "emptyLabel", "disabled", "testId", "multiple", "optionTestIdKey", "compareWith"], outputs: ["selectionChange", "multiSelectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11407
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTablePagerComponent, isStandalone: true, selector: "tn-table-pager", inputs: { testId: { classPropertyName: "testId", publicName: "testId", isSignal: true, isRequired: false, transformFunction: null }, currentPage: { classPropertyName: "currentPage", publicName: "currentPage", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, pageSizeOptions: { classPropertyName: "pageSizeOptions", publicName: "pageSizeOptions", isSignal: true, isRequired: false, transformFunction: null }, totalItems: { classPropertyName: "totalItems", publicName: "totalItems", isSignal: true, isRequired: false, transformFunction: null }, dataProvider: { classPropertyName: "dataProvider", publicName: "dataProvider", isSignal: true, isRequired: false, transformFunction: null }, itemsPerPageLabel: { classPropertyName: "itemsPerPageLabel", publicName: "itemsPerPageLabel", isSignal: true, isRequired: false, transformFunction: null }, ofLabel: { classPropertyName: "ofLabel", publicName: "ofLabel", isSignal: true, isRequired: false, transformFunction: null }, firstPageLabel: { classPropertyName: "firstPageLabel", publicName: "firstPageLabel", isSignal: true, isRequired: false, transformFunction: null }, previousPageLabel: { classPropertyName: "previousPageLabel", publicName: "previousPageLabel", isSignal: true, isRequired: false, transformFunction: null }, nextPageLabel: { classPropertyName: "nextPageLabel", publicName: "nextPageLabel", isSignal: true, isRequired: false, transformFunction: null }, lastPageLabel: { classPropertyName: "lastPageLabel", publicName: "lastPageLabel", isSignal: true, isRequired: false, transformFunction: null }, tablePaginationLabel: { classPropertyName: "tablePaginationLabel", publicName: "tablePaginationLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { currentPage: "currentPageChange", pageSize: "pageSizeChange", pageChange: "pageChange", pageSizeChange: "pageSizeChange" }, host: { attributes: { "role": "navigation" }, properties: { "attr.aria-label": "resolvedTablePaginationLabel()" }, classAttribute: "tn-table-pager" }, ngImport: i0, template: "<div class=\"tn-table-pager__page-size\">\n <span class=\"tn-table-pager__label\">{{ resolvedItemsPerPageLabel() }}:</span>\n <tn-select\n class=\"tn-table-pager__size-select\"\n [testId]=\"childTestId('page-size')\"\n [options]=\"pageSizeSelectOptions()\"\n [ariaLabel]=\"resolvedItemsPerPageLabel()\"\n [ngModel]=\"pageSize()\"\n (ngModelChange)=\"onPageSizeChange($event)\" />\n</div>\n\n<span class=\"tn-table-pager__range\">\n @if (effectiveTotalItems() === 0) {\n 0 {{ resolvedOfLabel() }} 0\n } @else if (lastItemOnPage() > firstItemOnPage()) {\n {{ firstItemOnPage() }} \u2013 {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n } @else {\n {{ lastItemOnPage() }} {{ resolvedOfLabel() }} {{ effectiveTotalItems() }}\n }\n</span>\n\n<div class=\"tn-table-pager__buttons\">\n <tn-icon-button\n name=\"page-first\"\n library=\"mdi\"\n [testId]=\"childTestId('first-page')\"\n [ariaLabel]=\"resolvedFirstPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"goToPage(1)\" />\n <tn-icon-button\n name=\"chevron-left\"\n library=\"mdi\"\n [testId]=\"childTestId('previous-page')\"\n [ariaLabel]=\"resolvedPreviousPageLabel()\"\n [disabled]=\"isFirstPageDisabled()\"\n (onClick)=\"previousPage()\" />\n <tn-icon-button\n name=\"chevron-right\"\n library=\"mdi\"\n [testId]=\"childTestId('next-page')\"\n [ariaLabel]=\"resolvedNextPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"nextPage()\" />\n <tn-icon-button\n name=\"page-last\"\n library=\"mdi\"\n [testId]=\"childTestId('last-page')\"\n [ariaLabel]=\"resolvedLastPageLabel()\"\n [disabled]=\"isLastPageDisabled()\"\n (onClick)=\"goToPage(totalPages())\" />\n</div>\n", styles: [":host{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:10px;background-color:var(--tn-bg2);border:1px solid var(--tn-lines);color:var(--tn-fg2)}.tn-table-pager__page-size{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.tn-table-pager__label{white-space:nowrap}.tn-table-pager__size-select{min-width:84px}.tn-table-pager__range{white-space:nowrap}.tn-table-pager__buttons{display:flex;align-items:center;gap:4px}@media(max-width:600px){:host{gap:8px}.tn-table-pager__page-size{gap:4px}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "library", "iconClass"], outputs: ["onClick"] }, { kind: "component", type: TnSelectComponent, selector: "tn-select", inputs: ["options", "optionGroups", "placeholder", "ariaLabel", "noOptionsLabel", "allowEmpty", "emptyLabel", "disabled", "showSelectAll", "selectAllLabel", "testId", "multiple", "optionTestIdKey", "compareWith"], outputs: ["selectionChange", "multiSelectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11255
11408
  }
11256
11409
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, decorators: [{
11257
11410
  type: Component,
@@ -11446,7 +11599,7 @@ class TnTreeNodeComponent extends CdkTreeNode {
11446
11599
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11447
11600
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTreeNodeComponent, isStandalone: true, selector: "tn-tree-node", host: { attributes: { "role": "treeitem" }, properties: { "attr.aria-level": "level + 1", "attr.aria-expanded": "isExpandable ? isExpanded : null" }, classAttribute: "tn-tree-node-wrapper" }, providers: [
11448
11601
  { provide: CdkTreeNode, useExisting: TnTreeNodeComponent }
11449
- ], exportAs: ["tnTreeNode"], usesInheritance: true, hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "<div class=\"tn-tree-node\"\n cdkTreeNodeToggle\n role=\"treeitem\"\n [class.tn-tree-node--expandable]=\"isExpandable\"\n [attr.aria-level]=\"level + 1\"\n [attr.aria-expanded]=\"isExpandable ? isExpanded : null\"\n [attr.aria-selected]=\"false\"\n [style.cursor]=\"isExpandable ? 'pointer' : 'default'\">\n \n <div class=\"tn-tree-node__content\">\n <!-- Arrow icon for expandable nodes -->\n @if (isExpandable) {\n <div\n class=\"tn-tree-node__toggle\"\n [class.tn-tree-node__toggle--expanded]=\"isExpanded\">\n <tn-icon\n library=\"mdi\"\n size=\"sm\"\n style=\"transition: transform 0.2s ease;\"\n [name]=\"isExpanded ? 'chevron-down' : 'chevron-right'\" />\n </div>\n }\n\n <!-- Spacer for non-expandable nodes -->\n @if (!isExpandable) {\n <div class=\"tn-tree-node__spacer\"></div>\n }\n \n <!-- Node content -->\n <div class=\"tn-tree-node__text\">\n <ng-content />\n </div>\n </div>\n</div>", styles: [":host{display:block}.tn-tree-node{border-bottom:1px solid var(--tn-lines);transition:background-color .2s ease}.tn-tree-node:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node:last-child{border-bottom:none}.tn-tree-node--expandable{cursor:pointer}.tn-tree-node--expandable:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node--expandable:active{background-color:var(--tn-alt-bg1)}.tn-tree-node__content{display:flex;align-items:center;gap:8px;min-height:48px;padding:12px 16px}.tn-tree-node__toggle{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;background:none;color:var(--tn-fg2);cursor:pointer;border-radius:3px;transition:all .2s ease;flex-shrink:0}.tn-tree-node__toggle:hover{background-color:var(--tn-alt-bg2);color:var(--tn-fg1)}.tn-tree-node__toggle:focus{outline:2px solid var(--tn-primary);outline-offset:1px}.tn-tree-node__toggle svg{transition:transform .2s ease;transform:rotate(0)}.tn-tree-node__toggle--expanded svg{transform:rotate(90deg)}.tn-tree-node__spacer{width:24px;height:24px;flex-shrink:0}.tn-tree-node__text{flex:1;min-width:0;color:var(--tn-fg1)}.tn-tree-node__children{padding-left:24px}.tn-tree-invisible{display:none}\n"], dependencies: [{ kind: "ngmodule", type: CdkTreeModule }, { kind: "directive", type: i2.CdkTreeNodeToggle, selector: "[cdkTreeNodeToggle]", inputs: ["cdkTreeNodeToggleRecursive"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11602
+ ], exportAs: ["tnTreeNode"], usesInheritance: true, hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "<!-- The `role=\"treeitem\"` (plus aria-level/aria-expanded) lives on the host\n `.tn-tree-node-wrapper`, so this inner div is a presentational container \u2014\n no role or aria-* here, to avoid exposing a second, nested treeitem to AT. -->\n<div class=\"tn-tree-node\"\n cdkTreeNodeToggle\n [class.tn-tree-node--expandable]=\"isExpandable\"\n [style.cursor]=\"isExpandable ? 'pointer' : 'default'\">\n \n <div class=\"tn-tree-node__content\">\n <!-- Arrow icon for expandable nodes -->\n @if (isExpandable) {\n <div\n class=\"tn-tree-node__toggle\"\n [class.tn-tree-node__toggle--expanded]=\"isExpanded\">\n <tn-icon\n library=\"mdi\"\n size=\"sm\"\n style=\"transition: transform 0.2s ease;\"\n [name]=\"isExpanded ? 'chevron-down' : 'chevron-right'\" />\n </div>\n }\n\n <!-- Spacer for non-expandable nodes -->\n @if (!isExpandable) {\n <div class=\"tn-tree-node__spacer\"></div>\n }\n \n <!-- Node content -->\n <div class=\"tn-tree-node__text\">\n <ng-content />\n </div>\n </div>\n</div>", styles: [":host{display:block}.tn-tree-node{border-bottom:1px solid var(--tn-lines);transition:background-color .2s ease}.tn-tree-node:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node:last-child{border-bottom:none}.tn-tree-node--expandable{cursor:pointer}.tn-tree-node--expandable:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node--expandable:active{background-color:var(--tn-alt-bg1)}.tn-tree-node__content{display:flex;align-items:center;gap:8px;min-height:48px;padding:12px 16px}.tn-tree-node__toggle{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;background:none;color:var(--tn-fg2);cursor:pointer;border-radius:3px;transition:all .2s ease;flex-shrink:0}.tn-tree-node__toggle:hover{background-color:var(--tn-alt-bg2);color:var(--tn-fg1)}.tn-tree-node__toggle:focus{outline:2px solid var(--tn-primary);outline-offset:1px}.tn-tree-node__toggle svg{transition:transform .2s ease;transform:rotate(0)}.tn-tree-node__toggle--expanded svg{transform:rotate(90deg)}.tn-tree-node__spacer{width:24px;height:24px;flex-shrink:0}.tn-tree-node__text{flex:1;min-width:0;color:var(--tn-fg1)}.tn-tree-node__children{padding-left:24px}.tn-tree-invisible{display:none}\n"], dependencies: [{ kind: "ngmodule", type: CdkTreeModule }, { kind: "directive", type: i2.CdkTreeNodeToggle, selector: "[cdkTreeNodeToggle]", inputs: ["cdkTreeNodeToggleRecursive"] }, { kind: "component", type: TnIconComponent, selector: "tn-icon", inputs: ["name", "size", "color", "tooltip", "ariaLabel", "library", "testId", "fullSize", "customSize"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
11450
11603
  }
11451
11604
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeNodeComponent, decorators: [{
11452
11605
  type: Component,
@@ -11457,7 +11610,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11457
11610
  '[attr.aria-level]': 'level + 1',
11458
11611
  '[attr.aria-expanded]': 'isExpandable ? isExpanded : null',
11459
11612
  'role': 'treeitem'
11460
- }, encapsulation: ViewEncapsulation.Emulated, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-tree-node\"\n cdkTreeNodeToggle\n role=\"treeitem\"\n [class.tn-tree-node--expandable]=\"isExpandable\"\n [attr.aria-level]=\"level + 1\"\n [attr.aria-expanded]=\"isExpandable ? isExpanded : null\"\n [attr.aria-selected]=\"false\"\n [style.cursor]=\"isExpandable ? 'pointer' : 'default'\">\n \n <div class=\"tn-tree-node__content\">\n <!-- Arrow icon for expandable nodes -->\n @if (isExpandable) {\n <div\n class=\"tn-tree-node__toggle\"\n [class.tn-tree-node__toggle--expanded]=\"isExpanded\">\n <tn-icon\n library=\"mdi\"\n size=\"sm\"\n style=\"transition: transform 0.2s ease;\"\n [name]=\"isExpanded ? 'chevron-down' : 'chevron-right'\" />\n </div>\n }\n\n <!-- Spacer for non-expandable nodes -->\n @if (!isExpandable) {\n <div class=\"tn-tree-node__spacer\"></div>\n }\n \n <!-- Node content -->\n <div class=\"tn-tree-node__text\">\n <ng-content />\n </div>\n </div>\n</div>", styles: [":host{display:block}.tn-tree-node{border-bottom:1px solid var(--tn-lines);transition:background-color .2s ease}.tn-tree-node:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node:last-child{border-bottom:none}.tn-tree-node--expandable{cursor:pointer}.tn-tree-node--expandable:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node--expandable:active{background-color:var(--tn-alt-bg1)}.tn-tree-node__content{display:flex;align-items:center;gap:8px;min-height:48px;padding:12px 16px}.tn-tree-node__toggle{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;background:none;color:var(--tn-fg2);cursor:pointer;border-radius:3px;transition:all .2s ease;flex-shrink:0}.tn-tree-node__toggle:hover{background-color:var(--tn-alt-bg2);color:var(--tn-fg1)}.tn-tree-node__toggle:focus{outline:2px solid var(--tn-primary);outline-offset:1px}.tn-tree-node__toggle svg{transition:transform .2s ease;transform:rotate(0)}.tn-tree-node__toggle--expanded svg{transform:rotate(90deg)}.tn-tree-node__spacer{width:24px;height:24px;flex-shrink:0}.tn-tree-node__text{flex:1;min-width:0;color:var(--tn-fg1)}.tn-tree-node__children{padding-left:24px}.tn-tree-invisible{display:none}\n"] }]
11613
+ }, encapsulation: ViewEncapsulation.Emulated, changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- The `role=\"treeitem\"` (plus aria-level/aria-expanded) lives on the host\n `.tn-tree-node-wrapper`, so this inner div is a presentational container \u2014\n no role or aria-* here, to avoid exposing a second, nested treeitem to AT. -->\n<div class=\"tn-tree-node\"\n cdkTreeNodeToggle\n [class.tn-tree-node--expandable]=\"isExpandable\"\n [style.cursor]=\"isExpandable ? 'pointer' : 'default'\">\n \n <div class=\"tn-tree-node__content\">\n <!-- Arrow icon for expandable nodes -->\n @if (isExpandable) {\n <div\n class=\"tn-tree-node__toggle\"\n [class.tn-tree-node__toggle--expanded]=\"isExpanded\">\n <tn-icon\n library=\"mdi\"\n size=\"sm\"\n style=\"transition: transform 0.2s ease;\"\n [name]=\"isExpanded ? 'chevron-down' : 'chevron-right'\" />\n </div>\n }\n\n <!-- Spacer for non-expandable nodes -->\n @if (!isExpandable) {\n <div class=\"tn-tree-node__spacer\"></div>\n }\n \n <!-- Node content -->\n <div class=\"tn-tree-node__text\">\n <ng-content />\n </div>\n </div>\n</div>", styles: [":host{display:block}.tn-tree-node{border-bottom:1px solid var(--tn-lines);transition:background-color .2s ease}.tn-tree-node:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node:last-child{border-bottom:none}.tn-tree-node--expandable{cursor:pointer}.tn-tree-node--expandable:hover{background-color:var(--tn-alt-bg2)}.tn-tree-node--expandable:active{background-color:var(--tn-alt-bg1)}.tn-tree-node__content{display:flex;align-items:center;gap:8px;min-height:48px;padding:12px 16px}.tn-tree-node__toggle{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;background:none;color:var(--tn-fg2);cursor:pointer;border-radius:3px;transition:all .2s ease;flex-shrink:0}.tn-tree-node__toggle:hover{background-color:var(--tn-alt-bg2);color:var(--tn-fg1)}.tn-tree-node__toggle:focus{outline:2px solid var(--tn-primary);outline-offset:1px}.tn-tree-node__toggle svg{transition:transform .2s ease;transform:rotate(0)}.tn-tree-node__toggle--expanded svg{transform:rotate(90deg)}.tn-tree-node__spacer{width:24px;height:24px;flex-shrink:0}.tn-tree-node__text{flex:1;min-width:0;color:var(--tn-fg1)}.tn-tree-node__children{padding-left:24px}.tn-tree-invisible{display:none}\n"] }]
11461
11614
  }], ctorParameters: () => [] });
11462
11615
 
11463
11616
  class TnTreeNodeOutletDirective {
@@ -11542,6 +11695,679 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11542
11695
  }, encapsulation: ViewEncapsulation.Emulated, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"tn-nested-tree-node__content\">\n <!-- Toggle button for expandable nodes (provided by component) -->\n @if (isExpandable) {\n <button\n class=\"tn-nested-tree-node__toggle\"\n cdkTreeNodeToggle\n type=\"button\"\n [class.tn-nested-tree-node__toggle--expanded]=\"isExpanded\"\n [attr.aria-label]=\"'Toggle node'\">\n <tn-icon\n library=\"mdi\"\n size=\"sm\"\n style=\"transition: transform 0.2s ease;\"\n [name]=\"isExpanded ? 'chevron-down' : 'chevron-right'\" />\n </button>\n }\n\n <!-- Spacer for non-expandable nodes to maintain alignment -->\n @if (!isExpandable) {\n <div class=\"tn-nested-tree-node__spacer\"></div>\n }\n\n <!-- Consumer content -->\n <ng-content />\n</div>\n\n<!-- Children container -->\n@if (isExpandable) {\n <div class=\"tn-nested-tree-node-container\" role=\"group\" [class.tn-tree-invisible]=\"!isExpanded\">\n <ng-content select=\"[slot=children]\" />\n </div>\n}", styles: [".tn-nested-tree-node-wrapper{display:block;width:100%}.tn-nested-tree-node{display:block;width:100%;font-family:var(--tn-font-family-body);font-size:1rem;line-height:1.4;color:var(--tn-fg1)}.tn-nested-tree-node--expandable .tn-nested-tree-node__content{cursor:pointer}.tn-nested-tree-node__content{display:flex;align-items:center;gap:8px;min-height:48px;padding:12px 16px;border-bottom:1px solid var(--tn-lines);transition:background-color .2s ease}.tn-nested-tree-node__content:hover{background-color:var(--tn-alt-bg2)}.tn-nested-tree-node__content:focus-within{background-color:var(--tn-alt-bg2);outline:2px solid var(--tn-primary);outline-offset:-2px}.tn-tree-invisible{display:none}.tn-nested-tree-node__toggle{display:flex;align-items:center;justify-content:center;width:24px;height:24px;margin-right:8px;padding:0;border:none;background:transparent;border-radius:4px;cursor:pointer;color:var(--tn-fg2);transition:background-color .2s ease,color .2s ease}.tn-nested-tree-node__toggle:hover{background-color:var(--tn-bg3);color:var(--tn-fg1)}.tn-nested-tree-node__toggle:focus{outline:2px solid var(--tn-primary);outline-offset:2px}.tn-nested-tree-node__toggle svg{transition:transform .2s ease}.tn-nested-tree-node__toggle--expanded svg{transform:rotate(90deg)}.tn-nested-tree-node__spacer{width:24px;height:24px;flex-shrink:0}.tn-nested-tree-node__text{flex:1;display:flex;align-items:center;gap:8px;min-width:0;color:var(--tn-fg1)}div.tn-nested-tree-node-container{padding-left:40px}@media(prefers-reduced-motion:reduce){.tn-nested-tree-node__toggle svg,.tn-nested-tree-node__content,.tn-nested-tree-node__children{transition:none}}@media(prefers-contrast:high){.tn-nested-tree-node__content{border:1px solid transparent}.tn-nested-tree-node__content:hover,.tn-nested-tree-node__content:focus-within{border-color:var(--tn-fg1)}.tn-nested-tree-node__toggle{border:1px solid var(--tn-fg2)}.tn-nested-tree-node__toggle:hover,.tn-nested-tree-node__toggle:focus{border-color:var(--tn-fg1)}}\n"] }]
11543
11696
  }], ctorParameters: () => [] });
11544
11697
 
11698
+ /**
11699
+ * Renders a single virtual tree row.
11700
+ *
11701
+ * `cdk-virtual-scroll-viewport` materialises this outlet for each row that scrolls
11702
+ * into view; the outlet instantiates the node's template with its context and
11703
+ * (re)binds `CdkTreeNode.mostRecentTreeNode.data`, so the row behaves exactly as a
11704
+ * node rendered directly by the CDK tree. When the same DOM view is recycled for a
11705
+ * different node (virtual scrolling reuses views), only the context is patched.
11706
+ */
11707
+ class TnTreeVirtualScrollNodeOutletDirective {
11708
+ _viewContainerRef = inject(ViewContainerRef);
11709
+ _viewRef = null;
11710
+ data = input.required(...(ngDevMode ? [{ debugName: "data" }] : []));
11711
+ /**
11712
+ * Re-assert the row's aria position every check. The viewport's recycling view
11713
+ * repeater can swap the underlying DOM view for a different row without our
11714
+ * `ngOnChanges` writing the new `aria-setsize`/`aria-posinset` in a way that
11715
+ * survives the swap, so we reconcile the current element against the current data
11716
+ * on each change-detection pass (two cheap attribute writes) — self-healing.
11717
+ */
11718
+ ngDoCheck() {
11719
+ this.applyAriaPosition();
11720
+ this.applyKeyboardAffordance();
11721
+ }
11722
+ ngOnChanges(changes) {
11723
+ const dataChange = changes['data'];
11724
+ const recreateView = dataChange ? this.shouldRecreateView(dataChange) : false;
11725
+ if (recreateView) {
11726
+ const viewContainerRef = this._viewContainerRef;
11727
+ if (this._viewRef) {
11728
+ viewContainerRef.remove(viewContainerRef.indexOf(this._viewRef));
11729
+ }
11730
+ this._viewRef = this.data()
11731
+ ? viewContainerRef.createEmbeddedView(this.data().nodeDef.template, this.data().context)
11732
+ : null;
11733
+ // Bind the freshly-created CdkTreeNode to this row's data. `mostRecentTreeNode`
11734
+ // is a global CDK static (verified against @angular/cdk 21.x) set synchronously
11735
+ // while `createEmbeddedView` above instantiates the node template. This mirrors
11736
+ // CDK's own internal wiring and assumes the node def's template contains exactly
11737
+ // one CdkTreeNode; it is fragile under view recycling, hence the shape check that
11738
+ // gates recreation. A CDK upgrade that changes this static would stop rows from
11739
+ // binding their data — covered by the outlet's rendering tests.
11740
+ if (CdkTreeNode.mostRecentTreeNode && this._viewRef) {
11741
+ CdkTreeNode.mostRecentTreeNode.data = this.data().data;
11742
+ }
11743
+ }
11744
+ else if (this._viewRef && this.data().context) {
11745
+ this.updateExistingContext(this.data().context);
11746
+ }
11747
+ }
11748
+ /**
11749
+ * Whether the row must be re-instantiated (vs. patched in place). Every wrapper
11750
+ * carries the same fixed key set (`data`/`context`/`nodeDef`/`posInSet`/`setSize`),
11751
+ * so recreation comes down to whether the raw data node is a different reference —
11752
+ * that is what marks a genuinely different row (a missing wrapper also recreates).
11753
+ */
11754
+ shouldRecreateView(dataChange) {
11755
+ const prevValue = dataChange.previousValue;
11756
+ const currValue = dataChange.currentValue;
11757
+ return prevValue?.data !== currValue?.data;
11758
+ }
11759
+ /**
11760
+ * Reflect posinset/setsize onto the row element so screen readers can announce
11761
+ * "item N of M". Runs on every `ngDoCheck` (the hot CD path), so only write when the
11762
+ * attribute actually differs — skipping the redundant `setAttribute` on the common
11763
+ * pass where nothing changed.
11764
+ */
11765
+ applyAriaPosition() {
11766
+ const root = this._viewRef?.rootNodes?.[0];
11767
+ const data = this.data();
11768
+ if (!root?.setAttribute || !data) {
11769
+ return;
11770
+ }
11771
+ const setSize = String(data.setSize);
11772
+ const posInSet = String(data.posInSet);
11773
+ if (root.getAttribute('aria-setsize') !== setSize) {
11774
+ root.setAttribute('aria-setsize', setSize);
11775
+ }
11776
+ if (root.getAttribute('aria-posinset') !== posInSet) {
11777
+ root.setAttribute('aria-posinset', posInSet);
11778
+ }
11779
+ }
11780
+ /**
11781
+ * Make expandable rows keyboard-focusable so a user can Tab to a pool/branch and
11782
+ * expand it with Enter/Space (the host `TnTreeVirtualScrollViewComponent` handles the
11783
+ * key). The virtual viewport recycles a row's view between expandable and leaf nodes,
11784
+ * so this reconciles every check; leaf rows are left to the consumer's own interactive
11785
+ * content (e.g. a `routerLink` anchor). Expandability is read from `aria-expanded`,
11786
+ * which `tn-tree-node` sets only on expandable nodes. Only touch the DOM on change —
11787
+ * this runs on the hot CD path.
11788
+ */
11789
+ applyKeyboardAffordance() {
11790
+ const root = this._viewRef?.rootNodes?.[0];
11791
+ if (!root?.setAttribute) {
11792
+ return;
11793
+ }
11794
+ const expandable = root.hasAttribute('aria-expanded');
11795
+ if (expandable) {
11796
+ if (root.getAttribute('tabindex') !== '0') {
11797
+ root.setAttribute('tabindex', '0');
11798
+ }
11799
+ }
11800
+ else if (root.getAttribute('tabindex') === '0') {
11801
+ root.removeAttribute('tabindex');
11802
+ }
11803
+ }
11804
+ updateExistingContext(ctx) {
11805
+ if (!this._viewRef) {
11806
+ return;
11807
+ }
11808
+ for (const propName of Object.keys(ctx)) {
11809
+ this._viewRef.context[propName]
11810
+ = this.data().context[propName];
11811
+ }
11812
+ }
11813
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeVirtualScrollNodeOutletDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
11814
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.1.0", type: TnTreeVirtualScrollNodeOutletDirective, isStandalone: true, selector: "[tnTreeVirtualScrollNodeOutlet]", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: true, transformFunction: null } }, usesOnChanges: true, ngImport: i0 });
11815
+ }
11816
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeVirtualScrollNodeOutletDirective, decorators: [{
11817
+ type: Directive,
11818
+ args: [{
11819
+ selector: '[tnTreeVirtualScrollNodeOutlet]',
11820
+ standalone: true,
11821
+ }]
11822
+ }], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: true }] }] } });
11823
+
11824
+ /** Default fixed row height (px) used by the virtual scroll viewport. */
11825
+ const defaultTreeItemSize = 48;
11826
+ /**
11827
+ * Row counts for the default buffer sizes (how many extra rows of content the viewport
11828
+ * renders past the visible area) and for the distance the user must scroll before the
11829
+ * scroll-to-top button appears. Expressed in rows and multiplied by `itemSize()` at
11830
+ * runtime (see resolvedMin/MaxBufferPx) so the defaults scale with a custom row height.
11831
+ */
11832
+ const defaultMinBufferRows = 4;
11833
+ const defaultBufferRows = 8;
11834
+ const scrollToTopThresholdRows = 8;
11835
+ const scrollFrameScheduler = typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler;
11836
+ /**
11837
+ * A `tn-tree` that renders through a `cdk-virtual-scroll-viewport`, so only the
11838
+ * rows currently in view are materialised in the DOM. Drop-in for large trees
11839
+ * (thousands of nodes) where the plain `tn-tree` would render every node.
11840
+ *
11841
+ * Usage mirrors `tn-tree`: provide a `TnTreeFlatDataSource` + `FlatTreeControl`
11842
+ * and one `*cdkTreeNodeDef` template. Rows are a fixed height (`itemSize`).
11843
+ *
11844
+ * ```html
11845
+ * <tn-tree-virtual-scroll-view [dataSource]="dataSource" [treeControl]="treeControl" [itemSize]="52">
11846
+ * <tn-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding [routerLink]="...">
11847
+ * {{ node.name }}
11848
+ * </tn-tree-node>
11849
+ * </tn-tree-virtual-scroll-view>
11850
+ * ```
11851
+ *
11852
+ * Accessibility: rows are exposed with `role="treeitem"` plus `aria-level`,
11853
+ * `aria-posinset` and `aria-setsize` (the latter two relative to the node's
11854
+ * siblings — nodes sharing the same parent/level — per the WAI-ARIA tree
11855
+ * pattern, not the flattened list of all visible rows), and the virtual-scroll
11856
+ * wrappers are marked `role="presentation"` so assistive tech still sees the
11857
+ * items as children of the `role="tree"` host.
11858
+ *
11859
+ * Keyboard: expandable rows are focusable and expand/collapse with Enter/Space (see
11860
+ * {@link onTreeKeydown}), so a keyboard/AT user can operate the `aria-expanded` that
11861
+ * each expandable `treeitem` advertises. Roving arrow-key / Home / End navigation
11862
+ * BETWEEN nodes is NOT supported here, though — because rows are materialised (and
11863
+ * recycled) by the virtual scroll viewport rather than registered with CdkTree's node
11864
+ * outlet, the CDK `TreeKeyManager` has no stable node set to drive. Use Tab to move
11865
+ * between rows, or the non-virtual `tn-tree` when full roving navigation is required.
11866
+ */
11867
+ class TnTreeVirtualScrollViewComponent extends CdkTree {
11868
+ destroyRef = inject(DestroyRef);
11869
+ cdr = inject(ChangeDetectorRef);
11870
+ virtualScrollViewport = viewChild.required(CdkVirtualScrollViewport);
11871
+ /** Fixed row height in px. Must match the actual rendered node height. */
11872
+ itemSize = input(defaultTreeItemSize, ...(ngDevMode ? [{ debugName: "itemSize" }] : []));
11873
+ /**
11874
+ * Viewport buffer sizes (px). Leave at 0 (the default) to derive them from `itemSize()`
11875
+ * — {@link defaultMinBufferRows} / {@link defaultBufferRows} rows — so the buffers scale
11876
+ * with a custom row height; pass an explicit px value to override. (A fixed px default
11877
+ * would under-buffer a tall `itemSize`, even failing to buffer a single row when
11878
+ * `itemSize > maxBufferPx`.)
11879
+ */
11880
+ minBufferPx = input(0, ...(ngDevMode ? [{ debugName: "minBufferPx" }] : []));
11881
+ maxBufferPx = input(0, ...(ngDevMode ? [{ debugName: "maxBufferPx" }] : []));
11882
+ /** `minBufferPx` resolved to px, falling back to a row-count default that tracks `itemSize()`. */
11883
+ resolvedMinBufferPx = computed(() => this.minBufferPx() || this.itemSize() * defaultMinBufferRows, ...(ngDevMode ? [{ debugName: "resolvedMinBufferPx" }] : []));
11884
+ /** `maxBufferPx` resolved to px, falling back to a row-count default that tracks `itemSize()`. */
11885
+ resolvedMaxBufferPx = computed(() => this.maxBufferPx() || this.itemSize() * defaultBufferRows, ...(ngDevMode ? [{ debugName: "resolvedMaxBufferPx" }] : []));
11886
+ /**
11887
+ * When true the viewport scrolls with the window instead of an internal scroll area.
11888
+ * `booleanAttribute` so the bare presence form (`<... scrollWindow>`) coerces to `true`
11889
+ * rather than the empty-string (falsy) an un-transformed input would receive.
11890
+ *
11891
+ * Set once at initialisation — NOT reactive. The scroll listener, presentational roles
11892
+ * and ResizeObserver are wired in `ngAfterViewInit` against the viewport that this input
11893
+ * selected; toggling it later re-renders the viewport but does not re-run that wiring.
11894
+ */
11895
+ scrollWindow = input(false, { ...(ngDevMode ? { debugName: "scrollWindow" } : {}), transform: booleanAttribute });
11896
+ /** Whether to show the floating "scroll to top" button once scrolled down. */
11897
+ showScrollToTop = input(true, { ...(ngDevMode ? { debugName: "showScrollToTop" } : {}), transform: booleanAttribute });
11898
+ /** Accessible label / tooltip for the scroll-to-top button (i18n is the consumer's job). */
11899
+ scrollToTopLabel = input('Scroll to top', ...(ngDevMode ? [{ debugName: "scrollToTopLabel" }] : []));
11900
+ /**
11901
+ * Per-row trackBy over the ORIGINAL data node (not the internal wrapper). Strongly
11902
+ * recommended: without it rows track by index, so the recycling viewport reuses a
11903
+ * detached view for whatever node lands at that index on data changes. Pass a stable
11904
+ * key (e.g. `(_, node) => node.id`) as the stories do.
11905
+ */
11906
+ nodeTrackBy = input(...(ngDevMode ? [undefined, { debugName: "nodeTrackBy" }] : []));
11907
+ /** Emits the viewport's horizontal `scrollLeft` (used to sync a sticky column header). */
11908
+ viewportScrolled = output();
11909
+ /**
11910
+ * Emits the observed content size whenever it changes (used to size a sticky header).
11911
+ * `width` is the full rendered content width — including horizontal overflow past the
11912
+ * viewport — which is what a horizontally-synced header needs. `height` is the observed
11913
+ * content wrapper's height: the visible viewport height in internal-scroll mode, but the
11914
+ * FULL content height (row count × `itemSize`) in `scrollWindow` mode, where the wrapper
11915
+ * grows with the document. Consumers wanting the visible viewport height should not rely
11916
+ * on this field in window mode.
11917
+ */
11918
+ viewportResized = output();
11919
+ /** Last `scrollLeft` emitted via {@link viewportScrolled}; used to skip redundant emits. */
11920
+ lastEmittedScrollLeft = 0;
11921
+ nodes$ = new BehaviorSubject([]);
11922
+ innerTrackBy = computed(() => {
11923
+ const fn = this.nodeTrackBy();
11924
+ return fn
11925
+ ? (index, node) => fn(index, node.data)
11926
+ : (index) => index;
11927
+ }, ...(ngDevMode ? [{ debugName: "innerTrackBy" }] : []));
11928
+ renderNodeChanges$ = new BehaviorSubject([]);
11929
+ resizeObserver = null;
11930
+ scrollViewportElement = null;
11931
+ scrollFrameSubscription = null;
11932
+ constructor() {
11933
+ super(inject(IterableDiffers), inject(ChangeDetectorRef), inject(ViewContainerRef));
11934
+ this.listenForNodeChanges();
11935
+ }
11936
+ ngAfterViewInit() {
11937
+ super.ngAfterViewInit?.();
11938
+ const element = this.virtualScrollViewport().elementRef.nativeElement;
11939
+ const scrollable = this.virtualScrollViewport().scrollable;
11940
+ // `getElementRef()` resolves to the viewport in the self-scrolling case and to the
11941
+ // external scroll element otherwise — in scrollWindow mode that is
11942
+ // `document.documentElement`, which is what we read `scrollLeft` from below.
11943
+ this.scrollViewportElement = scrollable.getElementRef().nativeElement;
11944
+ // React to scrolls via the scrollable's own stream rather than addEventListener on
11945
+ // that element ref. In scrollWindow mode the scroll EVENTS fire on `document` while
11946
+ // the element ref is `document.documentElement`, so a raw listener on the element
11947
+ // would never fire and the scroll-to-top button / `viewportScrolled` could not react.
11948
+ // `elementScrolled()` is wired to the correct target for both internal and window modes.
11949
+ scrollable.elementScrolled()
11950
+ .pipe(takeUntilDestroyed(this.destroyRef))
11951
+ .subscribe(this.onViewportScroll);
11952
+ // Observe the rendered CONTENT wrapper rather than the viewport itself, so
11953
+ // `viewportResized` reports the full (possibly horizontally-overflowing) content
11954
+ // width — that is what a sticky column header needs to stay aligned with the rows.
11955
+ // Falls back to the viewport element if the wrapper is not present.
11956
+ const contentWrapper = element.querySelector('.cdk-virtual-scroll-content-wrapper');
11957
+ const observed = contentWrapper ?? element;
11958
+ // The CDK viewport + content wrapper sit between the host `role="tree"` and the
11959
+ // `role="treeitem"` rows. Mark them presentational so assistive tech collapses
11960
+ // them and still sees the rows as direct tree items (ARIA requires treeitems to
11961
+ // be children of the tree, optionally through a presentational container).
11962
+ element.setAttribute('role', 'presentation');
11963
+ contentWrapper?.setAttribute('role', 'presentation');
11964
+ // Guarded for non-browser environments (SSR, jsdom-based unit tests) where
11965
+ // ResizeObserver is absent; the resize output simply does not emit there.
11966
+ if (typeof ResizeObserver !== 'undefined') {
11967
+ this.resizeObserver = new ResizeObserver((entries) => {
11968
+ const rect = entries[0]?.contentRect;
11969
+ if (rect) {
11970
+ this.viewportResized.emit({ width: rect.width, height: rect.height });
11971
+ }
11972
+ });
11973
+ this.resizeObserver.observe(observed);
11974
+ }
11975
+ }
11976
+ ngOnDestroy() {
11977
+ super.ngOnDestroy();
11978
+ // The scroll subscription is torn down by takeUntilDestroyed; just drop refs here.
11979
+ this.scrollViewportElement = null;
11980
+ this.scrollFrameSubscription?.unsubscribe();
11981
+ this.scrollFrameSubscription = null;
11982
+ this.resizeObserver?.disconnect();
11983
+ this.resizeObserver = null;
11984
+ }
11985
+ /**
11986
+ * Cached visibility of the scroll-to-top button. Recomputed only on scroll (see
11987
+ * {@link onViewportScroll}) rather than read as a getter every change-detection
11988
+ * pass, so `measureScrollOffset` — a layout-forcing read — is not called each cycle.
11989
+ */
11990
+ isScrollTopButtonVisible = false;
11991
+ scrollToTop() {
11992
+ // Use scrollToOffset (which delegates to the viewport's scrollable) rather than
11993
+ // scrollTo (which scrolls the viewport element directly) so this also scrolls an
11994
+ // external scroll container back to the top.
11995
+ this.virtualScrollViewport().scrollToOffset(0, 'smooth');
11996
+ this.cdr.markForCheck();
11997
+ }
11998
+ /**
11999
+ * Enter/Space on a focused expandable row toggles its expansion. The virtual viewport
12000
+ * recycles rows so CDK's `TreeKeyManager` can't drive them (see class docs); this keeps
12001
+ * the expand/collapse that each `aria-expanded` row advertises keyboard-operable, via
12002
+ * event delegation on the `role="tree"` host that reuses the row's `cdkTreeNodeToggle`
12003
+ * click handler. Ignored when focus is on an inner control (e.g. a `routerLink` anchor)
12004
+ * so that element keeps its own Enter/Space behaviour.
12005
+ */
12006
+ onTreeKeydown(event) {
12007
+ if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar') {
12008
+ return;
12009
+ }
12010
+ const target = event.target;
12011
+ const row = target?.closest?.('.tn-tree-node-wrapper[role="treeitem"]');
12012
+ if (!row || row !== target || !row.hasAttribute('aria-expanded')) {
12013
+ return;
12014
+ }
12015
+ const toggle = row.querySelector('[cdkTreeNodeToggle]');
12016
+ if (toggle) {
12017
+ // Prevent Space from scrolling the page; reuse the toggle's existing click handler.
12018
+ event.preventDefault();
12019
+ toggle.click();
12020
+ }
12021
+ }
12022
+ updateScrollTopButtonVisibility() {
12023
+ const visible = this.showScrollToTop()
12024
+ && this.virtualScrollViewport().measureScrollOffset('top') > this.itemSize() * scrollToTopThresholdRows;
12025
+ if (visible !== this.isScrollTopButtonVisible) {
12026
+ this.isScrollTopButtonVisible = visible;
12027
+ this.cdr.markForCheck();
12028
+ }
12029
+ }
12030
+ // CdkTree calls this with the full set of currently-visible nodes. Instead of
12031
+ // rendering them into the outlet, feed them to the virtual scroll pipeline.
12032
+ renderNodeChanges(data) {
12033
+ this.renderNodeChanges$.next(data);
12034
+ }
12035
+ getNodeLevel(nodeData) {
12036
+ return this.treeControl?.getLevel ? this.treeControl.getLevel(nodeData) : 0;
12037
+ }
12038
+ /**
12039
+ * Compute per-row `aria-posinset` / `aria-setsize` scoped to each node's SIBLINGS
12040
+ * (same parent / same level), as the WAI-ARIA tree pattern requires — not the flat
12041
+ * list of all visible rows. The flat node set is depth-first ordered and each node
12042
+ * carries a level, so siblings at level L are the consecutive level-L nodes bounded
12043
+ * by any shallower node (level < L = a parent boundary); deeper nodes in between are
12044
+ * descendants of an earlier sibling and don't split the group. One pass, tracking the
12045
+ * open sibling group at each level.
12046
+ */
12047
+ computeAriaPositions(levels) {
12048
+ const posInSet = new Array(levels.length);
12049
+ const setSize = new Array(levels.length);
12050
+ // groups[level] = indices of the currently-open sibling group at that depth.
12051
+ const groups = [];
12052
+ // Close (and stamp setSize on) every group deeper than `level`, because reaching a
12053
+ // node at `level` ends any sibling groups nested under the previous sibling.
12054
+ const finalizeDeeperThan = (level) => {
12055
+ for (let l = groups.length - 1; l > level; l--) {
12056
+ const group = groups[l];
12057
+ if (group?.length) {
12058
+ for (const idx of group) {
12059
+ setSize[idx] = group.length;
12060
+ }
12061
+ }
12062
+ }
12063
+ groups.length = level + 1;
12064
+ };
12065
+ for (let i = 0; i < levels.length; i++) {
12066
+ const level = levels[i];
12067
+ finalizeDeeperThan(level);
12068
+ (groups[level] ??= []).push(i);
12069
+ posInSet[i] = groups[level].length;
12070
+ }
12071
+ // Close the groups still open at the end of the list.
12072
+ finalizeDeeperThan(-1);
12073
+ return levels.map((_, i) => ({ posInSet: posInSet[i], setSize: setSize[i] }));
12074
+ }
12075
+ createNode(nodeData, index, level, posInSet, setSize) {
12076
+ // `_getNodeDef` is a private CdkTree API (verified against @angular/cdk 21.x). Assert
12077
+ // it resolves so a CDK upgrade that renames/removes it fails loudly here rather than
12078
+ // silently rendering a blank tree at runtime.
12079
+ const nodeDef = this._getNodeDef(nodeData, index);
12080
+ if (!nodeDef) {
12081
+ throw new Error('TnTreeVirtualScrollView: CdkTree._getNodeDef returned no node definition. '
12082
+ + 'This private @angular/cdk API (expected ^21.1.0) may have changed — verify tree compatibility.');
12083
+ }
12084
+ const context = new CdkTreeNodeOutletContext(nodeData);
12085
+ context.level = level;
12086
+ // posInSet/setSize let a screen reader announce "item N of M" even though only a
12087
+ // slice of rows exists in the DOM at any time. They are scoped to this node's
12088
+ // siblings (see computeAriaPositions), per the WAI-ARIA tree pattern.
12089
+ return {
12090
+ data: nodeData, context, nodeDef, posInSet, setSize,
12091
+ };
12092
+ }
12093
+ buildVirtualNodes(data) {
12094
+ const levels = data.map((node) => this.getNodeLevel(node));
12095
+ const positions = this.computeAriaPositions(levels);
12096
+ return data.map((node, index) => this.createNode(node, index, levels[index], positions[index].posInSet, positions[index].setSize));
12097
+ }
12098
+ listenForNodeChanges() {
12099
+ this.renderNodeChanges$.pipe(auditTime(0, scrollFrameScheduler), map((data) => this.buildVirtualNodes(data)), takeUntilDestroyed(this.destroyRef)).subscribe((nodes) => {
12100
+ this.nodes$.next(nodes);
12101
+ this.cdr.markForCheck();
12102
+ // The viewport's recycling repeater may swap a row's DOM view during the render
12103
+ // triggered above, leaving the outlet's aria-posinset/aria-setsize written to a
12104
+ // now-detached element. Schedule one more check so the outlet's ngDoCheck
12105
+ // reconciles the aria attributes onto the settled elements.
12106
+ queueMicrotask(() => this.cdr.markForCheck());
12107
+ // The visible node set just changed (e.g. a large branch was collapsed), which can
12108
+ // shrink the scrollable content and clamp the scroll offset without firing a scroll
12109
+ // event. Re-evaluate the scroll-to-top button after layout settles so it can't linger
12110
+ // over content that is no longer scrollable. Guarded by the live element ref so a
12111
+ // frame scheduled just before destroy doesn't measure a torn-down viewport.
12112
+ scrollFrameScheduler.schedule(() => {
12113
+ if (this.scrollViewportElement) {
12114
+ this.updateScrollTopButtonVisibility();
12115
+ }
12116
+ });
12117
+ });
12118
+ }
12119
+ onViewportScroll = () => {
12120
+ // Scroll events can fire several times per frame. Coalesce the work — a
12121
+ // horizontal-offset emit and `measureScrollOffset` (a layout-forcing read) — into a
12122
+ // single animation frame so fast scrolls don't trigger repeated forced reflows.
12123
+ if (this.scrollFrameSubscription) {
12124
+ return;
12125
+ }
12126
+ this.scrollFrameSubscription = scrollFrameScheduler.schedule(() => {
12127
+ this.scrollFrameSubscription = null;
12128
+ // Read scrollLeft from the actual scroll source, not the viewport element: in
12129
+ // scrollWindow mode the viewport itself does not scroll (the window/external
12130
+ // element does), so the viewport's own scrollLeft would always be 0. Only emit when
12131
+ // it actually changed — this fires on vertical scrolls too, where scrollLeft is
12132
+ // unchanged, and a consumer syncing a sticky header would otherwise re-apply the
12133
+ // same translateX on every vertical scroll frame of a large tree.
12134
+ const left = this.scrollViewportElement?.scrollLeft ?? 0;
12135
+ if (left !== this.lastEmittedScrollLeft) {
12136
+ this.lastEmittedScrollLeft = left;
12137
+ this.viewportScrolled.emit(left);
12138
+ }
12139
+ // Re-evaluate the scroll-to-top button on every scroll (the scroll source may be an
12140
+ // external, OnPush-detached container, so nothing else marks this view dirty).
12141
+ this.updateScrollTopButtonVisibility();
12142
+ });
12143
+ };
12144
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeVirtualScrollViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
12145
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: TnTreeVirtualScrollViewComponent, isStandalone: true, selector: "tn-tree-virtual-scroll-view", inputs: { itemSize: { classPropertyName: "itemSize", publicName: "itemSize", isSignal: true, isRequired: false, transformFunction: null }, minBufferPx: { classPropertyName: "minBufferPx", publicName: "minBufferPx", isSignal: true, isRequired: false, transformFunction: null }, maxBufferPx: { classPropertyName: "maxBufferPx", publicName: "maxBufferPx", isSignal: true, isRequired: false, transformFunction: null }, scrollWindow: { classPropertyName: "scrollWindow", publicName: "scrollWindow", isSignal: true, isRequired: false, transformFunction: null }, showScrollToTop: { classPropertyName: "showScrollToTop", publicName: "showScrollToTop", isSignal: true, isRequired: false, transformFunction: null }, scrollToTopLabel: { classPropertyName: "scrollToTopLabel", publicName: "scrollToTopLabel", isSignal: true, isRequired: false, transformFunction: null }, nodeTrackBy: { classPropertyName: "nodeTrackBy", publicName: "nodeTrackBy", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewportScrolled: "viewportScrolled", viewportResized: "viewportResized" }, host: { attributes: { "role": "tree" }, listeners: { "keydown": "onTreeKeydown($event)" }, properties: { "style.--tn-tree-item-size.px": "itemSize()" }, classAttribute: "tn-tree tn-tree-virtual-scroll-view" }, providers: [
12146
+ { provide: CdkTree, useExisting: TnTreeVirtualScrollViewComponent },
12147
+ ], viewQueries: [{ propertyName: "virtualScrollViewport", first: true, predicate: CdkVirtualScrollViewport, descendants: true, isSignal: true }], exportAs: ["tnTreeVirtualScrollView"], usesInheritance: true, hostDirectives: [{ directive: TnTestIdDirective, inputs: ["tnTestId", "testId"] }], ngImport: i0, template: "<!-- The two branches are intentionally near-identical: `scrollWindow` is a structural\n directive (CdkVirtualScrollableWindow) selected by the bare attribute's presence, so\n it can't be toggled via a property binding on a single viewport. Don't DRY this into\n one element \u2014 dropping the attribute selection breaks window-scroll mode. -->\n@if (scrollWindow()) {\n <cdk-virtual-scroll-viewport\n scrollWindow\n [itemSize]=\"itemSize()\"\n [minBufferPx]=\"resolvedMinBufferPx()\"\n [maxBufferPx]=\"resolvedMaxBufferPx()\"\n >\n <ng-container *cdkVirtualFor=\"let item of nodes$ | async; trackBy: innerTrackBy()\">\n <ng-template tnTreeVirtualScrollNodeOutlet [data]=\"item\" />\n </ng-container>\n </cdk-virtual-scroll-viewport>\n} @else {\n <cdk-virtual-scroll-viewport\n class=\"tn-tree-virtual-scroll-view__viewport\"\n [itemSize]=\"itemSize()\"\n [minBufferPx]=\"resolvedMinBufferPx()\"\n [maxBufferPx]=\"resolvedMaxBufferPx()\"\n >\n <ng-container *cdkVirtualFor=\"let item of nodes$ | async; trackBy: innerTrackBy()\">\n <ng-template tnTreeVirtualScrollNodeOutlet [data]=\"item\" />\n </ng-container>\n </cdk-virtual-scroll-viewport>\n}\n\n<!-- Required so CdkTree finds a node outlet during init, but intentionally left\n empty: rows are rendered through the virtual scroll viewport above, not here. -->\n<ng-container cdkTreeNodeOutlet />\n\n@if (isScrollTopButtonVisible) {\n <tn-icon-button\n name=\"arrow-up\"\n library=\"mdi\"\n class=\"tn-tree-virtual-scroll-view__scroll-top\"\n testId=\"scroll-to-top\"\n [class.tn-tree-virtual-scroll-view__scroll-top--fixed]=\"scrollWindow()\"\n [ariaLabel]=\"scrollToTopLabel()\"\n [tooltip]=\"scrollToTopLabel()\"\n (onClick)=\"scrollToTop()\"\n />\n}\n", styles: [".tn-tree-virtual-scroll-view__viewport{height:100%;width:100%}.tn-tree-virtual-scroll-view{display:block;position:relative;width:100%;height:100%;--tn-tree-item-size: 48px}.tn-tree-virtual-scroll-view .tn-tree-node__content{height:var(--tn-tree-item-size);min-height:var(--tn-tree-item-size);padding-top:0;padding-bottom:0;box-sizing:border-box}.tn-tree-virtual-scroll-view__scroll-top{position:absolute;right:16px;bottom:16px;z-index:2}.tn-tree-virtual-scroll-view__scroll-top--fixed{position:fixed}\n"], dependencies: [{ kind: "ngmodule", type: CdkTreeModule }, { kind: "directive", type: i2.CdkTreeNodeOutlet, selector: "[cdkTreeNodeOutlet]" }, { kind: "component", type: CdkVirtualScrollViewport, selector: "cdk-virtual-scroll-viewport", inputs: ["orientation", "appendOnly"], outputs: ["scrolledIndexChange"] }, { kind: "directive", type: CdkVirtualScrollableWindow, selector: "cdk-virtual-scroll-viewport[scrollWindow]" }, { kind: "directive", type: CdkFixedSizeVirtualScroll, selector: "cdk-virtual-scroll-viewport[itemSize]", inputs: ["itemSize", "minBufferPx", "maxBufferPx"] }, { kind: "directive", type: CdkVirtualForOf, selector: "[cdkVirtualFor][cdkVirtualForOf]", inputs: ["cdkVirtualForOf", "cdkVirtualForTrackBy", "cdkVirtualForTemplate", "cdkVirtualForTemplateCacheSize"] }, { kind: "directive", type: TnTreeVirtualScrollNodeOutletDirective, selector: "[tnTreeVirtualScrollNodeOutlet]", inputs: ["data"] }, { kind: "component", type: TnIconButtonComponent, selector: "tn-icon-button", inputs: ["disabled", "dense", "ariaLabel", "ariaExpanded", "testId", "name", "size", "color", "tooltip", "tooltipPosition", "library", "iconClass"], outputs: ["onClick"] }, { kind: "pipe", type: AsyncPipe, name: "async" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
12148
+ }
12149
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeVirtualScrollViewComponent, decorators: [{
12150
+ type: Component,
12151
+ args: [{ selector: 'tn-tree-virtual-scroll-view', standalone: true, exportAs: 'tnTreeVirtualScrollView', providers: [
12152
+ { provide: CdkTree, useExisting: TnTreeVirtualScrollViewComponent },
12153
+ ], imports: [
12154
+ CdkTreeModule,
12155
+ CdkVirtualScrollViewport,
12156
+ CdkVirtualScrollableWindow,
12157
+ CdkFixedSizeVirtualScroll,
12158
+ CdkVirtualForOf,
12159
+ TnTreeVirtualScrollNodeOutletDirective,
12160
+ TnIconButtonComponent,
12161
+ AsyncPipe,
12162
+ ], hostDirectives: [{ directive: TnTestIdDirective, inputs: ['tnTestId: testId'] }], host: {
12163
+ 'class': 'tn-tree tn-tree-virtual-scroll-view',
12164
+ 'role': 'tree',
12165
+ // Expose itemSize to CSS so each row can be pinned to exactly this height
12166
+ // (see the .scss) — virtual scrolling needs the painted row height to match
12167
+ // the height the viewport assumes per item, otherwise hover/selection
12168
+ // backgrounds bleed across neighbouring rows.
12169
+ '[style.--tn-tree-item-size.px]': 'itemSize()',
12170
+ '(keydown)': 'onTreeKeydown($event)',
12171
+ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- The two branches are intentionally near-identical: `scrollWindow` is a structural\n directive (CdkVirtualScrollableWindow) selected by the bare attribute's presence, so\n it can't be toggled via a property binding on a single viewport. Don't DRY this into\n one element \u2014 dropping the attribute selection breaks window-scroll mode. -->\n@if (scrollWindow()) {\n <cdk-virtual-scroll-viewport\n scrollWindow\n [itemSize]=\"itemSize()\"\n [minBufferPx]=\"resolvedMinBufferPx()\"\n [maxBufferPx]=\"resolvedMaxBufferPx()\"\n >\n <ng-container *cdkVirtualFor=\"let item of nodes$ | async; trackBy: innerTrackBy()\">\n <ng-template tnTreeVirtualScrollNodeOutlet [data]=\"item\" />\n </ng-container>\n </cdk-virtual-scroll-viewport>\n} @else {\n <cdk-virtual-scroll-viewport\n class=\"tn-tree-virtual-scroll-view__viewport\"\n [itemSize]=\"itemSize()\"\n [minBufferPx]=\"resolvedMinBufferPx()\"\n [maxBufferPx]=\"resolvedMaxBufferPx()\"\n >\n <ng-container *cdkVirtualFor=\"let item of nodes$ | async; trackBy: innerTrackBy()\">\n <ng-template tnTreeVirtualScrollNodeOutlet [data]=\"item\" />\n </ng-container>\n </cdk-virtual-scroll-viewport>\n}\n\n<!-- Required so CdkTree finds a node outlet during init, but intentionally left\n empty: rows are rendered through the virtual scroll viewport above, not here. -->\n<ng-container cdkTreeNodeOutlet />\n\n@if (isScrollTopButtonVisible) {\n <tn-icon-button\n name=\"arrow-up\"\n library=\"mdi\"\n class=\"tn-tree-virtual-scroll-view__scroll-top\"\n testId=\"scroll-to-top\"\n [class.tn-tree-virtual-scroll-view__scroll-top--fixed]=\"scrollWindow()\"\n [ariaLabel]=\"scrollToTopLabel()\"\n [tooltip]=\"scrollToTopLabel()\"\n (onClick)=\"scrollToTop()\"\n />\n}\n", styles: [".tn-tree-virtual-scroll-view__viewport{height:100%;width:100%}.tn-tree-virtual-scroll-view{display:block;position:relative;width:100%;height:100%;--tn-tree-item-size: 48px}.tn-tree-virtual-scroll-view .tn-tree-node__content{height:var(--tn-tree-item-size);min-height:var(--tn-tree-item-size);padding-top:0;padding-bottom:0;box-sizing:border-box}.tn-tree-virtual-scroll-view__scroll-top{position:absolute;right:16px;bottom:16px;z-index:2}.tn-tree-virtual-scroll-view__scroll-top--fixed{position:fixed}\n"] }]
12172
+ }], ctorParameters: () => [], propDecorators: { virtualScrollViewport: [{ type: i0.ViewChild, args: [i0.forwardRef(() => CdkVirtualScrollViewport), { isSignal: true }] }], itemSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemSize", required: false }] }], minBufferPx: [{ type: i0.Input, args: [{ isSignal: true, alias: "minBufferPx", required: false }] }], maxBufferPx: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxBufferPx", required: false }] }], scrollWindow: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollWindow", required: false }] }], showScrollToTop: [{ type: i0.Input, args: [{ isSignal: true, alias: "showScrollToTop", required: false }] }], scrollToTopLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollToTopLabel", required: false }] }], nodeTrackBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodeTrackBy", required: false }] }], viewportScrolled: [{ type: i0.Output, args: ["viewportScrolled"] }], viewportResized: [{ type: i0.Output, args: ["viewportResized"] }] } });
12173
+
12174
+ /**
12175
+ * Harness for interacting with `tn-tree-virtual-scroll-view` in tests.
12176
+ *
12177
+ * Because the component virtualises its rows, every query below operates over the
12178
+ * currently-MATERIALISED (visible + buffered) rows only — nodes scrolled far out of view
12179
+ * are not in the DOM. Indexes are positions within that visible set, not the full data.
12180
+ *
12181
+ * @example
12182
+ * ```typescript
12183
+ * const tree = await loader.getHarness(TnTreeVirtualScrollViewHarness);
12184
+ * expect(await tree.getRowTexts()).toEqual(['pool', 'other']);
12185
+ *
12186
+ * await tree.expand(0);
12187
+ * expect(await tree.isExpanded(0)).toBe(true);
12188
+ * expect(await tree.getAriaLevel(1)).toBe(2);
12189
+ * ```
12190
+ */
12191
+ class TnTreeVirtualScrollViewHarness extends ComponentHarness {
12192
+ static hostSelector = 'tn-tree-virtual-scroll-view';
12193
+ /**
12194
+ * Gets a `HarnessPredicate` that can be used to search for a virtual-scroll tree
12195
+ * with specific attributes.
12196
+ *
12197
+ * @param options Options for filtering which instances are considered a match.
12198
+ * @returns A `HarnessPredicate` configured with the given options.
12199
+ */
12200
+ static with(options = {}) {
12201
+ return new HarnessPredicate(TnTreeVirtualScrollViewHarness, options);
12202
+ }
12203
+ rows = this.locatorForAll('.tn-tree-node-wrapper[role="treeitem"]');
12204
+ scrollTopButton = this.locatorForOptional('.tn-tree-virtual-scroll-view__scroll-top');
12205
+ // --- Row queries ---
12206
+ /**
12207
+ * Gets the number of currently-materialised tree rows.
12208
+ *
12209
+ * @returns Promise resolving to the visible row count.
12210
+ */
12211
+ async getRowCount() {
12212
+ return (await this.rows()).length;
12213
+ }
12214
+ /**
12215
+ * Gets the text label of every materialised row (the node's text cell only, excluding
12216
+ * the expand chevron glyph).
12217
+ *
12218
+ * @returns Promise resolving to an array of row label strings.
12219
+ */
12220
+ async getRowTexts() {
12221
+ const cells = await this.locatorForAll('.tn-tree-node-wrapper[role="treeitem"] .tn-tree-node__text')();
12222
+ const texts = [];
12223
+ for (const cell of cells) {
12224
+ texts.push((await cell.text()).trim());
12225
+ }
12226
+ return texts;
12227
+ }
12228
+ /**
12229
+ * Gets the text label of a single materialised row.
12230
+ *
12231
+ * @param index Zero-based index within the visible rows.
12232
+ * @returns Promise resolving to the row's label.
12233
+ */
12234
+ async getRowText(index) {
12235
+ const cell = await this.locatorFor(`.tn-tree-node-wrapper[role="treeitem"]:nth-of-type(${index + 1}) .tn-tree-node__text`)();
12236
+ return (await cell.text()).trim();
12237
+ }
12238
+ /**
12239
+ * Gets the 1-based `aria-level` of a row (root nodes are 1).
12240
+ *
12241
+ * @param index Zero-based index within the visible rows.
12242
+ * @returns Promise resolving to the aria-level, or null if unset.
12243
+ */
12244
+ async getAriaLevel(index) {
12245
+ return this.getRowNumberAttribute(index, 'aria-level');
12246
+ }
12247
+ /**
12248
+ * Gets a row's `aria-posinset` — its 1-based position within its SIBLING set.
12249
+ *
12250
+ * @param index Zero-based index within the visible rows.
12251
+ * @returns Promise resolving to the aria-posinset, or null if unset.
12252
+ */
12253
+ async getAriaPosInSet(index) {
12254
+ return this.getRowNumberAttribute(index, 'aria-posinset');
12255
+ }
12256
+ /**
12257
+ * Gets a row's `aria-setsize` — the size of its SIBLING set.
12258
+ *
12259
+ * @param index Zero-based index within the visible rows.
12260
+ * @returns Promise resolving to the aria-setsize, or null if unset.
12261
+ */
12262
+ async getAriaSetSize(index) {
12263
+ return this.getRowNumberAttribute(index, 'aria-setsize');
12264
+ }
12265
+ /**
12266
+ * Whether a row is expandable (advertises `aria-expanded`).
12267
+ *
12268
+ * @param index Zero-based index within the visible rows.
12269
+ * @returns Promise resolving to true if the row is expandable.
12270
+ */
12271
+ async isExpandable(index) {
12272
+ const row = await this.getRow(index);
12273
+ return (await row.getAttribute('aria-expanded')) !== null;
12274
+ }
12275
+ /**
12276
+ * Whether an expandable row is currently expanded.
12277
+ *
12278
+ * @param index Zero-based index within the visible rows.
12279
+ * @returns Promise resolving to true if `aria-expanded="true"`.
12280
+ */
12281
+ async isExpanded(index) {
12282
+ const row = await this.getRow(index);
12283
+ return (await row.getAttribute('aria-expanded')) === 'true';
12284
+ }
12285
+ /**
12286
+ * Whether a row is keyboard tab-focusable (`tabindex="0"`). Only expandable rows are.
12287
+ *
12288
+ * @param index Zero-based index within the visible rows.
12289
+ * @returns Promise resolving to true if the row is tab-focusable.
12290
+ */
12291
+ async isFocusable(index) {
12292
+ const row = await this.getRow(index);
12293
+ return (await row.getAttribute('tabindex')) === '0';
12294
+ }
12295
+ // --- Expansion ---
12296
+ /**
12297
+ * Toggles a row's expansion by clicking its toggle. No-op semantics are the tree's
12298
+ * (clicking a leaf does nothing).
12299
+ *
12300
+ * @param index Zero-based index within the visible rows.
12301
+ */
12302
+ async toggle(index) {
12303
+ const toggle = await this.locatorFor(`.tn-tree-node-wrapper[role="treeitem"]:nth-of-type(${index + 1}) .tn-tree-node`)();
12304
+ await toggle.click();
12305
+ }
12306
+ /**
12307
+ * Expands a row if it is expandable and currently collapsed.
12308
+ *
12309
+ * @param index Zero-based index within the visible rows.
12310
+ */
12311
+ async expand(index) {
12312
+ if ((await this.isExpandable(index)) && !(await this.isExpanded(index))) {
12313
+ await this.toggle(index);
12314
+ }
12315
+ }
12316
+ /**
12317
+ * Collapses a row if it is currently expanded.
12318
+ *
12319
+ * @param index Zero-based index within the visible rows.
12320
+ */
12321
+ async collapse(index) {
12322
+ if (await this.isExpanded(index)) {
12323
+ await this.toggle(index);
12324
+ }
12325
+ }
12326
+ /**
12327
+ * Focuses a row and presses a key on it (Enter/Space toggle an expandable row).
12328
+ *
12329
+ * @param index Zero-based index within the visible rows.
12330
+ * @param key Which key to press — Enter or Space.
12331
+ */
12332
+ async pressKeyOnRow(index, key) {
12333
+ const row = await this.getRow(index);
12334
+ await row.focus();
12335
+ await row.sendKeys(key === 'enter' ? TestKey.ENTER : ' ');
12336
+ }
12337
+ // --- Scroll-to-top button ---
12338
+ /**
12339
+ * Whether the floating scroll-to-top button is currently visible.
12340
+ *
12341
+ * @returns Promise resolving to true if the button is rendered.
12342
+ */
12343
+ async isScrollToTopVisible() {
12344
+ return (await this.scrollTopButton()) !== null;
12345
+ }
12346
+ /**
12347
+ * Clicks the scroll-to-top button. Throws if it is not currently visible.
12348
+ */
12349
+ async clickScrollToTop() {
12350
+ const button = await this.scrollTopButton();
12351
+ if (!button) {
12352
+ throw new Error('Scroll-to-top button is not currently visible');
12353
+ }
12354
+ await button.click();
12355
+ }
12356
+ // --- Internal helpers ---
12357
+ async getRow(index) {
12358
+ const rows = await this.rows();
12359
+ if (index >= rows.length) {
12360
+ throw new Error(`Row index ${index} out of bounds (${rows.length} materialised rows)`);
12361
+ }
12362
+ return rows[index];
12363
+ }
12364
+ async getRowNumberAttribute(index, attr) {
12365
+ const row = await this.getRow(index);
12366
+ const value = await row.getAttribute(attr);
12367
+ return value === null ? null : Number(value);
12368
+ }
12369
+ }
12370
+
11545
12371
  var ModifierKeys;
11546
12372
  (function (ModifierKeys) {
11547
12373
  ModifierKeys["COMMAND"] = "\u2318";
@@ -14028,7 +14854,7 @@ class TnTimeInputComponent {
14028
14854
  useExisting: forwardRef(() => TnTimeInputComponent),
14029
14855
  multi: true
14030
14856
  }
14031
- ], ngImport: i0, template: "<tn-select\n [options]=\"timeSelectOptions()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"testId()\"\n [ngModel]=\"_value\"\n (selectionChange)=\"onSelectionChange($event)\" />\n", styles: [":host{display:block;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: TnSelectComponent, selector: "tn-select", inputs: ["options", "optionGroups", "placeholder", "ariaLabel", "noOptionsLabel", "allowEmpty", "emptyLabel", "disabled", "testId", "multiple", "optionTestIdKey", "compareWith"], outputs: ["selectionChange", "multiSelectionChange"] }] });
14857
+ ], ngImport: i0, template: "<tn-select\n [options]=\"timeSelectOptions()\"\n [placeholder]=\"placeholder()\"\n [disabled]=\"isDisabled()\"\n [testId]=\"testId()\"\n [ngModel]=\"_value\"\n (selectionChange)=\"onSelectionChange($event)\" />\n", styles: [":host{display:block;width:100%}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: TnSelectComponent, selector: "tn-select", inputs: ["options", "optionGroups", "placeholder", "ariaLabel", "noOptionsLabel", "allowEmpty", "emptyLabel", "disabled", "showSelectAll", "selectAllLabel", "testId", "multiple", "optionTestIdKey", "compareWith"], outputs: ["selectionChange", "multiSelectionChange"] }] });
14032
14858
  }
14033
14859
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTimeInputComponent, decorators: [{
14034
14860
  type: Component,
@@ -17872,5 +18698,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
17872
18698
  * Generated bundle index. Do not edit.
17873
18699
  */
17874
18700
 
17875
- export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, StripMntPrefixPipe, THEME_MAP, THEME_STORAGE_KEY, TN_FORM_FIELD_ERRORS, 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, TnCalendarComponent, 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, 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, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, 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, TnTreeNodeComponent, TnTreeNodeOutletDirective, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, composeTestId, controlTestId, createLucideLibrary, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, formatSize, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
18701
+ export { CommonShortcuts, DEFAULT_THEME, DiskIconComponent, DiskType, FileSizePipe, InputType, LIGHT_THEME, LabelMarkupPipe, LabelTextPipe, LinuxModifierKeys, LinuxShortcuts, ModifierKeys, QuickShortcuts, ShortcutBuilder, StripMntPrefixPipe, THEME_MAP, THEME_STORAGE_KEY, TN_FORM_FIELD_ERRORS, 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, TnCalendarComponent, 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, 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, TnNestedTreeNodeComponent, TnParticleProgressBarComponent, TnProgressBarComponent, TnRadioComponent, 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, TnTreeNodeComponent, TnTreeNodeOutletDirective, TnTreeVirtualScrollNodeOutletDirective, TnTreeVirtualScrollViewComponent, TnTreeVirtualScrollViewHarness, TruncatePathPipe, WindowsModifierKeys, WindowsShortcuts, composeTestId, controlTestId, createLucideLibrary, createShortcut, defaultSpriteBasePath, defaultSpriteConfigPath, defaultTreeItemSize, formatSize, kebabTestSegment, labelMarkupToHtml, labelMarkupToText, libIconMarker, parseLabelMarkup, parseSize, registerLucideIcons, scopeTestId, setupLucideIntegration, tnIconMarker, writeTestId };
17876
18702
  //# sourceMappingURL=truenas-ui-components.mjs.map