@neuravision/ng-construct 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 [class]="avatarClasses()" role="img" [attr.aria-label]="ariaLabel()">
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 [class]="avatarClasses()" role="img" [attr.aria-label]="ariaLabel()">
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
- * Select dropdown component with form control support
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
- * ></af-select>
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
- /** Select label */
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
- /** Options array */
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 select is required */
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
- /** Value comparison function (for object values) */
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 = null;
1762
- onChangeCallback = () => { };
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
- getAriaDescribedBy() {
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
- get isPlaceholderSelected() {
1882
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
1883
+ isPlaceholderSelected = computed(() => {
1774
1884
  if (!this.placeholder())
1775
1885
  return false;
1776
- if (this.value === null || this.value === undefined || this.value === '')
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
- onChange(event) {
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 = null;
1794
- this.onChangeCallback(null);
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 = nextValue;
1800
- this.onChangeCallback(nextValue);
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
- /** ControlValueAccessor implementation */
1915
+ /** @docs-private */
1803
1916
  writeValue(value) {
1804
- this.value = value ?? null;
1917
+ this.value.set(value ?? null);
1805
1918
  }
1919
+ /** @docs-private */
1806
1920
  registerOnChange(fn) {
1807
- this.onChangeCallback = fn;
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
- [id]="selectId()"
1835
- class="ct-select"
1836
- [disabled]="disabled()"
1837
- [required]="required()"
1838
- [attr.aria-invalid]="error() ? true : null"
1839
- [attr.aria-describedby]="getAriaDescribedBy()"
1840
- (change)="onChange($event)"
1841
- (blur)="onTouched()"
1842
- >
1843
- @if (placeholder()) {
1844
- <option value="" [disabled]="true" [selected]="isPlaceholderSelected">
1845
- {{ placeholder() }}
1846
- </option>
1847
- }
1848
- @for (option of options(); track option.value) {
1849
- <option
1850
- [selected]="isOptionSelected(option)"
1851
- [disabled]="option.disabled || false"
1852
- >
1853
- {{ option.label }}
1854
- </option>
1855
- }
1856
- </select>
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, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
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: [{ selector: 'af-select', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
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
- ], template: `
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
- [id]="selectId()"
1893
- class="ct-select"
1894
- [disabled]="disabled()"
1895
- [required]="required()"
1896
- [attr.aria-invalid]="error() ? true : null"
1897
- [attr.aria-describedby]="getAriaDescribedBy()"
1898
- (change)="onChange($event)"
1899
- (blur)="onTouched()"
1900
- >
1901
- @if (placeholder()) {
1902
- <option value="" [disabled]="true" [selected]="isPlaceholderSelected">
1903
- {{ placeholder() }}
1904
- </option>
1905
- }
1906
- @for (option of options(); track option.value) {
1907
- <option
1908
- [selected]="isOptionSelected(option)"
1909
- [disabled]="option.disabled || false"
1910
- >
1911
- {{ option.label }}
1912
- </option>
1913
- }
1914
- </select>
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
- `, styles: [":host{display:block}\n"] }]
1929
- }], 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"] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], selectId: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectId", required: false }] }] } });
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
@@ -10132,5 +10392,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
10132
10392
  * Generated bundle index. Do not edit.
10133
10393
  */
10134
10394
 
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 };
10395
+ export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_INPUT_I18N, AF_SELECT_I18N, AF_SELECT_MENU_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 };
10136
10396
  //# sourceMappingURL=neuravision-ng-construct.mjs.map