@truenas/ui-components 0.3.19 → 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" }] : []));
@@ -2199,7 +2199,11 @@ class TnBannerHarness extends ComponentHarness {
2199
2199
  */
2200
2200
  static with(options = {}) {
2201
2201
  return new HarnessPredicate(TnBannerHarness, options)
2202
- .addOption('textContains', options.textContains, (harness, text) => HarnessPredicate.stringMatches(harness.getText(), text));
2202
+ .addOption('textContains', options.textContains, (harness, text) => HarnessPredicate.stringMatches(harness.getText(),
2203
+ // strings trigger exact matching in `stringMatches`, but since we call the option
2204
+ // `textContains`, this would be misleading. here, we convert strings to a Regex
2205
+ // to trigger partial matching behavior on `stringMatches`.
2206
+ typeof text === 'string' ? new RegExp(helperEscapeRegex(text)) : text));
2203
2207
  }
2204
2208
  /**
2205
2209
  * Gets all text content from the banner (heading + message combined).
@@ -2218,6 +2222,15 @@ class TnBannerHarness extends ComponentHarness {
2218
2222
  return (await host.text()).trim();
2219
2223
  }
2220
2224
  }
2225
+ /**
2226
+ * helper function to stand-in for `RegExp.escape`, since that doesn't
2227
+ * exist in our deployment target of ES2023.
2228
+ * @param text a string to escape.
2229
+ * @returns an escaped string, safe for using in the `RegExp` constructor.
2230
+ */
2231
+ function helperEscapeRegex(text) {
2232
+ return text.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
2233
+ }
2221
2234
 
2222
2235
  const escapableChars = new Set(['*', '`', '\\']);
2223
2236
  function isWhitespace(char) {
@@ -8716,6 +8729,16 @@ class TnSelectComponent {
8716
8729
  /** Label of the empty option rendered when `allowEmpty` is set. */
8717
8730
  emptyLabel = input('--', ...(ngDevMode ? [{ debugName: "emptyLabel" }] : []));
8718
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" }] : []));
8719
8742
  testId = input(undefined, ...(ngDevMode ? [{ debugName: "testId" }] : []));
8720
8743
  /** Test-id base, falling back to the bound control name when `testId` is unset. */
8721
8744
  resolvedTestId = controlTestId(this.testId);
@@ -8803,18 +8826,23 @@ class TnSelectComponent {
8803
8826
  */
8804
8827
  navigableOptions = computed(() => {
8805
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
+ }
8806
8834
  const baseId = `tn-select-opt-${this.idNamespace()}`;
8807
8835
  let i = 0;
8808
8836
  for (const opt of this.displayOptions()) {
8809
8837
  if (!opt.disabled) {
8810
- result.push({ option: opt, id: `${baseId}-${i}` });
8838
+ result.push({ kind: 'option', option: opt, id: `${baseId}-${i}` });
8811
8839
  }
8812
8840
  i++;
8813
8841
  }
8814
8842
  for (const group of this.optionGroups()) {
8815
8843
  for (const opt of group.options) {
8816
8844
  if (!opt.disabled && !group.disabled) {
8817
- result.push({ option: opt, id: `${baseId}-${i}` });
8845
+ result.push({ kind: 'option', option: opt, id: `${baseId}-${i}` });
8818
8846
  }
8819
8847
  i++;
8820
8848
  }
@@ -8829,14 +8857,15 @@ class TnSelectComponent {
8829
8857
  }, ...(ngDevMode ? [{ debugName: "focusedOptionId" }] : []));
8830
8858
  /** Stable DOM id for an option; matches what navigableOptions() assigns. */
8831
8859
  optionId(option) {
8832
- const entry = this.navigableOptions().find((x) => x.option === option);
8860
+ const entry = this.navigableOptions().find((x) => x.kind === 'option' && x.option === option);
8833
8861
  return entry?.id ?? null;
8834
8862
  }
8835
8863
  /** Whether `option` is the keyboard-highlighted item. */
8836
8864
  isOptionFocused(option) {
8837
8865
  const idx = this.focusedIndex();
8838
8866
  const nav = this.navigableOptions();
8839
- 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;
8840
8869
  }
8841
8870
  /**
8842
8871
  * Test-id segments for an option row, consumed by `[tnTestId]` with
@@ -8921,7 +8950,7 @@ class TnSelectComponent {
8921
8950
  // otherwise leave it unset so the next ArrowDown lands on the first item.
8922
8951
  const selected = this.selectedValue();
8923
8952
  if (selected !== null && selected !== undefined) {
8924
- 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));
8925
8954
  this.focusedIndex.set(idx);
8926
8955
  }
8927
8956
  else if (this.emptyOption()) {
@@ -9118,6 +9147,92 @@ class TnSelectComponent {
9118
9147
  hasAnyOptions = computed(() => {
9119
9148
  return this.options().length > 0 || this.optionGroups().length > 0;
9120
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
+ }
9121
9236
  /** One-shot guard so the object-compare warning fires at most once per instance. */
9122
9237
  warnedAboutObjectCompare = false;
9123
9238
  /**
@@ -9261,7 +9376,13 @@ class TnSelectComponent {
9261
9376
  if (idx < 0 || idx >= nav.length) {
9262
9377
  return;
9263
9378
  }
9264
- 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
+ }
9265
9386
  }
9266
9387
  scrollFocusedIntoView() {
9267
9388
  const id = this.focusedOptionId();
@@ -9284,13 +9405,13 @@ class TnSelectComponent {
9284
9405
  });
9285
9406
  }
9286
9407
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9287
- 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: [
9288
9409
  {
9289
9410
  provide: NG_VALUE_ACCESSOR,
9290
9411
  useExisting: forwardRef(() => TnSelectComponent),
9291
9412
  multi: true
9292
9413
  }
9293
- ], 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 });
9294
9415
  }
9295
9416
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnSelectComponent, decorators: [{
9296
9417
  type: Component,
@@ -9300,8 +9421,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
9300
9421
  useExisting: forwardRef(() => TnSelectComponent),
9301
9422
  multi: true
9302
9423
  }
9303
- ], 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"] }]
9304
- }], 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 }] }] } });
9305
9426
 
9306
9427
  /**
9307
9428
  * Harness for interacting with tn-select in tests.
@@ -9459,7 +9580,10 @@ class TnSelectHarness extends ComponentHarness {
9459
9580
  await this.open();
9460
9581
  // Dropdown panel is rendered in a CDK overlay (outside the host element),
9461
9582
  // so we search the document root rather than the harness-local subtree.
9462
- 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)')();
9463
9587
  for (const option of options) {
9464
9588
  const text = (await option.text()).trim();
9465
9589
  const matches = filter instanceof RegExp
@@ -9519,13 +9643,55 @@ class TnSelectHarness extends ComponentHarness {
9519
9643
  await this.open();
9520
9644
  // Dropdown panel is rendered in a CDK overlay (outside the host element),
9521
9645
  // so we search the document root rather than the harness-local subtree.
9522
- 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)')();
9523
9649
  const labels = [];
9524
9650
  for (const option of options) {
9525
9651
  labels.push((await option.text()).trim());
9526
9652
  }
9527
9653
  return labels;
9528
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
+ }
9529
9695
  }
9530
9696
 
9531
9697
  /// <reference types="jest" />
@@ -11238,7 +11404,7 @@ class TnTablePagerComponent {
11238
11404
  provider.setPagination(pagination);
11239
11405
  }
11240
11406
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11241
- 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 });
11242
11408
  }
11243
11409
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTablePagerComponent, decorators: [{
11244
11410
  type: Component,
@@ -11433,7 +11599,7 @@ class TnTreeNodeComponent extends CdkTreeNode {
11433
11599
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11434
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: [
11435
11601
  { provide: CdkTreeNode, useExisting: TnTreeNodeComponent }
11436
- ], 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 });
11437
11603
  }
11438
11604
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTreeNodeComponent, decorators: [{
11439
11605
  type: Component,
@@ -11444,7 +11610,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11444
11610
  '[attr.aria-level]': 'level + 1',
11445
11611
  '[attr.aria-expanded]': 'isExpandable ? isExpanded : null',
11446
11612
  'role': 'treeitem'
11447
- }, 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"] }]
11448
11614
  }], ctorParameters: () => [] });
11449
11615
 
11450
11616
  class TnTreeNodeOutletDirective {
@@ -11529,6 +11695,679 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
11529
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"] }]
11530
11696
  }], ctorParameters: () => [] });
11531
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
+
11532
12371
  var ModifierKeys;
11533
12372
  (function (ModifierKeys) {
11534
12373
  ModifierKeys["COMMAND"] = "\u2318";
@@ -14015,7 +14854,7 @@ class TnTimeInputComponent {
14015
14854
  useExisting: forwardRef(() => TnTimeInputComponent),
14016
14855
  multi: true
14017
14856
  }
14018
- ], 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"] }] });
14019
14858
  }
14020
14859
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: TnTimeInputComponent, decorators: [{
14021
14860
  type: Component,
@@ -17859,5 +18698,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImpor
17859
18698
  * Generated bundle index. Do not edit.
17860
18699
  */
17861
18700
 
17862
- 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 };
17863
18702
  //# sourceMappingURL=truenas-ui-components.mjs.map