@neuravision/ng-construct 0.5.0 → 0.7.0
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,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, inject, input, output, signal, computed, ChangeDetectionStrategy, Component, Injectable, forwardRef, model, booleanAttribute, viewChild, contentChildren, effect, DOCUMENT as DOCUMENT$1, isDevMode, contentChild, ElementRef, TemplateRef, Directive, viewChildren, Renderer2, numberAttribute, Pipe } from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, input, output, signal, computed, ChangeDetectionStrategy, Component, Injectable, forwardRef, model, booleanAttribute, viewChild, contentChildren, effect, DOCUMENT as DOCUMENT$1, isDevMode, contentChild, ElementRef, TemplateRef, Directive, viewChildren, Renderer2, numberAttribute, Injector, Pipe } from '@angular/core';
|
|
3
3
|
import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
|
|
4
4
|
import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
|
|
5
5
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
|
@@ -1098,6 +1098,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
1098
1098
|
`, styles: [":host{display:contents}\n"] }]
|
|
1099
1099
|
}], propDecorators: { sidebarState: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarState", required: false }] }, { type: i0.Output, args: ["sidebarStateChange"] }], panelState: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelState", required: false }] }, { type: i0.Output, args: ["panelStateChange"] }], noSidebar: [{ type: i0.Input, args: [{ isSignal: true, alias: "noSidebar", required: false }] }], sidebarRight: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarRight", required: false }] }], sidebarFullHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarFullHeight", required: false }] }], withHeader: [{ type: i0.Input, args: [{ isSignal: true, alias: "withHeader", required: false }] }], sidebarBranded: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarBranded", required: false }] }], glass: [{ type: i0.Input, args: [{ isSignal: true, alias: "glass", required: false }] }], sidebarLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "sidebarLabel", required: false }] }], panelLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelLabel", required: false }] }], mainId: [{ type: i0.Input, args: [{ isSignal: true, alias: "mainId", required: false }] }], skipLinkLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "skipLinkLabel", required: false }] }] } });
|
|
1100
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* Number of distinct colors in the seeded avatar palette. Must match the
|
|
1103
|
+
* `[data-seed-color="N"]` selectors shipped by `@neuravision/construct`
|
|
1104
|
+
* (see `components/avatar.css`). Bump together when Construct adds slots.
|
|
1105
|
+
*/
|
|
1106
|
+
const AVATAR_SEED_PALETTE_SIZE = 8;
|
|
1101
1107
|
/**
|
|
1102
1108
|
* Avatar component displaying a user image with fallback to initials.
|
|
1103
1109
|
*
|
|
@@ -1105,9 +1111,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
1105
1111
|
* fails to load or no `src` is given, initials derived from `name` are
|
|
1106
1112
|
* shown instead.
|
|
1107
1113
|
*
|
|
1114
|
+
* Set `colorSeed` to give each user a stable, deterministic background
|
|
1115
|
+
* color picked from the Construct DS palette — useful in lists where the
|
|
1116
|
+
* eye should recognize repeat individuals at a glance. The seed is hashed
|
|
1117
|
+
* locally and bound to `data-seed-color`; an empty seed leaves the
|
|
1118
|
+
* attribute off and the avatar keeps the default background.
|
|
1119
|
+
*
|
|
1108
1120
|
* @example
|
|
1109
1121
|
* <af-avatar src="/photo.jpg" name="Jane Doe" alt="Jane Doe" size="lg" />
|
|
1110
1122
|
* <af-avatar name="John Smith" status="online" />
|
|
1123
|
+
* <af-avatar name="Jane Doe" colorSeed="user-uuid-7b3e2a4d" />
|
|
1111
1124
|
*/
|
|
1112
1125
|
class AfAvatarComponent {
|
|
1113
1126
|
/** Image URL. Falls back to initials when missing or on load error. */
|
|
@@ -1120,6 +1133,12 @@ class AfAvatarComponent {
|
|
|
1120
1133
|
alt = input('', ...(ngDevMode ? [{ debugName: "alt" }] : []));
|
|
1121
1134
|
/** Online status indicator. */
|
|
1122
1135
|
status = input(undefined, ...(ngDevMode ? [{ debugName: "status" }] : []));
|
|
1136
|
+
/**
|
|
1137
|
+
* Stable identifier (e.g. userUUID, email, username) hashed into a
|
|
1138
|
+
* deterministic palette index. The same seed always produces the same
|
|
1139
|
+
* color. Leave empty to keep the default avatar background.
|
|
1140
|
+
*/
|
|
1141
|
+
colorSeed = input('', ...(ngDevMode ? [{ debugName: "colorSeed" }] : []));
|
|
1123
1142
|
/** Tracks whether the image failed to load. */
|
|
1124
1143
|
imageError = signal(false, ...(ngDevMode ? [{ debugName: "imageError" }] : []));
|
|
1125
1144
|
/** Whether to render the `<img>` element. */
|
|
@@ -1135,6 +1154,18 @@ class AfAvatarComponent {
|
|
|
1135
1154
|
}, ...(ngDevMode ? [{ debugName: "initials" }] : []));
|
|
1136
1155
|
/** Accessible label for the avatar. */
|
|
1137
1156
|
ariaLabel = computed(() => this.alt() || this.name() || 'Avatar', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
|
|
1157
|
+
/**
|
|
1158
|
+
* Palette index in `[1, AVATAR_SEED_PALETTE_SIZE]` derived from `colorSeed`,
|
|
1159
|
+
* or `null` when no seed is set. Returning `null` causes Angular to omit
|
|
1160
|
+
* the `data-seed-color` attribute, preserving the unseeded default.
|
|
1161
|
+
*/
|
|
1162
|
+
seedColorIndex = computed(() => {
|
|
1163
|
+
const seed = this.colorSeed();
|
|
1164
|
+
if (!seed)
|
|
1165
|
+
return null;
|
|
1166
|
+
// Construct's selectors are 1-indexed (data-seed-color="1".."8")
|
|
1167
|
+
return (this.hashSeed(seed) % AVATAR_SEED_PALETTE_SIZE) + 1;
|
|
1168
|
+
}, ...(ngDevMode ? [{ debugName: "seedColorIndex" }] : []));
|
|
1138
1169
|
avatarClasses = computed(() => {
|
|
1139
1170
|
const classes = ['ct-avatar'];
|
|
1140
1171
|
if (this.size() !== 'md') {
|
|
@@ -1146,9 +1177,26 @@ class AfAvatarComponent {
|
|
|
1146
1177
|
onImageError() {
|
|
1147
1178
|
this.imageError.set(true);
|
|
1148
1179
|
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Dependency-free 32-bit string hash (djb2-style). Pure and stable across
|
|
1182
|
+
* runs and environments — same input always yields the same non-negative
|
|
1183
|
+
* integer.
|
|
1184
|
+
*/
|
|
1185
|
+
hashSeed(seed) {
|
|
1186
|
+
let hash = 0;
|
|
1187
|
+
for (let i = 0; i < seed.length; i++) {
|
|
1188
|
+
hash = (hash << 5) - hash + seed.charCodeAt(i);
|
|
1189
|
+
hash |= 0; // force 32-bit int
|
|
1190
|
+
}
|
|
1191
|
+
return Math.abs(hash);
|
|
1192
|
+
}
|
|
1149
1193
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAvatarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1150
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfAvatarComponent, isStandalone: true, selector: "af-avatar", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
1151
|
-
<span
|
|
1194
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfAvatarComponent, isStandalone: true, selector: "af-avatar", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: false, transformFunction: null }, colorSeed: { classPropertyName: "colorSeed", publicName: "colorSeed", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
1195
|
+
<span
|
|
1196
|
+
[class]="avatarClasses()"
|
|
1197
|
+
role="img"
|
|
1198
|
+
[attr.aria-label]="ariaLabel()"
|
|
1199
|
+
[attr.data-seed-color]="seedColorIndex()">
|
|
1152
1200
|
@if (showImage()) {
|
|
1153
1201
|
<img
|
|
1154
1202
|
class="ct-avatar__image"
|
|
@@ -1172,7 +1220,11 @@ class AfAvatarComponent {
|
|
|
1172
1220
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAvatarComponent, decorators: [{
|
|
1173
1221
|
type: Component,
|
|
1174
1222
|
args: [{ selector: 'af-avatar', changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
1175
|
-
<span
|
|
1223
|
+
<span
|
|
1224
|
+
[class]="avatarClasses()"
|
|
1225
|
+
role="img"
|
|
1226
|
+
[attr.aria-label]="ariaLabel()"
|
|
1227
|
+
[attr.data-seed-color]="seedColorIndex()">
|
|
1176
1228
|
@if (showImage()) {
|
|
1177
1229
|
<img
|
|
1178
1230
|
class="ct-avatar__image"
|
|
@@ -1192,7 +1244,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
1192
1244
|
}
|
|
1193
1245
|
</span>
|
|
1194
1246
|
`, styles: [":host{display:inline-block}\n"] }]
|
|
1195
|
-
}], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }] } });
|
|
1247
|
+
}], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }], colorSeed: [{ type: i0.Input, args: [{ isSignal: true, alias: "colorSeed", required: false }] }] } });
|
|
1196
1248
|
|
|
1197
1249
|
/**
|
|
1198
1250
|
* Button component from the Construct Design System.
|
|
@@ -1728,132 +1780,203 @@ class AfInputHarness {
|
|
|
1728
1780
|
}
|
|
1729
1781
|
|
|
1730
1782
|
/**
|
|
1731
|
-
*
|
|
1783
|
+
* Injection token to override select screen-reader announcements
|
|
1784
|
+
* and the fallback `aria-label`.
|
|
1732
1785
|
*
|
|
1733
1786
|
* @example
|
|
1787
|
+
* providers: [{
|
|
1788
|
+
* provide: AF_SELECT_I18N,
|
|
1789
|
+
* useValue: {
|
|
1790
|
+
* required: 'Pflichtfeld',
|
|
1791
|
+
* selectOption: 'Option auswählen',
|
|
1792
|
+
* selected: '{label} ausgewählt',
|
|
1793
|
+
* },
|
|
1794
|
+
* }]
|
|
1795
|
+
*/
|
|
1796
|
+
const AF_SELECT_I18N = new InjectionToken('AfSelectI18n', {
|
|
1797
|
+
factory: () => ({
|
|
1798
|
+
required: 'required',
|
|
1799
|
+
selectOption: 'Select option',
|
|
1800
|
+
selected: '{label} selected',
|
|
1801
|
+
}),
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Native select dropdown component with form control support.
|
|
1806
|
+
* Wraps a native `<select>` element with design system styling,
|
|
1807
|
+
* accessible labelling, and Angular forms integration.
|
|
1808
|
+
*
|
|
1809
|
+
* For a custom dropdown with keyboard-navigated listbox, see `af-select-menu`.
|
|
1810
|
+
*
|
|
1811
|
+
* @example Basic usage with ngModel
|
|
1734
1812
|
* <af-select
|
|
1735
1813
|
* label="Role"
|
|
1736
1814
|
* [options]="roleOptions"
|
|
1737
1815
|
* [(ngModel)]="selectedRole"
|
|
1738
1816
|
* hint="Choose your primary role"
|
|
1739
|
-
*
|
|
1817
|
+
* />
|
|
1818
|
+
*
|
|
1819
|
+
* @example Reactive forms with error state
|
|
1820
|
+
* <af-select
|
|
1821
|
+
* label="Country"
|
|
1822
|
+
* [options]="countries"
|
|
1823
|
+
* [formControl]="countryControl"
|
|
1824
|
+
* [error]="countryControl.hasError('required') ? 'Required field' : ''"
|
|
1825
|
+
* />
|
|
1826
|
+
*
|
|
1827
|
+
* @accessibility
|
|
1828
|
+
* - Uses a native `<select>` element for built-in browser accessibility.
|
|
1829
|
+
* - `aria-invalid` is set when an error message is provided.
|
|
1830
|
+
* - `aria-describedby` links to hint or error text.
|
|
1831
|
+
* - Falls back to `aria-label` via {@link AF_SELECT_I18N} when no `label` input is given.
|
|
1832
|
+
* - Screen-reader announcements via {@link AriaLiveAnnouncer} on selection change.
|
|
1833
|
+
* - All user-facing strings are configurable via {@link AF_SELECT_I18N} for i18n.
|
|
1740
1834
|
*/
|
|
1741
1835
|
class AfSelectComponent {
|
|
1742
1836
|
static nextId = 0;
|
|
1743
|
-
|
|
1837
|
+
i18n = inject(AF_SELECT_I18N);
|
|
1838
|
+
announcer = inject(AriaLiveAnnouncer);
|
|
1839
|
+
/** Label shown above the select. */
|
|
1744
1840
|
label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
|
|
1745
|
-
/** Placeholder option */
|
|
1841
|
+
/** Placeholder option shown when no value is selected. */
|
|
1746
1842
|
placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
|
|
1747
|
-
/**
|
|
1843
|
+
/** Available options. */
|
|
1748
1844
|
options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
|
|
1749
|
-
/** Hint text shown below select */
|
|
1845
|
+
/** Hint text shown below the select. */
|
|
1750
1846
|
hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
|
|
1751
|
-
/** Error message */
|
|
1847
|
+
/** Error message — shows error state when non-empty. */
|
|
1752
1848
|
error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
|
|
1753
|
-
/** Whether
|
|
1849
|
+
/** Whether the field is required. */
|
|
1754
1850
|
required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
|
|
1755
|
-
/** Whether select is disabled */
|
|
1851
|
+
/** Whether the select is disabled. */
|
|
1756
1852
|
disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
|
|
1757
|
-
/**
|
|
1853
|
+
/** Size variant. */
|
|
1854
|
+
size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
|
|
1855
|
+
/** Value comparison function for object values. */
|
|
1758
1856
|
compareWith = input((a, b) => a === b, ...(ngDevMode ? [{ debugName: "compareWith" }] : []));
|
|
1759
|
-
/** Unique select ID */
|
|
1857
|
+
/** Unique select ID. */
|
|
1760
1858
|
selectId = input(`af-select-${AfSelectComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "selectId" }] : []));
|
|
1761
|
-
value
|
|
1762
|
-
|
|
1859
|
+
/** Emits when the user changes the selected value. */
|
|
1860
|
+
valueChange = output();
|
|
1861
|
+
value = signal(null, ...(ngDevMode ? [{ debugName: "value" }] : []));
|
|
1862
|
+
onChange = () => { };
|
|
1763
1863
|
onTouched = () => { };
|
|
1864
|
+
labelId = computed(() => `${this.selectId()}-label`, ...(ngDevMode ? [{ debugName: "labelId" }] : []));
|
|
1764
1865
|
hintId = computed(() => `${this.selectId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
|
|
1765
1866
|
errorId = computed(() => `${this.selectId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
|
|
1766
|
-
|
|
1867
|
+
selectClasses = computed(() => {
|
|
1868
|
+
const classes = ['ct-select'];
|
|
1869
|
+
const s = this.size();
|
|
1870
|
+
if (s === 'sm')
|
|
1871
|
+
classes.push('ct-select--sm');
|
|
1872
|
+
if (s === 'lg')
|
|
1873
|
+
classes.push('ct-select--lg');
|
|
1874
|
+
return classes.join(' ');
|
|
1875
|
+
}, ...(ngDevMode ? [{ debugName: "selectClasses" }] : []));
|
|
1876
|
+
ariaDescribedBy = computed(() => {
|
|
1767
1877
|
if (this.error())
|
|
1768
1878
|
return this.errorId();
|
|
1769
1879
|
if (this.hint())
|
|
1770
1880
|
return this.hintId();
|
|
1771
1881
|
return null;
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1882
|
+
}, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
|
|
1883
|
+
isPlaceholderSelected = computed(() => {
|
|
1774
1884
|
if (!this.placeholder())
|
|
1775
1885
|
return false;
|
|
1776
|
-
|
|
1886
|
+
const v = this.value();
|
|
1887
|
+
if (v === null || v === undefined || v === '')
|
|
1777
1888
|
return true;
|
|
1778
1889
|
return !this.hasMatchingOption();
|
|
1779
|
-
}
|
|
1780
|
-
hasMatchingOption() {
|
|
1781
|
-
return this.options().some(option => this.compareWith()(option.value, this.value));
|
|
1782
|
-
}
|
|
1890
|
+
}, ...(ngDevMode ? [{ debugName: "isPlaceholderSelected" }] : []));
|
|
1783
1891
|
isOptionSelected(option) {
|
|
1784
|
-
if (this.isPlaceholderSelected)
|
|
1892
|
+
if (this.isPlaceholderSelected())
|
|
1785
1893
|
return false;
|
|
1786
|
-
return this.compareWith()(option.value, this.value);
|
|
1894
|
+
return this.compareWith()(option.value, this.value());
|
|
1787
1895
|
}
|
|
1788
|
-
|
|
1896
|
+
handleChange(event) {
|
|
1789
1897
|
const target = event.target;
|
|
1790
1898
|
const index = target.selectedIndex;
|
|
1791
1899
|
const offset = this.placeholder() ? 1 : 0;
|
|
1792
1900
|
if (this.placeholder() && index === 0) {
|
|
1793
|
-
this.value
|
|
1794
|
-
this.
|
|
1901
|
+
this.value.set(null);
|
|
1902
|
+
this.onChange(null);
|
|
1903
|
+
this.valueChange.emit(null);
|
|
1795
1904
|
return;
|
|
1796
1905
|
}
|
|
1797
1906
|
const option = this.options()[index - offset];
|
|
1798
1907
|
const nextValue = option ? option.value : null;
|
|
1799
|
-
this.value
|
|
1800
|
-
this.
|
|
1908
|
+
this.value.set(nextValue);
|
|
1909
|
+
this.onChange(nextValue);
|
|
1910
|
+
this.valueChange.emit(nextValue);
|
|
1911
|
+
if (option) {
|
|
1912
|
+
this.announcer.announce(this.i18n.selected.replace('{label}', option.label));
|
|
1913
|
+
}
|
|
1801
1914
|
}
|
|
1802
|
-
/**
|
|
1915
|
+
/** @docs-private */
|
|
1803
1916
|
writeValue(value) {
|
|
1804
|
-
this.value
|
|
1917
|
+
this.value.set(value ?? null);
|
|
1805
1918
|
}
|
|
1919
|
+
/** @docs-private */
|
|
1806
1920
|
registerOnChange(fn) {
|
|
1807
|
-
this.
|
|
1921
|
+
this.onChange = fn;
|
|
1808
1922
|
}
|
|
1923
|
+
/** @docs-private */
|
|
1809
1924
|
registerOnTouched(fn) {
|
|
1810
1925
|
this.onTouched = fn;
|
|
1811
1926
|
}
|
|
1927
|
+
/** @docs-private */
|
|
1812
1928
|
setDisabledState(isDisabled) {
|
|
1813
1929
|
this.disabled.set(isDisabled);
|
|
1814
1930
|
}
|
|
1931
|
+
hasMatchingOption() {
|
|
1932
|
+
return this.options().some((option) => this.compareWith()(option.value, this.value()));
|
|
1933
|
+
}
|
|
1815
1934
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1816
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfSelectComponent, isStandalone: true, selector: "af-select", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, selectId: { classPropertyName: "selectId", publicName: "selectId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, providers: [
|
|
1935
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfSelectComponent, isStandalone: true, selector: "af-select", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, error: { classPropertyName: "error", publicName: "error", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, selectId: { classPropertyName: "selectId", publicName: "selectId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", valueChange: "valueChange" }, host: { styleAttribute: "display: block" }, providers: [
|
|
1817
1936
|
{
|
|
1818
1937
|
provide: NG_VALUE_ACCESSOR,
|
|
1819
1938
|
useExisting: forwardRef(() => AfSelectComponent),
|
|
1820
|
-
multi: true
|
|
1821
|
-
}
|
|
1939
|
+
multi: true,
|
|
1940
|
+
},
|
|
1822
1941
|
], ngImport: i0, template: `
|
|
1823
1942
|
<div class="ct-field" [class.ct-field--error]="error()">
|
|
1824
1943
|
@if (label()) {
|
|
1825
|
-
<label class="ct-field__label" [attr.for]="selectId()">
|
|
1944
|
+
<label class="ct-field__label" [id]="labelId()" [attr.for]="selectId()">
|
|
1826
1945
|
{{ label() }}
|
|
1827
1946
|
@if (required()) {
|
|
1828
|
-
<span aria-label="required"> *</span>
|
|
1947
|
+
<span [attr.aria-label]="i18n.required"> *</span>
|
|
1829
1948
|
}
|
|
1830
1949
|
</label>
|
|
1831
1950
|
}
|
|
1832
1951
|
|
|
1833
|
-
<select
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1952
|
+
<div class="ct-select-wrap">
|
|
1953
|
+
<select
|
|
1954
|
+
[id]="selectId()"
|
|
1955
|
+
[class]="selectClasses()"
|
|
1956
|
+
[disabled]="disabled()"
|
|
1957
|
+
[required]="required()"
|
|
1958
|
+
[attr.aria-invalid]="error() ? true : null"
|
|
1959
|
+
[attr.aria-describedby]="ariaDescribedBy()"
|
|
1960
|
+
[attr.aria-label]="label() ? null : i18n.selectOption"
|
|
1961
|
+
[attr.aria-labelledby]="label() ? labelId() : null"
|
|
1962
|
+
(change)="handleChange($event)"
|
|
1963
|
+
(blur)="onTouched()"
|
|
1964
|
+
>
|
|
1965
|
+
@if (placeholder()) {
|
|
1966
|
+
<option value="" [disabled]="true" [selected]="isPlaceholderSelected()">
|
|
1967
|
+
{{ placeholder() }}
|
|
1968
|
+
</option>
|
|
1969
|
+
}
|
|
1970
|
+
@for (option of options(); track option.value) {
|
|
1971
|
+
<option
|
|
1972
|
+
[selected]="isOptionSelected(option)"
|
|
1973
|
+
[disabled]="option.disabled || false"
|
|
1974
|
+
>
|
|
1975
|
+
{{ option.label }}
|
|
1976
|
+
</option>
|
|
1977
|
+
}
|
|
1978
|
+
</select>
|
|
1979
|
+
</div>
|
|
1857
1980
|
|
|
1858
1981
|
@if (hint() && !error()) {
|
|
1859
1982
|
<div class="ct-field__hint" [id]="hintId()">
|
|
@@ -1862,56 +1985,67 @@ class AfSelectComponent {
|
|
|
1862
1985
|
}
|
|
1863
1986
|
|
|
1864
1987
|
@if (error()) {
|
|
1865
|
-
<div class="ct-field__error" [id]="errorId()">
|
|
1988
|
+
<div class="ct-field__error" role="alert" [id]="errorId()">
|
|
1866
1989
|
{{ error() }}
|
|
1867
1990
|
</div>
|
|
1868
1991
|
}
|
|
1869
1992
|
</div>
|
|
1870
|
-
`, isInline: true,
|
|
1993
|
+
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1871
1994
|
}
|
|
1872
1995
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSelectComponent, decorators: [{
|
|
1873
1996
|
type: Component,
|
|
1874
|
-
args: [{
|
|
1997
|
+
args: [{
|
|
1998
|
+
selector: 'af-select',
|
|
1999
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2000
|
+
providers: [
|
|
1875
2001
|
{
|
|
1876
2002
|
provide: NG_VALUE_ACCESSOR,
|
|
1877
2003
|
useExisting: forwardRef(() => AfSelectComponent),
|
|
1878
|
-
multi: true
|
|
1879
|
-
}
|
|
1880
|
-
],
|
|
2004
|
+
multi: true,
|
|
2005
|
+
},
|
|
2006
|
+
],
|
|
2007
|
+
host: {
|
|
2008
|
+
style: 'display: block',
|
|
2009
|
+
},
|
|
2010
|
+
template: `
|
|
1881
2011
|
<div class="ct-field" [class.ct-field--error]="error()">
|
|
1882
2012
|
@if (label()) {
|
|
1883
|
-
<label class="ct-field__label" [attr.for]="selectId()">
|
|
2013
|
+
<label class="ct-field__label" [id]="labelId()" [attr.for]="selectId()">
|
|
1884
2014
|
{{ label() }}
|
|
1885
2015
|
@if (required()) {
|
|
1886
|
-
<span aria-label="required"> *</span>
|
|
2016
|
+
<span [attr.aria-label]="i18n.required"> *</span>
|
|
1887
2017
|
}
|
|
1888
2018
|
</label>
|
|
1889
2019
|
}
|
|
1890
2020
|
|
|
1891
|
-
<select
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
2021
|
+
<div class="ct-select-wrap">
|
|
2022
|
+
<select
|
|
2023
|
+
[id]="selectId()"
|
|
2024
|
+
[class]="selectClasses()"
|
|
2025
|
+
[disabled]="disabled()"
|
|
2026
|
+
[required]="required()"
|
|
2027
|
+
[attr.aria-invalid]="error() ? true : null"
|
|
2028
|
+
[attr.aria-describedby]="ariaDescribedBy()"
|
|
2029
|
+
[attr.aria-label]="label() ? null : i18n.selectOption"
|
|
2030
|
+
[attr.aria-labelledby]="label() ? labelId() : null"
|
|
2031
|
+
(change)="handleChange($event)"
|
|
2032
|
+
(blur)="onTouched()"
|
|
2033
|
+
>
|
|
2034
|
+
@if (placeholder()) {
|
|
2035
|
+
<option value="" [disabled]="true" [selected]="isPlaceholderSelected()">
|
|
2036
|
+
{{ placeholder() }}
|
|
2037
|
+
</option>
|
|
2038
|
+
}
|
|
2039
|
+
@for (option of options(); track option.value) {
|
|
2040
|
+
<option
|
|
2041
|
+
[selected]="isOptionSelected(option)"
|
|
2042
|
+
[disabled]="option.disabled || false"
|
|
2043
|
+
>
|
|
2044
|
+
{{ option.label }}
|
|
2045
|
+
</option>
|
|
2046
|
+
}
|
|
2047
|
+
</select>
|
|
2048
|
+
</div>
|
|
1915
2049
|
|
|
1916
2050
|
@if (hint() && !error()) {
|
|
1917
2051
|
<div class="ct-field__hint" [id]="hintId()">
|
|
@@ -1920,13 +2054,139 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
1920
2054
|
}
|
|
1921
2055
|
|
|
1922
2056
|
@if (error()) {
|
|
1923
|
-
<div class="ct-field__error" [id]="errorId()">
|
|
2057
|
+
<div class="ct-field__error" role="alert" [id]="errorId()">
|
|
1924
2058
|
{{ error() }}
|
|
1925
2059
|
</div>
|
|
1926
2060
|
}
|
|
1927
2061
|
</div>
|
|
1928
|
-
`,
|
|
1929
|
-
|
|
2062
|
+
`,
|
|
2063
|
+
}]
|
|
2064
|
+
}], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], selectId: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectId", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }] } });
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* Test harness for AfSelectComponent.
|
|
2068
|
+
*
|
|
2069
|
+
* Provides a semantic API for interacting with the native select in tests,
|
|
2070
|
+
* abstracting DOM details behind readable method names.
|
|
2071
|
+
*
|
|
2072
|
+
* @example
|
|
2073
|
+
* const harness = new AfSelectHarness(fixture.nativeElement);
|
|
2074
|
+
* expect(harness.isDisabled()).toBe(false);
|
|
2075
|
+
* harness.selectByIndex(1);
|
|
2076
|
+
* expect(harness.getValue()).toBe('Banana');
|
|
2077
|
+
*/
|
|
2078
|
+
class AfSelectHarness {
|
|
2079
|
+
hostEl;
|
|
2080
|
+
constructor(container) {
|
|
2081
|
+
const el = container.querySelector('af-select');
|
|
2082
|
+
if (!el) {
|
|
2083
|
+
throw new Error('AfSelectHarness: af-select element not found in container.');
|
|
2084
|
+
}
|
|
2085
|
+
this.hostEl = el;
|
|
2086
|
+
}
|
|
2087
|
+
/** Returns the native `<select>` element. */
|
|
2088
|
+
getSelectElement() {
|
|
2089
|
+
const select = this.hostEl.querySelector('select');
|
|
2090
|
+
if (!select) {
|
|
2091
|
+
throw new Error('AfSelectHarness: <select> element not found.');
|
|
2092
|
+
}
|
|
2093
|
+
return select;
|
|
2094
|
+
}
|
|
2095
|
+
/** Returns the current display value of the select. */
|
|
2096
|
+
getValue() {
|
|
2097
|
+
const select = this.getSelectElement();
|
|
2098
|
+
return select.options[select.selectedIndex]?.text?.trim() ?? '';
|
|
2099
|
+
}
|
|
2100
|
+
/** Returns the current selected index. */
|
|
2101
|
+
getSelectedIndex() {
|
|
2102
|
+
return this.getSelectElement().selectedIndex;
|
|
2103
|
+
}
|
|
2104
|
+
/** Selects an option by index and dispatches a change event. */
|
|
2105
|
+
selectByIndex(index) {
|
|
2106
|
+
const select = this.getSelectElement();
|
|
2107
|
+
select.selectedIndex = index;
|
|
2108
|
+
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2109
|
+
}
|
|
2110
|
+
/** Returns the trimmed label text, or null if no label. */
|
|
2111
|
+
getLabel() {
|
|
2112
|
+
const label = this.hostEl.querySelector('.ct-field__label');
|
|
2113
|
+
return label?.textContent?.trim() ?? null;
|
|
2114
|
+
}
|
|
2115
|
+
/** Returns the trimmed hint text, or null if no hint. */
|
|
2116
|
+
getHint() {
|
|
2117
|
+
const hint = this.hostEl.querySelector('.ct-field__hint');
|
|
2118
|
+
return hint?.textContent?.trim() ?? null;
|
|
2119
|
+
}
|
|
2120
|
+
/** Returns the trimmed error text, or null if no error. */
|
|
2121
|
+
getError() {
|
|
2122
|
+
const error = this.hostEl.querySelector('.ct-field__error');
|
|
2123
|
+
return error?.textContent?.trim() ?? null;
|
|
2124
|
+
}
|
|
2125
|
+
/** Returns whether the select is disabled. */
|
|
2126
|
+
isDisabled() {
|
|
2127
|
+
return this.getSelectElement().disabled;
|
|
2128
|
+
}
|
|
2129
|
+
/** Returns whether the select is required. */
|
|
2130
|
+
isRequired() {
|
|
2131
|
+
return this.getSelectElement().required;
|
|
2132
|
+
}
|
|
2133
|
+
/** Returns whether `aria-invalid="true"` is set. */
|
|
2134
|
+
isInvalid() {
|
|
2135
|
+
return this.getSelectElement().getAttribute('aria-invalid') === 'true';
|
|
2136
|
+
}
|
|
2137
|
+
/** Returns the `aria-describedby` attribute value. */
|
|
2138
|
+
getAriaDescribedBy() {
|
|
2139
|
+
return this.getSelectElement().getAttribute('aria-describedby');
|
|
2140
|
+
}
|
|
2141
|
+
/** Returns the `aria-label` attribute value. */
|
|
2142
|
+
getAriaLabel() {
|
|
2143
|
+
return this.getSelectElement().getAttribute('aria-label');
|
|
2144
|
+
}
|
|
2145
|
+
/** Returns all `<option>` elements. */
|
|
2146
|
+
getOptions() {
|
|
2147
|
+
return Array.from(this.getSelectElement().options);
|
|
2148
|
+
}
|
|
2149
|
+
/** Returns the number of options (including placeholder). */
|
|
2150
|
+
getOptionCount() {
|
|
2151
|
+
return this.getSelectElement().options.length;
|
|
2152
|
+
}
|
|
2153
|
+
/** Returns the trimmed text of the option at the given index. */
|
|
2154
|
+
getOptionText(index) {
|
|
2155
|
+
const options = this.getOptions();
|
|
2156
|
+
if (index < 0 || index >= options.length) {
|
|
2157
|
+
throw new Error(`AfSelectHarness: option index ${index} out of bounds (${options.length} options).`);
|
|
2158
|
+
}
|
|
2159
|
+
return options[index].text.trim();
|
|
2160
|
+
}
|
|
2161
|
+
/** Returns whether the option at the given index is disabled. */
|
|
2162
|
+
isOptionDisabled(index) {
|
|
2163
|
+
return this.getOptions()[index]?.disabled ?? false;
|
|
2164
|
+
}
|
|
2165
|
+
/** Returns whether the option at the given index is selected. */
|
|
2166
|
+
isOptionSelected(index) {
|
|
2167
|
+
return this.getOptions()[index]?.selected ?? false;
|
|
2168
|
+
}
|
|
2169
|
+
/** Returns the select element's ID. */
|
|
2170
|
+
getId() {
|
|
2171
|
+
return this.getSelectElement().id;
|
|
2172
|
+
}
|
|
2173
|
+
/** Returns whether the field wrapper has the error class. */
|
|
2174
|
+
hasFieldError() {
|
|
2175
|
+
return this.hostEl.querySelector('.ct-field--error') !== null;
|
|
2176
|
+
}
|
|
2177
|
+
/** Returns whether the select has the given CSS class. */
|
|
2178
|
+
hasClass(className) {
|
|
2179
|
+
return this.getSelectElement().classList.contains(className);
|
|
2180
|
+
}
|
|
2181
|
+
/** Returns whether the `.ct-select-wrap` wrapper exists. */
|
|
2182
|
+
hasSelectWrap() {
|
|
2183
|
+
return this.hostEl.querySelector('.ct-select-wrap') !== null;
|
|
2184
|
+
}
|
|
2185
|
+
/** Dispatches a blur event on the select. */
|
|
2186
|
+
blur() {
|
|
2187
|
+
this.getSelectElement().dispatchEvent(new Event('blur', { bubbles: true }));
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
1930
2190
|
|
|
1931
2191
|
/**
|
|
1932
2192
|
* Textarea component with form control support
|
|
@@ -10094,6 +10354,955 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
10094
10354
|
`, styles: [":host{display:contents}\n"] }]
|
|
10095
10355
|
}], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }] } });
|
|
10096
10356
|
|
|
10357
|
+
/**
|
|
10358
|
+
* Injection token to override tree screen-reader announcements
|
|
10359
|
+
* and visible labels for i18n.
|
|
10360
|
+
*
|
|
10361
|
+
* @example
|
|
10362
|
+
* providers: [{
|
|
10363
|
+
* provide: AF_TREE_I18N,
|
|
10364
|
+
* useValue: {
|
|
10365
|
+
* expanded: '{label} ausgeklappt',
|
|
10366
|
+
* collapsed: '{label} eingeklappt',
|
|
10367
|
+
* selected: '{label} ausgewählt',
|
|
10368
|
+
* toggleLabel: 'Aufklappen / Zuklappen',
|
|
10369
|
+
* loadingLabel: 'Lädt …',
|
|
10370
|
+
* emptyMessage: 'Keine Einträge',
|
|
10371
|
+
* orphanLabel: 'Übergeordneter Eintrag fehlt',
|
|
10372
|
+
* },
|
|
10373
|
+
* }]
|
|
10374
|
+
*/
|
|
10375
|
+
const AF_TREE_I18N = new InjectionToken('AfTreeI18n', {
|
|
10376
|
+
factory: () => ({
|
|
10377
|
+
expanded: '{label} expanded',
|
|
10378
|
+
collapsed: '{label} collapsed',
|
|
10379
|
+
selected: '{label} selected',
|
|
10380
|
+
toggleLabel: 'Toggle',
|
|
10381
|
+
loadingLabel: 'Loading…',
|
|
10382
|
+
emptyMessage: 'No entries',
|
|
10383
|
+
orphanLabel: 'Parent missing',
|
|
10384
|
+
}),
|
|
10385
|
+
});
|
|
10386
|
+
|
|
10387
|
+
/** Reset window for incremental type-ahead matching. */
|
|
10388
|
+
const TYPEAHEAD_RESET_MS = 500;
|
|
10389
|
+
/** Auto-incremented suffix used for the live-region id when no `aria-label` is set. */
|
|
10390
|
+
let nextTreeUid = 0;
|
|
10391
|
+
/**
|
|
10392
|
+
* Recursive internal component that renders a single `<li role="treeitem">`
|
|
10393
|
+
* and any visible children. Not part of the public API — consumers always
|
|
10394
|
+
* use {@link AfTreeComponent}.
|
|
10395
|
+
*
|
|
10396
|
+
* @docs-private
|
|
10397
|
+
*/
|
|
10398
|
+
class AfTreeNodeComponent {
|
|
10399
|
+
/** Backreference to the host tree — provides shared state, templates, and event hooks. */
|
|
10400
|
+
tree = inject(forwardRef(() => AfTreeComponent));
|
|
10401
|
+
host = inject(ElementRef);
|
|
10402
|
+
/** Node descriptor for this row. */
|
|
10403
|
+
node = input.required(...(ngDevMode ? [{ debugName: "node" }] : []));
|
|
10404
|
+
/** 1-based depth — sets `aria-level` and the `--ct-level` CSS custom property. */
|
|
10405
|
+
level = input.required(...(ngDevMode ? [{ debugName: "level" }] : []));
|
|
10406
|
+
/** Total siblings on this level — sets `aria-setsize`. */
|
|
10407
|
+
setSize = input.required(...(ngDevMode ? [{ debugName: "setSize" }] : []));
|
|
10408
|
+
/** 1-based position among siblings — sets `aria-posinset`. */
|
|
10409
|
+
posInSet = input.required(...(ngDevMode ? [{ debugName: "posInSet" }] : []));
|
|
10410
|
+
expandable = computed(() => this.tree.isExpandable(this.node()), ...(ngDevMode ? [{ debugName: "expandable" }] : []));
|
|
10411
|
+
expanded = computed(() => this.tree.isExpanded(this.node()), ...(ngDevMode ? [{ debugName: "expanded" }] : []));
|
|
10412
|
+
selected = computed(() => this.tree.isSelected(this.node()), ...(ngDevMode ? [{ debugName: "selected" }] : []));
|
|
10413
|
+
focused = computed(() => this.tree.focusedId() === this.node().id, ...(ngDevMode ? [{ debugName: "focused" }] : []));
|
|
10414
|
+
orphan = computed(() => this.node().meta?.['orphan'] === true, ...(ngDevMode ? [{ debugName: "orphan" }] : []));
|
|
10415
|
+
ariaSelected = computed(() => {
|
|
10416
|
+
if (this.tree.selection() === 'none')
|
|
10417
|
+
return null;
|
|
10418
|
+
return this.selected() ? 'true' : 'false';
|
|
10419
|
+
}, ...(ngDevMode ? [{ debugName: "ariaSelected" }] : []));
|
|
10420
|
+
visibleChildren = computed(() => {
|
|
10421
|
+
const children = this.node().children ?? [];
|
|
10422
|
+
return this.tree.filterChildren(children);
|
|
10423
|
+
}, ...(ngDevMode ? [{ debugName: "visibleChildren" }] : []));
|
|
10424
|
+
defaultLabelHtml = computed(() => this.tree.highlight(this.node().label), ...(ngDevMode ? [{ debugName: "defaultLabelHtml" }] : []));
|
|
10425
|
+
templateContext = computed(() => ({
|
|
10426
|
+
$implicit: this.node(),
|
|
10427
|
+
node: this.node(),
|
|
10428
|
+
level: this.level(),
|
|
10429
|
+
filter: this.tree.filter(),
|
|
10430
|
+
expanded: this.expanded(),
|
|
10431
|
+
selected: this.selected(),
|
|
10432
|
+
focused: this.focused(),
|
|
10433
|
+
}), ...(ngDevMode ? [{ debugName: "templateContext" }] : []));
|
|
10434
|
+
/** Returns the underlying `<li>` element so the host can move DOM focus here. */
|
|
10435
|
+
getLiElement() {
|
|
10436
|
+
return this.host.nativeElement.querySelector(':scope > li');
|
|
10437
|
+
}
|
|
10438
|
+
onFocus() {
|
|
10439
|
+
this.tree.focusedId.set(this.node().id);
|
|
10440
|
+
this.tree.nodeFocus.emit(this.node());
|
|
10441
|
+
}
|
|
10442
|
+
onToggleClick(event) {
|
|
10443
|
+
event.stopPropagation();
|
|
10444
|
+
if (this.node().disabled)
|
|
10445
|
+
return;
|
|
10446
|
+
this.tree.toggle(this.node());
|
|
10447
|
+
}
|
|
10448
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfTreeNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
10449
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfTreeNodeComponent, isStandalone: true, selector: "af-tree-node", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, level: { classPropertyName: "level", publicName: "level", isSignal: true, isRequired: true, transformFunction: null }, setSize: { classPropertyName: "setSize", publicName: "setSize", isSignal: true, isRequired: true, transformFunction: null }, posInSet: { classPropertyName: "posInSet", publicName: "posInSet", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
|
|
10450
|
+
<li
|
|
10451
|
+
#li
|
|
10452
|
+
class="ct-tree__node"
|
|
10453
|
+
[class.ct-tree__node--selected]="selected()"
|
|
10454
|
+
[class.ct-tree__node--orphan]="orphan()"
|
|
10455
|
+
role="treeitem"
|
|
10456
|
+
[attr.aria-level]="level()"
|
|
10457
|
+
[attr.aria-setsize]="setSize()"
|
|
10458
|
+
[attr.aria-posinset]="posInSet()"
|
|
10459
|
+
[attr.aria-expanded]="expandable() ? expanded() : null"
|
|
10460
|
+
[attr.aria-selected]="ariaSelected()"
|
|
10461
|
+
[attr.aria-disabled]="node().disabled ? 'true' : null"
|
|
10462
|
+
[attr.aria-busy]="node().loading ? 'true' : null"
|
|
10463
|
+
[attr.tabindex]="focused() ? 0 : -1"
|
|
10464
|
+
[attr.data-tree-id]="node().id"
|
|
10465
|
+
(focus)="onFocus()">
|
|
10466
|
+
<div class="ct-tree__row" [style.--ct-level]="level()">
|
|
10467
|
+
@if (expandable()) {
|
|
10468
|
+
<button
|
|
10469
|
+
type="button"
|
|
10470
|
+
class="ct-tree__toggle"
|
|
10471
|
+
tabindex="-1"
|
|
10472
|
+
[attr.aria-hidden]="true"
|
|
10473
|
+
[attr.aria-label]="tree.i18n.toggleLabel"
|
|
10474
|
+
(click)="onToggleClick($event)">
|
|
10475
|
+
<svg
|
|
10476
|
+
class="ct-tree__chevron"
|
|
10477
|
+
viewBox="0 0 24 24"
|
|
10478
|
+
fill="none"
|
|
10479
|
+
stroke="currentColor"
|
|
10480
|
+
stroke-width="2"
|
|
10481
|
+
stroke-linecap="round"
|
|
10482
|
+
stroke-linejoin="round"
|
|
10483
|
+
aria-hidden="true"
|
|
10484
|
+
focusable="false">
|
|
10485
|
+
<polyline points="9 6 15 12 9 18" />
|
|
10486
|
+
</svg>
|
|
10487
|
+
</button>
|
|
10488
|
+
} @else {
|
|
10489
|
+
<span class="ct-tree__spacer" aria-hidden="true"></span>
|
|
10490
|
+
}
|
|
10491
|
+
|
|
10492
|
+
<div class="ct-tree__content">
|
|
10493
|
+
@if (tree.nodeContent(); as tpl) {
|
|
10494
|
+
<ng-container
|
|
10495
|
+
[ngTemplateOutlet]="tpl"
|
|
10496
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10497
|
+
} @else {
|
|
10498
|
+
<span class="ct-tree__label" [innerHTML]="defaultLabelHtml()"></span>
|
|
10499
|
+
}
|
|
10500
|
+
@if (tree.nodeWarning(); as warnTpl) {
|
|
10501
|
+
<ng-container
|
|
10502
|
+
[ngTemplateOutlet]="warnTpl"
|
|
10503
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10504
|
+
}
|
|
10505
|
+
@if (node().loading) {
|
|
10506
|
+
<span class="ct-tree__sr-only">{{ tree.i18n.loadingLabel }}</span>
|
|
10507
|
+
}
|
|
10508
|
+
</div>
|
|
10509
|
+
|
|
10510
|
+
@if (tree.nodeActions(); as actTpl) {
|
|
10511
|
+
<div class="ct-tree__actions">
|
|
10512
|
+
<ng-container
|
|
10513
|
+
[ngTemplateOutlet]="actTpl"
|
|
10514
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10515
|
+
</div>
|
|
10516
|
+
}
|
|
10517
|
+
</div>
|
|
10518
|
+
|
|
10519
|
+
@if (expandable() && expanded() && !node().loading) {
|
|
10520
|
+
<ul
|
|
10521
|
+
class="ct-tree__group"
|
|
10522
|
+
role="group"
|
|
10523
|
+
[style.--ct-parent-level]="level() - 1">
|
|
10524
|
+
@for (
|
|
10525
|
+
child of visibleChildren();
|
|
10526
|
+
track tree.trackBy()(child);
|
|
10527
|
+
let i = $index, count = $count
|
|
10528
|
+
) {
|
|
10529
|
+
<af-tree-node
|
|
10530
|
+
[node]="child"
|
|
10531
|
+
[level]="level() + 1"
|
|
10532
|
+
[setSize]="count"
|
|
10533
|
+
[posInSet]="i + 1"></af-tree-node>
|
|
10534
|
+
}
|
|
10535
|
+
</ul>
|
|
10536
|
+
}
|
|
10537
|
+
</li>
|
|
10538
|
+
`, isInline: true, styles: [":host{display:contents}.ct-tree__sr-only{position:absolute;inline-size:1px;block-size:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => AfTreeNodeComponent), selector: "af-tree-node", inputs: ["node", "level", "setSize", "posInSet"] }, { kind: "directive", type: i0.forwardRef(() => NgTemplateOutlet), selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
10539
|
+
}
|
|
10540
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfTreeNodeComponent, decorators: [{
|
|
10541
|
+
type: Component,
|
|
10542
|
+
args: [{ selector: 'af-tree-node', standalone: true, imports: [NgTemplateOutlet, forwardRef(() => AfTreeNodeComponent)], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
10543
|
+
<li
|
|
10544
|
+
#li
|
|
10545
|
+
class="ct-tree__node"
|
|
10546
|
+
[class.ct-tree__node--selected]="selected()"
|
|
10547
|
+
[class.ct-tree__node--orphan]="orphan()"
|
|
10548
|
+
role="treeitem"
|
|
10549
|
+
[attr.aria-level]="level()"
|
|
10550
|
+
[attr.aria-setsize]="setSize()"
|
|
10551
|
+
[attr.aria-posinset]="posInSet()"
|
|
10552
|
+
[attr.aria-expanded]="expandable() ? expanded() : null"
|
|
10553
|
+
[attr.aria-selected]="ariaSelected()"
|
|
10554
|
+
[attr.aria-disabled]="node().disabled ? 'true' : null"
|
|
10555
|
+
[attr.aria-busy]="node().loading ? 'true' : null"
|
|
10556
|
+
[attr.tabindex]="focused() ? 0 : -1"
|
|
10557
|
+
[attr.data-tree-id]="node().id"
|
|
10558
|
+
(focus)="onFocus()">
|
|
10559
|
+
<div class="ct-tree__row" [style.--ct-level]="level()">
|
|
10560
|
+
@if (expandable()) {
|
|
10561
|
+
<button
|
|
10562
|
+
type="button"
|
|
10563
|
+
class="ct-tree__toggle"
|
|
10564
|
+
tabindex="-1"
|
|
10565
|
+
[attr.aria-hidden]="true"
|
|
10566
|
+
[attr.aria-label]="tree.i18n.toggleLabel"
|
|
10567
|
+
(click)="onToggleClick($event)">
|
|
10568
|
+
<svg
|
|
10569
|
+
class="ct-tree__chevron"
|
|
10570
|
+
viewBox="0 0 24 24"
|
|
10571
|
+
fill="none"
|
|
10572
|
+
stroke="currentColor"
|
|
10573
|
+
stroke-width="2"
|
|
10574
|
+
stroke-linecap="round"
|
|
10575
|
+
stroke-linejoin="round"
|
|
10576
|
+
aria-hidden="true"
|
|
10577
|
+
focusable="false">
|
|
10578
|
+
<polyline points="9 6 15 12 9 18" />
|
|
10579
|
+
</svg>
|
|
10580
|
+
</button>
|
|
10581
|
+
} @else {
|
|
10582
|
+
<span class="ct-tree__spacer" aria-hidden="true"></span>
|
|
10583
|
+
}
|
|
10584
|
+
|
|
10585
|
+
<div class="ct-tree__content">
|
|
10586
|
+
@if (tree.nodeContent(); as tpl) {
|
|
10587
|
+
<ng-container
|
|
10588
|
+
[ngTemplateOutlet]="tpl"
|
|
10589
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10590
|
+
} @else {
|
|
10591
|
+
<span class="ct-tree__label" [innerHTML]="defaultLabelHtml()"></span>
|
|
10592
|
+
}
|
|
10593
|
+
@if (tree.nodeWarning(); as warnTpl) {
|
|
10594
|
+
<ng-container
|
|
10595
|
+
[ngTemplateOutlet]="warnTpl"
|
|
10596
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10597
|
+
}
|
|
10598
|
+
@if (node().loading) {
|
|
10599
|
+
<span class="ct-tree__sr-only">{{ tree.i18n.loadingLabel }}</span>
|
|
10600
|
+
}
|
|
10601
|
+
</div>
|
|
10602
|
+
|
|
10603
|
+
@if (tree.nodeActions(); as actTpl) {
|
|
10604
|
+
<div class="ct-tree__actions">
|
|
10605
|
+
<ng-container
|
|
10606
|
+
[ngTemplateOutlet]="actTpl"
|
|
10607
|
+
[ngTemplateOutletContext]="templateContext()"></ng-container>
|
|
10608
|
+
</div>
|
|
10609
|
+
}
|
|
10610
|
+
</div>
|
|
10611
|
+
|
|
10612
|
+
@if (expandable() && expanded() && !node().loading) {
|
|
10613
|
+
<ul
|
|
10614
|
+
class="ct-tree__group"
|
|
10615
|
+
role="group"
|
|
10616
|
+
[style.--ct-parent-level]="level() - 1">
|
|
10617
|
+
@for (
|
|
10618
|
+
child of visibleChildren();
|
|
10619
|
+
track tree.trackBy()(child);
|
|
10620
|
+
let i = $index, count = $count
|
|
10621
|
+
) {
|
|
10622
|
+
<af-tree-node
|
|
10623
|
+
[node]="child"
|
|
10624
|
+
[level]="level() + 1"
|
|
10625
|
+
[setSize]="count"
|
|
10626
|
+
[posInSet]="i + 1"></af-tree-node>
|
|
10627
|
+
}
|
|
10628
|
+
</ul>
|
|
10629
|
+
}
|
|
10630
|
+
</li>
|
|
10631
|
+
`, styles: [":host{display:contents}.ct-tree__sr-only{position:absolute;inline-size:1px;block-size:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}\n"] }]
|
|
10632
|
+
}], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], level: [{ type: i0.Input, args: [{ isSignal: true, alias: "level", required: true }] }], setSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "setSize", required: true }] }], posInSet: [{ type: i0.Input, args: [{ isSignal: true, alias: "posInSet", required: true }] }] } });
|
|
10633
|
+
/**
|
|
10634
|
+
* Accessible Tree component implementing the WAI-ARIA Tree View pattern.
|
|
10635
|
+
*
|
|
10636
|
+
* Wraps the Construct `ct-tree` CSS component into a signal-based, OnPush
|
|
10637
|
+
* Angular component with full keyboard navigation, type-ahead, single /
|
|
10638
|
+
* multi-selection, async-load support, and client-side filtering.
|
|
10639
|
+
*
|
|
10640
|
+
* @example Static org tree
|
|
10641
|
+
* <af-tree
|
|
10642
|
+
* [nodes]="organizations()"
|
|
10643
|
+
* ariaLabel="Organizations"
|
|
10644
|
+
* [showIndentGuides]="true"
|
|
10645
|
+
* selection="single"
|
|
10646
|
+
* [(selectedIds)]="selected"
|
|
10647
|
+
* (nodeActivate)="open($event)">
|
|
10648
|
+
* <ng-template #nodeContent let-node>
|
|
10649
|
+
* <af-icon name="folder" />
|
|
10650
|
+
* <span>{{ node.label }}</span>
|
|
10651
|
+
* <af-badge>{{ node.data.customerType }}</af-badge>
|
|
10652
|
+
* </ng-template>
|
|
10653
|
+
* </af-tree>
|
|
10654
|
+
*
|
|
10655
|
+
* @example Async lazy-load
|
|
10656
|
+
* <af-tree
|
|
10657
|
+
* [nodes]="nodes()"
|
|
10658
|
+
* ariaLabel="File system"
|
|
10659
|
+
* (loadChildren)="onLoad($event)" />
|
|
10660
|
+
*
|
|
10661
|
+
* @accessibility
|
|
10662
|
+
* - Container exposes `role="tree"` and the required `aria-label`.
|
|
10663
|
+
* - Each node is a `<li role="treeitem">` carrying `aria-level`,
|
|
10664
|
+
* `aria-setsize`, `aria-posinset`, and (when expandable) `aria-expanded`.
|
|
10665
|
+
* - Roving tabindex on the `<li>` so screen readers announce treeitem role,
|
|
10666
|
+
* level and selection state when focus lands.
|
|
10667
|
+
* - Keyboard: `↑`/`↓` move focus, `→` expands or steps into children,
|
|
10668
|
+
* `←` collapses or steps to the parent, `Home`/`End` jump, `Enter`
|
|
10669
|
+
* activates, `Space` toggles selection (multi) or activates (single),
|
|
10670
|
+
* `*` expands all sibling branches, A–Z performs incremental type-ahead.
|
|
10671
|
+
* - Selection state is mirrored via `aria-selected` only when
|
|
10672
|
+
* `selection !== 'none'` — leaves implicit selection off in static trees.
|
|
10673
|
+
* - `aria-busy="true"` is rendered on rows whose `node.loading` is `true`.
|
|
10674
|
+
* - Custom slot templates receive the active filter so they can highlight
|
|
10675
|
+
* matches consistently with the default renderer.
|
|
10676
|
+
*/
|
|
10677
|
+
class AfTreeComponent {
|
|
10678
|
+
/** Internal id used to scope live-region announcements (debug aid). */
|
|
10679
|
+
uid = ++nextTreeUid;
|
|
10680
|
+
/** I18n bundle resolved via {@link AF_TREE_I18N}. Public so the recursive child component can render labels. */
|
|
10681
|
+
i18n = inject(AF_TREE_I18N);
|
|
10682
|
+
announcer = inject(AriaLiveAnnouncer);
|
|
10683
|
+
host = inject(ElementRef);
|
|
10684
|
+
injector = inject(Injector);
|
|
10685
|
+
// ─── Inputs ─────────────────────────────────────────────────────────────
|
|
10686
|
+
/** Hierarchical node list. */
|
|
10687
|
+
nodes = input.required(...(ngDevMode ? [{ debugName: "nodes" }] : []));
|
|
10688
|
+
/** Container `aria-label` — required by the WAI-ARIA Tree pattern. */
|
|
10689
|
+
ariaLabel = input.required(...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
|
|
10690
|
+
/** Selection mode. Defaults to `'none'`. */
|
|
10691
|
+
selection = input('none', ...(ngDevMode ? [{ debugName: "selection" }] : []));
|
|
10692
|
+
/** Two-way bound set of expanded node ids. */
|
|
10693
|
+
expandedIds = model(new Set(), ...(ngDevMode ? [{ debugName: "expandedIds" }] : []));
|
|
10694
|
+
/** Two-way bound set of selected node ids. */
|
|
10695
|
+
selectedIds = model(new Set(), ...(ngDevMode ? [{ debugName: "selectedIds" }] : []));
|
|
10696
|
+
/** Case-insensitive substring filter; auto-expands ancestors of matches. */
|
|
10697
|
+
filter = input('', ...(ngDevMode ? [{ debugName: "filter" }] : []));
|
|
10698
|
+
/** Render `.ct-tree--guides` (vertical indent lines). */
|
|
10699
|
+
showIndentGuides = input(false, { ...(ngDevMode ? { debugName: "showIndentGuides" } : {}), transform: booleanAttribute });
|
|
10700
|
+
/** Render `.ct-tree--dense` modifier. */
|
|
10701
|
+
dense = input(false, { ...(ngDevMode ? { debugName: "dense" } : {}), transform: booleanAttribute });
|
|
10702
|
+
/** Render `.ct-tree--bordered` (surface variant). */
|
|
10703
|
+
bordered = input(false, { ...(ngDevMode ? { debugName: "bordered" } : {}), transform: booleanAttribute });
|
|
10704
|
+
/** TrackBy override — defaults to `node.id`. */
|
|
10705
|
+
trackBy = input((n) => n.id, ...(ngDevMode ? [{ debugName: "trackBy" }] : []));
|
|
10706
|
+
// ─── Outputs ────────────────────────────────────────────────────────────
|
|
10707
|
+
/** Emits when a node is activated (Enter or row click). */
|
|
10708
|
+
nodeActivate = output();
|
|
10709
|
+
/** Emits when a node is expanded or collapsed. */
|
|
10710
|
+
nodeToggle = output();
|
|
10711
|
+
/** Emits when focus moves to a node. */
|
|
10712
|
+
nodeFocus = output();
|
|
10713
|
+
/** Emits the first time a node with `children === undefined` is expanded (lazy-load hook). */
|
|
10714
|
+
loadChildren = output();
|
|
10715
|
+
// ─── Slots (ContentChild templates) ─────────────────────────────────────
|
|
10716
|
+
/** Template for custom node content; falls back to `node.label` with highlight. */
|
|
10717
|
+
nodeContent = contentChild('nodeContent', ...(ngDevMode ? [{ debugName: "nodeContent" }] : []));
|
|
10718
|
+
/** Template for action buttons rendered visible-on-hover/focus. */
|
|
10719
|
+
nodeActions = contentChild('nodeActions', ...(ngDevMode ? [{ debugName: "nodeActions" }] : []));
|
|
10720
|
+
/** Template for warning slot (e.g. orphan indicators). */
|
|
10721
|
+
nodeWarning = contentChild('nodeWarning', ...(ngDevMode ? [{ debugName: "nodeWarning" }] : []));
|
|
10722
|
+
/** Template shown when the (filtered) tree is empty. */
|
|
10723
|
+
emptySlot = contentChild('empty', ...(ngDevMode ? [{ debugName: "emptySlot" }] : []));
|
|
10724
|
+
// ─── Internal state ─────────────────────────────────────────────────────
|
|
10725
|
+
/** Currently focused node id (drives the roving tabindex). */
|
|
10726
|
+
focusedId = signal(null, ...(ngDevMode ? [{ debugName: "focusedId" }] : []));
|
|
10727
|
+
/** Tracks nodes whose `loadChildren` has already fired so we don't refire on re-expand. */
|
|
10728
|
+
loadedIds = new Set();
|
|
10729
|
+
/** Type-ahead buffer + reset timer. */
|
|
10730
|
+
typeBuffer = '';
|
|
10731
|
+
typeTimer = null;
|
|
10732
|
+
// ─── Derived state ──────────────────────────────────────────────────────
|
|
10733
|
+
/** Flat list of visible (expanded-path) nodes. Used for keyboard nav. */
|
|
10734
|
+
visibleNodeOrder = computed(() => {
|
|
10735
|
+
const out = [];
|
|
10736
|
+
const walk = (list) => {
|
|
10737
|
+
for (const n of this.filterChildren(list)) {
|
|
10738
|
+
out.push(n);
|
|
10739
|
+
if (this.isExpanded(n) && !n.loading) {
|
|
10740
|
+
walk(n.children ?? []);
|
|
10741
|
+
}
|
|
10742
|
+
}
|
|
10743
|
+
};
|
|
10744
|
+
walk(this.nodes());
|
|
10745
|
+
return out;
|
|
10746
|
+
}, ...(ngDevMode ? [{ debugName: "visibleNodeOrder" }] : []));
|
|
10747
|
+
/** Top-level filtered nodes — used by the template. */
|
|
10748
|
+
visibleNodes = computed(() => this.filterChildren(this.nodes()), ...(ngDevMode ? [{ debugName: "visibleNodes" }] : []));
|
|
10749
|
+
treeClasses = computed(() => {
|
|
10750
|
+
const classes = ['ct-tree'];
|
|
10751
|
+
if (this.showIndentGuides())
|
|
10752
|
+
classes.push('ct-tree--guides');
|
|
10753
|
+
if (this.dense())
|
|
10754
|
+
classes.push('ct-tree--dense');
|
|
10755
|
+
if (this.bordered())
|
|
10756
|
+
classes.push('ct-tree--bordered');
|
|
10757
|
+
return classes.join(' ');
|
|
10758
|
+
}, ...(ngDevMode ? [{ debugName: "treeClasses" }] : []));
|
|
10759
|
+
/**
|
|
10760
|
+
* Set of node ids matching the current filter. Empty set means no filter active.
|
|
10761
|
+
* Filtering is case-insensitive substring on `node.label`.
|
|
10762
|
+
*/
|
|
10763
|
+
filterMatches = computed(() => {
|
|
10764
|
+
const q = this.filter().trim().toLowerCase();
|
|
10765
|
+
if (!q)
|
|
10766
|
+
return null;
|
|
10767
|
+
const matches = new Set();
|
|
10768
|
+
const walk = (list) => {
|
|
10769
|
+
let any = false;
|
|
10770
|
+
for (const n of list) {
|
|
10771
|
+
const self = n.label.toLowerCase().includes(q);
|
|
10772
|
+
const childHit = walk(n.children ?? []);
|
|
10773
|
+
if (self || childHit) {
|
|
10774
|
+
matches.add(n.id);
|
|
10775
|
+
any = true;
|
|
10776
|
+
}
|
|
10777
|
+
}
|
|
10778
|
+
return any;
|
|
10779
|
+
};
|
|
10780
|
+
walk(this.nodes());
|
|
10781
|
+
return matches;
|
|
10782
|
+
}, ...(ngDevMode ? [{ debugName: "filterMatches" }] : []));
|
|
10783
|
+
/** Auto-expanded ids derived from active filter (ancestors of matches). */
|
|
10784
|
+
autoExpandedIds = computed(() => {
|
|
10785
|
+
const matches = this.filterMatches();
|
|
10786
|
+
if (!matches)
|
|
10787
|
+
return null;
|
|
10788
|
+
const expanded = new Set();
|
|
10789
|
+
const walk = (list) => {
|
|
10790
|
+
for (const n of list) {
|
|
10791
|
+
if (matches.has(n.id) && (n.children?.length ?? 0) > 0) {
|
|
10792
|
+
expanded.add(n.id);
|
|
10793
|
+
}
|
|
10794
|
+
walk(n.children ?? []);
|
|
10795
|
+
}
|
|
10796
|
+
};
|
|
10797
|
+
walk(this.nodes());
|
|
10798
|
+
return expanded;
|
|
10799
|
+
}, ...(ngDevMode ? [{ debugName: "autoExpandedIds" }] : []));
|
|
10800
|
+
constructor() {
|
|
10801
|
+
/** Seed the focused id when nodes first become available so Tab lands on a row. */
|
|
10802
|
+
effect(() => {
|
|
10803
|
+
const first = this.visibleNodeOrder()[0];
|
|
10804
|
+
const current = this.focusedId();
|
|
10805
|
+
if (!first) {
|
|
10806
|
+
if (current)
|
|
10807
|
+
this.focusedId.set(null);
|
|
10808
|
+
return;
|
|
10809
|
+
}
|
|
10810
|
+
if (!current || !this.findNode(current)) {
|
|
10811
|
+
this.focusedId.set(first.id);
|
|
10812
|
+
}
|
|
10813
|
+
});
|
|
10814
|
+
}
|
|
10815
|
+
// ─── State queries (called from the recursive child) ────────────────────
|
|
10816
|
+
/** A node is expandable when it has unloaded children, real children, or is loading. */
|
|
10817
|
+
isExpandable(node) {
|
|
10818
|
+
if (node.isLeaf)
|
|
10819
|
+
return false;
|
|
10820
|
+
if (node.loading)
|
|
10821
|
+
return true;
|
|
10822
|
+
if (node.children === undefined)
|
|
10823
|
+
return true;
|
|
10824
|
+
return node.children.length > 0;
|
|
10825
|
+
}
|
|
10826
|
+
isExpanded(node) {
|
|
10827
|
+
if (this.autoExpandedIds()?.has(node.id))
|
|
10828
|
+
return true;
|
|
10829
|
+
return this.expandedIds().has(node.id);
|
|
10830
|
+
}
|
|
10831
|
+
isSelected(node) {
|
|
10832
|
+
return this.selectedIds().has(node.id);
|
|
10833
|
+
}
|
|
10834
|
+
/** Returns the children list filtered by the active filter (or all if none). */
|
|
10835
|
+
filterChildren(children) {
|
|
10836
|
+
const matches = this.filterMatches();
|
|
10837
|
+
if (!matches)
|
|
10838
|
+
return children;
|
|
10839
|
+
return children.filter((c) => matches.has(c.id));
|
|
10840
|
+
}
|
|
10841
|
+
/** Wraps the first case-insensitive match of the active filter in `<mark>` tags. */
|
|
10842
|
+
highlight(label) {
|
|
10843
|
+
const q = this.filter().trim();
|
|
10844
|
+
const safe = escapeHtml(label);
|
|
10845
|
+
if (!q)
|
|
10846
|
+
return safe;
|
|
10847
|
+
const idx = safe.toLowerCase().indexOf(q.toLowerCase());
|
|
10848
|
+
if (idx < 0)
|
|
10849
|
+
return safe;
|
|
10850
|
+
const end = idx + q.length;
|
|
10851
|
+
return `${safe.slice(0, idx)}<mark>${safe.slice(idx, end)}</mark>${safe.slice(end)}`;
|
|
10852
|
+
}
|
|
10853
|
+
// ─── Mutation API ───────────────────────────────────────────────────────
|
|
10854
|
+
/** Toggle the expanded state of `node`; fires `loadChildren` on first lazy-expand. */
|
|
10855
|
+
toggle(node) {
|
|
10856
|
+
if (node.disabled || !this.isExpandable(node))
|
|
10857
|
+
return;
|
|
10858
|
+
const wasExpanded = this.expandedIds().has(node.id);
|
|
10859
|
+
const next = new Set(this.expandedIds());
|
|
10860
|
+
if (wasExpanded)
|
|
10861
|
+
next.delete(node.id);
|
|
10862
|
+
else
|
|
10863
|
+
next.add(node.id);
|
|
10864
|
+
this.expandedIds.set(next);
|
|
10865
|
+
const expanded = !wasExpanded;
|
|
10866
|
+
this.nodeToggle.emit({ node, expanded });
|
|
10867
|
+
this.announcer.announce((expanded ? this.i18n.expanded : this.i18n.collapsed).replace('{label}', node.label));
|
|
10868
|
+
if (expanded && node.children === undefined && !this.loadedIds.has(node.id)) {
|
|
10869
|
+
this.loadedIds.add(node.id);
|
|
10870
|
+
this.loadChildren.emit(node);
|
|
10871
|
+
}
|
|
10872
|
+
}
|
|
10873
|
+
/**
|
|
10874
|
+
* Activate `node` — always emits `nodeActivate` and updates selection per
|
|
10875
|
+
* mode. In `multiple` mode `Enter` activates without toggling selection so
|
|
10876
|
+
* keyboard users can drive a primary action without disturbing checkboxes.
|
|
10877
|
+
*/
|
|
10878
|
+
activate(node, source) {
|
|
10879
|
+
if (node.disabled)
|
|
10880
|
+
return;
|
|
10881
|
+
const mode = this.selection();
|
|
10882
|
+
if (mode === 'single') {
|
|
10883
|
+
this.applySingleSelection(node);
|
|
10884
|
+
}
|
|
10885
|
+
else if (mode === 'multiple' && source !== 'enter') {
|
|
10886
|
+
this.toggleMultiSelection(node);
|
|
10887
|
+
}
|
|
10888
|
+
this.nodeActivate.emit(node);
|
|
10889
|
+
}
|
|
10890
|
+
applySingleSelection(node) {
|
|
10891
|
+
const current = this.selectedIds();
|
|
10892
|
+
if (current.size === 1 && current.has(node.id))
|
|
10893
|
+
return;
|
|
10894
|
+
this.selectedIds.set(new Set([node.id]));
|
|
10895
|
+
this.announcer.announce(this.i18n.selected.replace('{label}', node.label));
|
|
10896
|
+
}
|
|
10897
|
+
toggleMultiSelection(node) {
|
|
10898
|
+
const next = new Set(this.selectedIds());
|
|
10899
|
+
if (next.has(node.id))
|
|
10900
|
+
next.delete(node.id);
|
|
10901
|
+
else {
|
|
10902
|
+
next.add(node.id);
|
|
10903
|
+
this.announcer.announce(this.i18n.selected.replace('{label}', node.label));
|
|
10904
|
+
}
|
|
10905
|
+
this.selectedIds.set(next);
|
|
10906
|
+
}
|
|
10907
|
+
// ─── Keyboard handling ──────────────────────────────────────────────────
|
|
10908
|
+
handleKeydown(event) {
|
|
10909
|
+
const target = event.target;
|
|
10910
|
+
const li = target?.closest('li.ct-tree__node');
|
|
10911
|
+
if (!li || !this.host.nativeElement.contains(li))
|
|
10912
|
+
return;
|
|
10913
|
+
const id = li.getAttribute('data-tree-id');
|
|
10914
|
+
if (!id)
|
|
10915
|
+
return;
|
|
10916
|
+
const node = this.findNode(id);
|
|
10917
|
+
if (!node)
|
|
10918
|
+
return;
|
|
10919
|
+
const order = this.visibleNodeOrder();
|
|
10920
|
+
const idx = order.findIndex((n) => n.id === id);
|
|
10921
|
+
if (idx < 0)
|
|
10922
|
+
return;
|
|
10923
|
+
switch (event.key) {
|
|
10924
|
+
case 'ArrowDown': {
|
|
10925
|
+
event.preventDefault();
|
|
10926
|
+
const next = order[Math.min(order.length - 1, idx + 1)];
|
|
10927
|
+
if (next)
|
|
10928
|
+
this.moveFocus(next);
|
|
10929
|
+
break;
|
|
10930
|
+
}
|
|
10931
|
+
case 'ArrowUp': {
|
|
10932
|
+
event.preventDefault();
|
|
10933
|
+
const prev = order[Math.max(0, idx - 1)];
|
|
10934
|
+
if (prev)
|
|
10935
|
+
this.moveFocus(prev);
|
|
10936
|
+
break;
|
|
10937
|
+
}
|
|
10938
|
+
case 'ArrowRight': {
|
|
10939
|
+
event.preventDefault();
|
|
10940
|
+
if (!this.isExpandable(node))
|
|
10941
|
+
break;
|
|
10942
|
+
if (!this.expandedIds().has(node.id)) {
|
|
10943
|
+
this.toggle(node);
|
|
10944
|
+
}
|
|
10945
|
+
else {
|
|
10946
|
+
const firstChild = (node.children ?? [])[0];
|
|
10947
|
+
if (firstChild)
|
|
10948
|
+
this.moveFocus(firstChild);
|
|
10949
|
+
}
|
|
10950
|
+
break;
|
|
10951
|
+
}
|
|
10952
|
+
case 'ArrowLeft': {
|
|
10953
|
+
event.preventDefault();
|
|
10954
|
+
if (this.isExpandable(node) && this.expandedIds().has(node.id)) {
|
|
10955
|
+
this.toggle(node);
|
|
10956
|
+
}
|
|
10957
|
+
else {
|
|
10958
|
+
const parent = this.findParent(node.id);
|
|
10959
|
+
if (parent)
|
|
10960
|
+
this.moveFocus(parent);
|
|
10961
|
+
}
|
|
10962
|
+
break;
|
|
10963
|
+
}
|
|
10964
|
+
case 'Home': {
|
|
10965
|
+
event.preventDefault();
|
|
10966
|
+
if (order[0])
|
|
10967
|
+
this.moveFocus(order[0]);
|
|
10968
|
+
break;
|
|
10969
|
+
}
|
|
10970
|
+
case 'End': {
|
|
10971
|
+
event.preventDefault();
|
|
10972
|
+
const last = order[order.length - 1];
|
|
10973
|
+
if (last)
|
|
10974
|
+
this.moveFocus(last);
|
|
10975
|
+
break;
|
|
10976
|
+
}
|
|
10977
|
+
case 'Enter': {
|
|
10978
|
+
event.preventDefault();
|
|
10979
|
+
if (node.disabled)
|
|
10980
|
+
break;
|
|
10981
|
+
this.activate(node, 'enter');
|
|
10982
|
+
break;
|
|
10983
|
+
}
|
|
10984
|
+
case ' ': {
|
|
10985
|
+
event.preventDefault();
|
|
10986
|
+
if (node.disabled)
|
|
10987
|
+
break;
|
|
10988
|
+
if (this.selection() === 'none')
|
|
10989
|
+
this.activate(node, 'enter');
|
|
10990
|
+
else
|
|
10991
|
+
this.activate(node, 'space');
|
|
10992
|
+
break;
|
|
10993
|
+
}
|
|
10994
|
+
case '*': {
|
|
10995
|
+
event.preventDefault();
|
|
10996
|
+
this.expandClosedSiblings(node);
|
|
10997
|
+
break;
|
|
10998
|
+
}
|
|
10999
|
+
default: {
|
|
11000
|
+
if (event.key.length === 1 &&
|
|
11001
|
+
/\S/.test(event.key) &&
|
|
11002
|
+
!event.ctrlKey &&
|
|
11003
|
+
!event.metaKey &&
|
|
11004
|
+
!event.altKey) {
|
|
11005
|
+
this.typeahead(event.key);
|
|
11006
|
+
}
|
|
11007
|
+
}
|
|
11008
|
+
}
|
|
11009
|
+
}
|
|
11010
|
+
handleClick(event) {
|
|
11011
|
+
const target = event.target;
|
|
11012
|
+
/** Toggle button has its own handler — it stops propagation, but guard anyway. */
|
|
11013
|
+
if (target.closest('.ct-tree__toggle'))
|
|
11014
|
+
return;
|
|
11015
|
+
/** Clicks inside the actions slot belong to the consumer's buttons. */
|
|
11016
|
+
if (target.closest('.ct-tree__actions'))
|
|
11017
|
+
return;
|
|
11018
|
+
const li = target.closest('li.ct-tree__node');
|
|
11019
|
+
if (!li || !this.host.nativeElement.contains(li))
|
|
11020
|
+
return;
|
|
11021
|
+
const id = li.getAttribute('data-tree-id');
|
|
11022
|
+
if (!id)
|
|
11023
|
+
return;
|
|
11024
|
+
const node = this.findNode(id);
|
|
11025
|
+
if (!node || node.disabled)
|
|
11026
|
+
return;
|
|
11027
|
+
this.moveFocus(node);
|
|
11028
|
+
this.activate(node, 'click');
|
|
11029
|
+
}
|
|
11030
|
+
expandClosedSiblings(node) {
|
|
11031
|
+
const parent = this.findParent(node.id);
|
|
11032
|
+
const siblings = parent ? (parent.children ?? []) : this.nodes();
|
|
11033
|
+
for (const sib of siblings) {
|
|
11034
|
+
if (this.isExpandable(sib) && !sib.disabled && !this.expandedIds().has(sib.id)) {
|
|
11035
|
+
this.toggle(sib);
|
|
11036
|
+
}
|
|
11037
|
+
}
|
|
11038
|
+
}
|
|
11039
|
+
typeahead(char) {
|
|
11040
|
+
this.typeBuffer += char.toLowerCase();
|
|
11041
|
+
if (this.typeTimer)
|
|
11042
|
+
clearTimeout(this.typeTimer);
|
|
11043
|
+
this.typeTimer = setTimeout(() => {
|
|
11044
|
+
this.typeBuffer = '';
|
|
11045
|
+
this.typeTimer = null;
|
|
11046
|
+
}, TYPEAHEAD_RESET_MS);
|
|
11047
|
+
const order = this.visibleNodeOrder();
|
|
11048
|
+
if (order.length === 0)
|
|
11049
|
+
return;
|
|
11050
|
+
const currentId = this.focusedId();
|
|
11051
|
+
const startIdx = Math.max(0, order.findIndex((n) => n.id === currentId));
|
|
11052
|
+
const ordered = order.slice(startIdx + 1).concat(order.slice(0, startIdx + 1));
|
|
11053
|
+
const buf = this.typeBuffer;
|
|
11054
|
+
const match = ordered.find((n) => n.label.toLowerCase().startsWith(buf));
|
|
11055
|
+
if (match)
|
|
11056
|
+
this.moveFocus(match);
|
|
11057
|
+
}
|
|
11058
|
+
// ─── Focus / lookup helpers ─────────────────────────────────────────────
|
|
11059
|
+
/** Move focus to `node` — updates the roving tabindex and pulls DOM focus into the tree. */
|
|
11060
|
+
moveFocus(node) {
|
|
11061
|
+
if (node.disabled)
|
|
11062
|
+
return;
|
|
11063
|
+
this.focusedId.set(node.id);
|
|
11064
|
+
queueMicrotask(() => {
|
|
11065
|
+
const el = this.host.nativeElement.querySelector(`li.ct-tree__node[data-tree-id="${cssEscape(node.id)}"]`);
|
|
11066
|
+
el?.focus();
|
|
11067
|
+
});
|
|
11068
|
+
}
|
|
11069
|
+
/** Programmatically focus the tree row matching `id`. */
|
|
11070
|
+
focusNode(id) {
|
|
11071
|
+
const node = this.findNode(id);
|
|
11072
|
+
if (node)
|
|
11073
|
+
this.moveFocus(node);
|
|
11074
|
+
}
|
|
11075
|
+
/** Walk the tree until a node with the given id is found. */
|
|
11076
|
+
findNode(id, list = this.nodes()) {
|
|
11077
|
+
for (const n of list) {
|
|
11078
|
+
if (n.id === id)
|
|
11079
|
+
return n;
|
|
11080
|
+
const hit = n.children ? this.findNode(id, n.children) : null;
|
|
11081
|
+
if (hit)
|
|
11082
|
+
return hit;
|
|
11083
|
+
}
|
|
11084
|
+
return null;
|
|
11085
|
+
}
|
|
11086
|
+
/** Locate the parent of `id` — returns null when the node is at root level. */
|
|
11087
|
+
findParent(id, list = this.nodes()) {
|
|
11088
|
+
for (const n of list) {
|
|
11089
|
+
if (n.children?.some((c) => c.id === id))
|
|
11090
|
+
return n;
|
|
11091
|
+
if (n.children) {
|
|
11092
|
+
const hit = this.findParent(id, n.children);
|
|
11093
|
+
if (hit)
|
|
11094
|
+
return hit;
|
|
11095
|
+
}
|
|
11096
|
+
}
|
|
11097
|
+
return null;
|
|
11098
|
+
}
|
|
11099
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfTreeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
11100
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfTreeComponent, isStandalone: true, selector: "af-tree", inputs: { nodes: { classPropertyName: "nodes", publicName: "nodes", isSignal: true, isRequired: true, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: true, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, expandedIds: { classPropertyName: "expandedIds", publicName: "expandedIds", isSignal: true, isRequired: false, transformFunction: null }, selectedIds: { classPropertyName: "selectedIds", publicName: "selectedIds", isSignal: true, isRequired: false, transformFunction: null }, filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null }, showIndentGuides: { classPropertyName: "showIndentGuides", publicName: "showIndentGuides", isSignal: true, isRequired: false, transformFunction: null }, dense: { classPropertyName: "dense", publicName: "dense", isSignal: true, isRequired: false, transformFunction: null }, bordered: { classPropertyName: "bordered", publicName: "bordered", isSignal: true, isRequired: false, transformFunction: null }, trackBy: { classPropertyName: "trackBy", publicName: "trackBy", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { expandedIds: "expandedIdsChange", selectedIds: "selectedIdsChange", nodeActivate: "nodeActivate", nodeToggle: "nodeToggle", nodeFocus: "nodeFocus", loadChildren: "loadChildren" }, host: { listeners: { "keydown": "handleKeydown($event)", "click": "handleClick($event)" }, properties: { "class.af-tree": "true" } }, queries: [{ propertyName: "nodeContent", first: true, predicate: ["nodeContent"], descendants: true, isSignal: true }, { propertyName: "nodeActions", first: true, predicate: ["nodeActions"], descendants: true, isSignal: true }, { propertyName: "nodeWarning", first: true, predicate: ["nodeWarning"], descendants: true, isSignal: true }, { propertyName: "emptySlot", first: true, predicate: ["empty"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
11101
|
+
@if (visibleNodes().length > 0) {
|
|
11102
|
+
<ul
|
|
11103
|
+
[class]="treeClasses()"
|
|
11104
|
+
role="tree"
|
|
11105
|
+
[attr.aria-label]="ariaLabel()"
|
|
11106
|
+
[attr.aria-multiselectable]="selection() === 'multiple' ? 'true' : null">
|
|
11107
|
+
@for (
|
|
11108
|
+
node of visibleNodes();
|
|
11109
|
+
track trackBy()(node);
|
|
11110
|
+
let i = $index, count = $count
|
|
11111
|
+
) {
|
|
11112
|
+
<af-tree-node
|
|
11113
|
+
[node]="node"
|
|
11114
|
+
[level]="1"
|
|
11115
|
+
[setSize]="count"
|
|
11116
|
+
[posInSet]="i + 1"></af-tree-node>
|
|
11117
|
+
}
|
|
11118
|
+
</ul>
|
|
11119
|
+
} @else {
|
|
11120
|
+
<div class="af-tree__empty" role="status">
|
|
11121
|
+
@if (emptySlot(); as tpl) {
|
|
11122
|
+
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
|
|
11123
|
+
} @else {
|
|
11124
|
+
<span>{{ i18n.emptyMessage }}</span>
|
|
11125
|
+
}
|
|
11126
|
+
</div>
|
|
11127
|
+
}
|
|
11128
|
+
`, isInline: true, styles: [":host{display:block}.af-tree__empty{padding:var(--space-4, 1rem);color:var(--color-text-muted, currentColor);font-size:var(--font-size-sm, .875rem)}\n"], dependencies: [{ kind: "component", type: AfTreeNodeComponent, selector: "af-tree-node", inputs: ["node", "level", "setSize", "posInSet"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
11129
|
+
}
|
|
11130
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfTreeComponent, decorators: [{
|
|
11131
|
+
type: Component,
|
|
11132
|
+
args: [{ selector: 'af-tree', standalone: true, imports: [AfTreeNodeComponent, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, host: {
|
|
11133
|
+
'[class.af-tree]': 'true',
|
|
11134
|
+
'(keydown)': 'handleKeydown($event)',
|
|
11135
|
+
'(click)': 'handleClick($event)',
|
|
11136
|
+
}, template: `
|
|
11137
|
+
@if (visibleNodes().length > 0) {
|
|
11138
|
+
<ul
|
|
11139
|
+
[class]="treeClasses()"
|
|
11140
|
+
role="tree"
|
|
11141
|
+
[attr.aria-label]="ariaLabel()"
|
|
11142
|
+
[attr.aria-multiselectable]="selection() === 'multiple' ? 'true' : null">
|
|
11143
|
+
@for (
|
|
11144
|
+
node of visibleNodes();
|
|
11145
|
+
track trackBy()(node);
|
|
11146
|
+
let i = $index, count = $count
|
|
11147
|
+
) {
|
|
11148
|
+
<af-tree-node
|
|
11149
|
+
[node]="node"
|
|
11150
|
+
[level]="1"
|
|
11151
|
+
[setSize]="count"
|
|
11152
|
+
[posInSet]="i + 1"></af-tree-node>
|
|
11153
|
+
}
|
|
11154
|
+
</ul>
|
|
11155
|
+
} @else {
|
|
11156
|
+
<div class="af-tree__empty" role="status">
|
|
11157
|
+
@if (emptySlot(); as tpl) {
|
|
11158
|
+
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
|
|
11159
|
+
} @else {
|
|
11160
|
+
<span>{{ i18n.emptyMessage }}</span>
|
|
11161
|
+
}
|
|
11162
|
+
</div>
|
|
11163
|
+
}
|
|
11164
|
+
`, styles: [":host{display:block}.af-tree__empty{padding:var(--space-4, 1rem);color:var(--color-text-muted, currentColor);font-size:var(--font-size-sm, .875rem)}\n"] }]
|
|
11165
|
+
}], ctorParameters: () => [], propDecorators: { nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodes", required: true }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: true }] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }], expandedIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "expandedIds", required: false }] }, { type: i0.Output, args: ["expandedIdsChange"] }], selectedIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedIds", required: false }] }, { type: i0.Output, args: ["selectedIdsChange"] }], filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }], showIndentGuides: [{ type: i0.Input, args: [{ isSignal: true, alias: "showIndentGuides", required: false }] }], dense: [{ type: i0.Input, args: [{ isSignal: true, alias: "dense", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], trackBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "trackBy", required: false }] }], nodeActivate: [{ type: i0.Output, args: ["nodeActivate"] }], nodeToggle: [{ type: i0.Output, args: ["nodeToggle"] }], nodeFocus: [{ type: i0.Output, args: ["nodeFocus"] }], loadChildren: [{ type: i0.Output, args: ["loadChildren"] }], nodeContent: [{ type: i0.ContentChild, args: ['nodeContent', { isSignal: true }] }], nodeActions: [{ type: i0.ContentChild, args: ['nodeActions', { isSignal: true }] }], nodeWarning: [{ type: i0.ContentChild, args: ['nodeWarning', { isSignal: true }] }], emptySlot: [{ type: i0.ContentChild, args: ['empty', { isSignal: true }] }] } });
|
|
11166
|
+
/** Minimal HTML escaper for the default label renderer. */
|
|
11167
|
+
function escapeHtml(value) {
|
|
11168
|
+
return value
|
|
11169
|
+
.replace(/&/g, '&')
|
|
11170
|
+
.replace(/</g, '<')
|
|
11171
|
+
.replace(/>/g, '>')
|
|
11172
|
+
.replace(/"/g, '"')
|
|
11173
|
+
.replace(/'/g, ''');
|
|
11174
|
+
}
|
|
11175
|
+
/** Escape an arbitrary id for safe use inside a `[data-tree-id="…"]` attribute selector. */
|
|
11176
|
+
function cssEscape(value) {
|
|
11177
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
11178
|
+
return CSS.escape(value);
|
|
11179
|
+
}
|
|
11180
|
+
return value.replace(/(["\\])/g, '\\$1');
|
|
11181
|
+
}
|
|
11182
|
+
|
|
11183
|
+
/**
|
|
11184
|
+
* Test harness for {@link AfTreeComponent}.
|
|
11185
|
+
*
|
|
11186
|
+
* Wraps the rendered DOM behind a semantic API so specs and host apps can
|
|
11187
|
+
* navigate the tree without coupling to internal CSS class names.
|
|
11188
|
+
*
|
|
11189
|
+
* @example
|
|
11190
|
+
* const harness = new AfTreeHarness(fixture.nativeElement);
|
|
11191
|
+
* harness.getNode('root').focus();
|
|
11192
|
+
* harness.pressKey('ArrowDown');
|
|
11193
|
+
* expect(harness.focusedId()).toBe('child-1');
|
|
11194
|
+
*/
|
|
11195
|
+
class AfTreeHarness {
|
|
11196
|
+
hostEl;
|
|
11197
|
+
constructor(container) {
|
|
11198
|
+
const el = container.querySelector('af-tree');
|
|
11199
|
+
if (!el) {
|
|
11200
|
+
throw new Error('AfTreeHarness: af-tree element not found in container.');
|
|
11201
|
+
}
|
|
11202
|
+
this.hostEl = el;
|
|
11203
|
+
}
|
|
11204
|
+
/** Container `<ul role="tree">` element. */
|
|
11205
|
+
getRootElement() {
|
|
11206
|
+
return this.hostEl.querySelector(':scope > ul.ct-tree');
|
|
11207
|
+
}
|
|
11208
|
+
/** Returns harnesses for every visible (rendered) treeitem in document order. */
|
|
11209
|
+
getVisibleNodes() {
|
|
11210
|
+
const lis = Array.from(this.hostEl.querySelectorAll('li.ct-tree__node'));
|
|
11211
|
+
return lis.map((li) => new AfTreeNodeHarness(li));
|
|
11212
|
+
}
|
|
11213
|
+
/** Returns the harness for the node with the given id, or null when not rendered. */
|
|
11214
|
+
getNode(id) {
|
|
11215
|
+
const li = this.hostEl.querySelector(`li.ct-tree__node[data-tree-id="${cssAttrEscape(id)}"]`);
|
|
11216
|
+
return li ? new AfTreeNodeHarness(li) : null;
|
|
11217
|
+
}
|
|
11218
|
+
/** Id of the currently focused node (the one whose `<li>` carries `tabindex=0`). */
|
|
11219
|
+
focusedId() {
|
|
11220
|
+
const li = this.hostEl.querySelector('li.ct-tree__node[tabindex="0"]');
|
|
11221
|
+
return li?.getAttribute('data-tree-id') ?? null;
|
|
11222
|
+
}
|
|
11223
|
+
/** Dispatches a keydown on the focused row (or first row if none focused). */
|
|
11224
|
+
pressKey(key) {
|
|
11225
|
+
const target = this.hostEl.querySelector('li.ct-tree__node[tabindex="0"]') ??
|
|
11226
|
+
this.hostEl.querySelector('li.ct-tree__node');
|
|
11227
|
+
target?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
11228
|
+
}
|
|
11229
|
+
/** Returns whether `aria-multiselectable` is set on the root list. */
|
|
11230
|
+
isMultiselectable() {
|
|
11231
|
+
return this.getRootElement()?.getAttribute('aria-multiselectable') === 'true';
|
|
11232
|
+
}
|
|
11233
|
+
/** Returns the `aria-label` of the root list. */
|
|
11234
|
+
getAriaLabel() {
|
|
11235
|
+
return this.getRootElement()?.getAttribute('aria-label') ?? null;
|
|
11236
|
+
}
|
|
11237
|
+
/** True when the empty-state is rendered (no rows visible). */
|
|
11238
|
+
isEmpty() {
|
|
11239
|
+
return !this.getRootElement();
|
|
11240
|
+
}
|
|
11241
|
+
}
|
|
11242
|
+
/** Test harness for a single `<li role="treeitem">` rendered by `af-tree`. */
|
|
11243
|
+
class AfTreeNodeHarness {
|
|
11244
|
+
liEl;
|
|
11245
|
+
constructor(liEl) {
|
|
11246
|
+
this.liEl = liEl;
|
|
11247
|
+
}
|
|
11248
|
+
/** Stable id of the node (mirrors `TreeNode.id`). */
|
|
11249
|
+
getId() {
|
|
11250
|
+
return this.liEl.getAttribute('data-tree-id') ?? '';
|
|
11251
|
+
}
|
|
11252
|
+
/** Trimmed label text of the node. */
|
|
11253
|
+
getLabel() {
|
|
11254
|
+
const content = this.liEl.querySelector(':scope > .ct-tree__row .ct-tree__content');
|
|
11255
|
+
return (content?.textContent ?? '').trim();
|
|
11256
|
+
}
|
|
11257
|
+
/** 1-based depth of the node. */
|
|
11258
|
+
getLevel() {
|
|
11259
|
+
return Number(this.liEl.getAttribute('aria-level') ?? '0');
|
|
11260
|
+
}
|
|
11261
|
+
isExpanded() {
|
|
11262
|
+
return this.liEl.getAttribute('aria-expanded') === 'true';
|
|
11263
|
+
}
|
|
11264
|
+
isExpandable() {
|
|
11265
|
+
return this.liEl.hasAttribute('aria-expanded');
|
|
11266
|
+
}
|
|
11267
|
+
isSelected() {
|
|
11268
|
+
return this.liEl.getAttribute('aria-selected') === 'true';
|
|
11269
|
+
}
|
|
11270
|
+
isDisabled() {
|
|
11271
|
+
return this.liEl.getAttribute('aria-disabled') === 'true';
|
|
11272
|
+
}
|
|
11273
|
+
isBusy() {
|
|
11274
|
+
return this.liEl.getAttribute('aria-busy') === 'true';
|
|
11275
|
+
}
|
|
11276
|
+
isFocused() {
|
|
11277
|
+
return this.liEl.getAttribute('tabindex') === '0';
|
|
11278
|
+
}
|
|
11279
|
+
/** Programmatically focus the row's `<li>` element. */
|
|
11280
|
+
focus() {
|
|
11281
|
+
this.liEl.focus();
|
|
11282
|
+
}
|
|
11283
|
+
/** Click the row (centre area, not the toggle / actions). */
|
|
11284
|
+
clickRow() {
|
|
11285
|
+
const row = this.liEl.querySelector(':scope > .ct-tree__row .ct-tree__content');
|
|
11286
|
+
row?.click();
|
|
11287
|
+
}
|
|
11288
|
+
/** Click the chevron toggle button. */
|
|
11289
|
+
clickToggle() {
|
|
11290
|
+
const toggle = this.liEl.querySelector(':scope > .ct-tree__row .ct-tree__toggle');
|
|
11291
|
+
toggle?.click();
|
|
11292
|
+
}
|
|
11293
|
+
/** Return the underlying `<li>` element for advanced assertions. */
|
|
11294
|
+
getElement() {
|
|
11295
|
+
return this.liEl;
|
|
11296
|
+
}
|
|
11297
|
+
}
|
|
11298
|
+
/** Escape characters that would break a `[attr="…"]` selector. */
|
|
11299
|
+
function cssAttrEscape(value) {
|
|
11300
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
11301
|
+
return CSS.escape(value);
|
|
11302
|
+
}
|
|
11303
|
+
return value.replace(/(["\\])/g, '\\$1');
|
|
11304
|
+
}
|
|
11305
|
+
|
|
10097
11306
|
// Components
|
|
10098
11307
|
|
|
10099
11308
|
/**
|
|
@@ -10132,5 +11341,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
|
|
|
10132
11341
|
* Generated bundle index. Do not edit.
|
|
10133
11342
|
*/
|
|
10134
11343
|
|
|
10135
|
-
export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_INPUT_I18N, AF_SELECT_MENU_I18N, AfAccordionComponent, AfAccordionHarness, AfAccordionItemComponent, AfAccordionItemHarness, AfAlertComponent, AfAlertHarness, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBadgeHarness, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfButtonHarness, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfInputHarness, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectMenuComponent, AfSelectMenuHarness, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective };
|
|
11344
|
+
export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_INPUT_I18N, AF_SELECT_I18N, AF_SELECT_MENU_I18N, AF_TREE_I18N, AVATAR_SEED_PALETTE_SIZE, AfAccordionComponent, AfAccordionHarness, AfAccordionItemComponent, AfAccordionItemHarness, AfAlertComponent, AfAlertHarness, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBadgeHarness, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfButtonHarness, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfInputHarness, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectHarness, AfSelectMenuComponent, AfSelectMenuHarness, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective, AfTreeComponent, AfTreeHarness, AfTreeNodeHarness };
|
|
10136
11345
|
//# sourceMappingURL=neuravision-ng-construct.mjs.map
|