@neuravision/ng-construct 0.4.2 → 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.
@@ -1,9 +1,18 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, output, signal, computed, ChangeDetectionStrategy, Component, inject, forwardRef, model, booleanAttribute, viewChild, contentChildren, effect, DOCUMENT, contentChild, ElementRef, TemplateRef, Directive, Injectable, viewChildren, Renderer2, InjectionToken, numberAttribute, Pipe } from '@angular/core';
3
- import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
- import { NgTemplateOutlet } from '@angular/common';
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';
3
+ import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
4
+ import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
5
5
  import { RouterLink, RouterLinkActive } from '@angular/router';
6
6
 
7
+ /**
8
+ * Injection token to provide custom i18n strings for {@link AfAlertComponent}.
9
+ *
10
+ * @example
11
+ * providers: [{ provide: AF_ALERT_I18N, useValue: { dismiss: 'Schliessen', dismissed: 'Warnung geschlossen' } }]
12
+ */
13
+ const AF_ALERT_I18N = new InjectionToken('AF_ALERT_I18N', {
14
+ factory: () => ({ dismiss: 'Dismiss alert', dismissed: 'Alert dismissed' }),
15
+ });
7
16
  /**
8
17
  * Alert component for displaying contextual feedback messages.
9
18
  *
@@ -11,6 +20,14 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
11
20
  * - `danger` / `warning` → `role="alert"` (assertive announcement)
12
21
  * - `info` / `success` → `role="status"` (polite announcement)
13
22
  *
23
+ * ### Accessibility
24
+ * - The dismiss button is keyboard-accessible via Tab, activated by Enter/Space (native `<button>`)
25
+ * - When dismissed, a screen-reader announcement is made via an `aria-live="polite"` region
26
+ * - The icon slot is marked `aria-hidden="true"` to prevent redundant announcements
27
+ * - The `aria-label` on the dismiss button is configurable via {@link AF_ALERT_I18N} for i18n
28
+ * - Supports `forced-colors` (Windows High Contrast) mode
29
+ * - Uses CSS logical properties for RTL layout support
30
+ *
14
31
  * @example
15
32
  * <af-alert variant="warning" [dismissible]="true" (dismissed)="onDismiss()">
16
33
  * <span icon>⚠</span>
@@ -22,14 +39,17 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
22
39
  * </af-alert>
23
40
  */
24
41
  class AfAlertComponent {
25
- /** Color variant determining the alert's visual style and ARIA role */
42
+ i18n = inject(AF_ALERT_I18N);
43
+ /** Color variant determining the alert's visual style and ARIA role. */
26
44
  variant = input('info', ...(ngDevMode ? [{ debugName: "variant" }] : []));
27
- /** Whether the alert can be dismissed by the user */
45
+ /** Whether the alert can be dismissed by the user. */
28
46
  dismissible = input(false, ...(ngDevMode ? [{ debugName: "dismissible" }] : []));
29
- /** Emits when the user dismisses the alert */
47
+ /** Emits when the user dismisses the alert. */
30
48
  dismissed = output();
31
- /** Controls alert visibility */
49
+ /** Controls alert visibility. */
32
50
  visible = signal(true, ...(ngDevMode ? [{ debugName: "visible" }] : []));
51
+ /** @internal Screen-reader announcement text. */
52
+ liveAnnouncement = signal('', ...(ngDevMode ? [{ debugName: "liveAnnouncement" }] : []));
33
53
  alertClasses = computed(() => {
34
54
  const classes = ['ct-alert'];
35
55
  if (this.dismissible()) {
@@ -46,10 +66,12 @@ class AfAlertComponent {
46
66
  const v = this.variant();
47
67
  return v === 'danger' || v === 'warning' ? 'alert' : 'status';
48
68
  }, ...(ngDevMode ? [{ debugName: "alertRole" }] : []));
49
- /** Hides the alert and emits the dismissed event */
69
+ /** Hides the alert and emits the dismissed event. */
50
70
  dismiss() {
51
71
  this.visible.set(false);
52
72
  this.dismissed.emit();
73
+ this.liveAnnouncement.set('');
74
+ setTimeout(() => this.liveAnnouncement.set(this.i18n.dismissed));
53
75
  }
54
76
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAlertComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
55
77
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfAlertComponent, isStandalone: true, selector: "af-alert", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, dismissible: { classPropertyName: "dismissible", publicName: "dismissible", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismissed: "dismissed" }, ngImport: i0, template: `
@@ -76,14 +98,17 @@ class AfAlertComponent {
76
98
  <button
77
99
  type="button"
78
100
  class="af-alert__dismiss"
79
- aria-label="Dismiss alert"
101
+ [attr.aria-label]="i18n.dismiss"
80
102
  (click)="dismiss()">
81
103
  <span class="ct-icon ct-icon--sm" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
82
104
  </button>
83
105
  }
84
106
  </div>
85
107
  }
86
- `, isInline: true, styles: [":host{display:contents}.ct-alert--dismissible{position:relative;padding-right:2.5rem}.af-alert__dismiss{position:absolute;top:.625rem;right:.625rem;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;padding:0;border:none;background:none;border-radius:var(--radius-sm, .25rem);color:var(--color-text-secondary);font-size:1.25rem;line-height:1;cursor:pointer}.af-alert__dismiss:hover{color:var(--color-text-primary)}.af-alert__dismiss:focus-visible{outline:2px solid var(--ct-alert-accent, var(--color-brand-primary));outline-offset:2px}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
108
+ <span class="af-alert__sr-only" aria-live="polite" aria-atomic="true">
109
+ {{ liveAnnouncement() }}
110
+ </span>
111
+ `, isInline: true, styles: [":host{display:contents}.ct-alert--dismissible{position:relative;padding-inline-end:2.5rem}.af-alert__dismiss{position:absolute;inset-block-start:.625rem;inset-inline-end:.625rem;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;min-width:24px;min-height:24px;padding:0;border:none;background:none;border-radius:var(--radius-sm, .25rem);color:var(--color-text-secondary);font-size:1.25rem;line-height:1;cursor:pointer}.af-alert__dismiss:hover{color:var(--color-text-primary)}.af-alert__dismiss:focus-visible{outline:2px solid var(--ct-alert-accent, var(--color-brand-primary));outline-offset:2px}@media(forced-colors:active){.af-alert__dismiss{border:1px solid ButtonText}.af-alert__dismiss:focus-visible{outline-color:Highlight}}.af-alert__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
87
112
  }
88
113
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAlertComponent, decorators: [{
89
114
  type: Component,
@@ -111,16 +136,183 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
111
136
  <button
112
137
  type="button"
113
138
  class="af-alert__dismiss"
114
- aria-label="Dismiss alert"
139
+ [attr.aria-label]="i18n.dismiss"
115
140
  (click)="dismiss()">
116
141
  <span class="ct-icon ct-icon--sm" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
117
142
  </button>
118
143
  }
119
144
  </div>
120
145
  }
121
- `, styles: [":host{display:contents}.ct-alert--dismissible{position:relative;padding-right:2.5rem}.af-alert__dismiss{position:absolute;top:.625rem;right:.625rem;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;padding:0;border:none;background:none;border-radius:var(--radius-sm, .25rem);color:var(--color-text-secondary);font-size:1.25rem;line-height:1;cursor:pointer}.af-alert__dismiss:hover{color:var(--color-text-primary)}.af-alert__dismiss:focus-visible{outline:2px solid var(--ct-alert-accent, var(--color-brand-primary));outline-offset:2px}\n"] }]
146
+ <span class="af-alert__sr-only" aria-live="polite" aria-atomic="true">
147
+ {{ liveAnnouncement() }}
148
+ </span>
149
+ `, styles: [":host{display:contents}.ct-alert--dismissible{position:relative;padding-inline-end:2.5rem}.af-alert__dismiss{position:absolute;inset-block-start:.625rem;inset-inline-end:.625rem;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;min-width:24px;min-height:24px;padding:0;border:none;background:none;border-radius:var(--radius-sm, .25rem);color:var(--color-text-secondary);font-size:1.25rem;line-height:1;cursor:pointer}.af-alert__dismiss:hover{color:var(--color-text-primary)}.af-alert__dismiss:focus-visible{outline:2px solid var(--ct-alert-accent, var(--color-brand-primary));outline-offset:2px}@media(forced-colors:active){.af-alert__dismiss{border:1px solid ButtonText}.af-alert__dismiss:focus-visible{outline-color:Highlight}}.af-alert__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"] }]
122
150
  }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], dismissible: [{ type: i0.Input, args: [{ isSignal: true, alias: "dismissible", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
123
151
 
152
+ /**
153
+ * Test harness for AfAlertComponent.
154
+ *
155
+ * Provides a semantic API for interacting with the alert in tests,
156
+ * abstracting DOM details behind readable method names.
157
+ *
158
+ * @example
159
+ * const harness = new AfAlertHarness(fixture.nativeElement);
160
+ * expect(harness.getVariant()).toBe('info');
161
+ * expect(harness.getRole()).toBe('status');
162
+ * harness.dismiss();
163
+ */
164
+ class AfAlertHarness {
165
+ hostEl;
166
+ constructor(container) {
167
+ const el = container.querySelector('af-alert');
168
+ if (!el) {
169
+ throw new Error('AfAlertHarness: af-alert element not found in container.');
170
+ }
171
+ this.hostEl = el;
172
+ }
173
+ /** Returns the inner `.ct-alert` wrapper element, or `null` if the alert is not visible. */
174
+ getAlertElement() {
175
+ return this.hostEl.querySelector('.ct-alert');
176
+ }
177
+ /** Returns the `data-variant` attribute value. Throws if the alert is not visible. */
178
+ getVariant() {
179
+ const el = this.requireVisible();
180
+ return el.getAttribute('data-variant') ?? '';
181
+ }
182
+ /** Returns the ARIA `role` attribute value. Throws if the alert is not visible. */
183
+ getRole() {
184
+ const el = this.requireVisible();
185
+ return el.getAttribute('role') ?? '';
186
+ }
187
+ /** Returns the full trimmed text content of the alert. Throws if not visible. */
188
+ getText() {
189
+ const el = this.requireVisible();
190
+ return el.textContent?.trim() ?? '';
191
+ }
192
+ /** Returns the trimmed text content of the title slot. Throws if not visible. */
193
+ getTitle() {
194
+ const el = this.requireVisible();
195
+ const title = el.querySelector('.ct-alert__title');
196
+ return title?.textContent?.trim() ?? '';
197
+ }
198
+ /** Returns whether the alert is currently visible in the DOM. */
199
+ isVisible() {
200
+ return this.getAlertElement() !== null;
201
+ }
202
+ /** Returns whether the alert has a dismiss button. Throws if not visible. */
203
+ isDismissible() {
204
+ const el = this.requireVisible();
205
+ return el.querySelector('.af-alert__dismiss') !== null;
206
+ }
207
+ /** Clicks the dismiss button. Throws if the alert is not visible or not dismissible. */
208
+ dismiss() {
209
+ const btn = this.getDismissButton();
210
+ if (!btn) {
211
+ throw new Error('AfAlertHarness: dismiss button not found. Is the alert dismissible?');
212
+ }
213
+ btn.click();
214
+ }
215
+ /** Returns the dismiss button element, or `null` if not present. */
216
+ getDismissButton() {
217
+ return (this.getAlertElement()?.querySelector('.af-alert__dismiss') ?? null);
218
+ }
219
+ /** Returns the `aria-live` region element for screen-reader announcements. */
220
+ getLiveRegion() {
221
+ return this.hostEl.querySelector('[aria-live="polite"]');
222
+ }
223
+ requireVisible() {
224
+ const el = this.getAlertElement();
225
+ if (!el) {
226
+ throw new Error('AfAlertHarness: alert is not visible.');
227
+ }
228
+ return el;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Lightweight service that announces messages to screen readers
234
+ * via an `aria-live` region. No `@angular/cdk` dependency required.
235
+ *
236
+ * @example
237
+ * private announcer = inject(AriaLiveAnnouncer);
238
+ * this.announcer.announce('Section expanded');
239
+ */
240
+ class AriaLiveAnnouncer {
241
+ document = inject(DOCUMENT);
242
+ liveEl = null;
243
+ clearTimer = null;
244
+ /** Announce a message to screen readers. */
245
+ announce(message, politeness = 'polite') {
246
+ const el = this.getOrCreateLiveElement();
247
+ el.setAttribute('aria-live', politeness);
248
+ // Clear then re-set so assistive tech registers the change.
249
+ el.textContent = '';
250
+ if (this.clearTimer) {
251
+ clearTimeout(this.clearTimer);
252
+ }
253
+ // Small delay so the DOM mutation is picked up as a new announcement.
254
+ setTimeout(() => {
255
+ el.textContent = message;
256
+ }, 50);
257
+ // Auto-clear after 3 seconds to avoid stale text.
258
+ this.clearTimer = setTimeout(() => {
259
+ el.textContent = '';
260
+ }, 3000);
261
+ }
262
+ ngOnDestroy() {
263
+ if (this.clearTimer) {
264
+ clearTimeout(this.clearTimer);
265
+ }
266
+ this.liveEl?.remove();
267
+ this.liveEl = null;
268
+ }
269
+ getOrCreateLiveElement() {
270
+ if (this.liveEl)
271
+ return this.liveEl;
272
+ const el = this.document.createElement('div');
273
+ el.setAttribute('aria-live', 'polite');
274
+ el.setAttribute('aria-atomic', 'true');
275
+ el.classList.add('cdk-visually-hidden');
276
+ // Visually hidden but accessible to screen readers.
277
+ Object.assign(el.style, {
278
+ position: 'absolute',
279
+ width: '1px',
280
+ height: '1px',
281
+ padding: '0',
282
+ margin: '-1px',
283
+ overflow: 'hidden',
284
+ clip: 'rect(0, 0, 0, 0)',
285
+ whiteSpace: 'nowrap',
286
+ border: '0',
287
+ });
288
+ this.document.body.appendChild(el);
289
+ this.liveEl = el;
290
+ return el;
291
+ }
292
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AriaLiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
293
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AriaLiveAnnouncer, providedIn: 'root' });
294
+ }
295
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AriaLiveAnnouncer, decorators: [{
296
+ type: Injectable,
297
+ args: [{ providedIn: 'root' }]
298
+ }] });
299
+
300
+ /**
301
+ * Injection token to override accordion screen-reader announcements.
302
+ *
303
+ * @example
304
+ * providers: [{
305
+ * provide: AF_ACCORDION_I18N,
306
+ * useValue: { expanded: '{heading} ausgeklappt', collapsed: '{heading} eingeklappt' },
307
+ * }]
308
+ */
309
+ const AF_ACCORDION_I18N = new InjectionToken('AfAccordionI18n', {
310
+ factory: () => ({
311
+ expanded: '{heading} expanded',
312
+ collapsed: '{heading} collapsed',
313
+ }),
314
+ });
315
+
124
316
  let nextId$1 = 0;
125
317
  /**
126
318
  * Individual accordion item used within af-accordion.
@@ -133,6 +325,8 @@ let nextId$1 = 0;
133
325
  class AfAccordionItemComponent {
134
326
  itemId = nextId$1++;
135
327
  accordion = inject(forwardRef(() => AfAccordionComponent), { optional: true });
328
+ announcer = inject(AriaLiveAnnouncer);
329
+ i18n = inject(AF_ACCORDION_I18N);
136
330
  /** Heading text displayed in the accordion trigger. */
137
331
  heading = input.required(...(ngDevMode ? [{ debugName: "heading" }] : []));
138
332
  /** Whether this item is expanded (supports two-way binding). */
@@ -165,6 +359,8 @@ class AfAccordionItemComponent {
165
359
  if (willExpand) {
166
360
  this.accordion?.onItemExpanded(this);
167
361
  }
362
+ const template = willExpand ? this.i18n.expanded : this.i18n.collapsed;
363
+ this.announcer.announce(template.replace('{heading}', this.heading()));
168
364
  }
169
365
  /** Programmatically collapse this item. */
170
366
  collapse() {
@@ -354,6 +550,96 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
354
550
  `, styles: [":host{display:block}\n"] }]
355
551
  }], propDecorators: { multi: [{ type: i0.Input, args: [{ isSignal: true, alias: "multi", required: false }] }], bordered: [{ type: i0.Input, args: [{ isSignal: true, alias: "bordered", required: false }] }], items: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => AfAccordionItemComponent), { isSignal: true }] }] } });
356
552
 
553
+ /**
554
+ * Test harness for AfAccordionItemComponent.
555
+ *
556
+ * Provides a semantic API for interacting with a single accordion item,
557
+ * abstracting DOM details behind readable method names.
558
+ */
559
+ class AfAccordionItemHarness {
560
+ hostEl;
561
+ constructor(hostEl) {
562
+ this.hostEl = hostEl;
563
+ }
564
+ /** Returns the heading text. */
565
+ getHeading() {
566
+ const heading = this.hostEl.querySelector('.ct-accordion__heading');
567
+ return heading?.textContent?.trim() ?? '';
568
+ }
569
+ /** Returns whether the item is expanded. */
570
+ isExpanded() {
571
+ return this.getTriggerElement().getAttribute('aria-expanded') === 'true';
572
+ }
573
+ /** Returns whether the item is disabled. */
574
+ isDisabled() {
575
+ return this.getTriggerElement().getAttribute('aria-disabled') === 'true';
576
+ }
577
+ /** Clicks the trigger to toggle the item. */
578
+ toggle() {
579
+ this.getTriggerElement().click();
580
+ }
581
+ /** Returns the trigger (summary) element. */
582
+ getTriggerElement() {
583
+ const summary = this.hostEl.querySelector('summary');
584
+ if (!summary) {
585
+ throw new Error('AfAccordionItemHarness: <summary> element not found.');
586
+ }
587
+ return summary;
588
+ }
589
+ /** Returns the content panel element. */
590
+ getContentElement() {
591
+ const panel = this.hostEl.querySelector('[role="region"]');
592
+ if (!panel) {
593
+ throw new Error('AfAccordionItemHarness: content region not found.');
594
+ }
595
+ return panel;
596
+ }
597
+ /** Returns the `aria-controls` value of the trigger. */
598
+ getAriaControls() {
599
+ return this.getTriggerElement().getAttribute('aria-controls');
600
+ }
601
+ /** Returns the `aria-expanded` value of the trigger. */
602
+ getAriaExpanded() {
603
+ return this.getTriggerElement().getAttribute('aria-expanded');
604
+ }
605
+ }
606
+ /**
607
+ * Test harness for AfAccordionComponent.
608
+ *
609
+ * @example
610
+ * const harness = new AfAccordionHarness(fixture.nativeElement);
611
+ * expect(harness.getItems()).toHaveLength(3);
612
+ * harness.getItem(0).toggle();
613
+ */
614
+ class AfAccordionHarness {
615
+ hostEl;
616
+ constructor(container) {
617
+ const el = container.querySelector('af-accordion');
618
+ if (!el) {
619
+ throw new Error('AfAccordionHarness: af-accordion element not found in container.');
620
+ }
621
+ this.hostEl = el;
622
+ }
623
+ /** Returns harnesses for all accordion items. */
624
+ getItems() {
625
+ const items = Array.from(this.hostEl.querySelectorAll('af-accordion-item'));
626
+ return items.map((el) => new AfAccordionItemHarness(el));
627
+ }
628
+ /** Returns the harness for the accordion item at the given index. */
629
+ getItem(index) {
630
+ const items = this.getItems();
631
+ if (index < 0 || index >= items.length) {
632
+ throw new Error(`AfAccordionHarness: index ${index} out of range (${items.length} items).`);
633
+ }
634
+ return items[index];
635
+ }
636
+ /** Returns whether the bordered variant is applied. */
637
+ isBordered() {
638
+ const wrapper = this.hostEl.querySelector('.ct-accordion');
639
+ return wrapper?.classList.contains('ct-accordion--bordered') ?? false;
640
+ }
641
+ }
642
+
357
643
  /**
358
644
  * Skip-link component for keyboard-only navigation bypass.
359
645
  *
@@ -373,7 +659,7 @@ class AfSkipLinkComponent {
373
659
  target = input.required(...(ngDevMode ? [{ debugName: "target" }] : []));
374
660
  /** Visible label text shown when the link receives focus. */
375
661
  label = input('Skip to main content', ...(ngDevMode ? [{ debugName: "label" }] : []));
376
- document = inject(DOCUMENT);
662
+ document = inject(DOCUMENT$1);
377
663
  /**
378
664
  * Moves focus to the target element so keyboard navigation
379
665
  * continues from there instead of the top of the page.
@@ -812,6 +1098,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
812
1098
  `, styles: [":host{display:contents}\n"] }]
813
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 }] }] } });
814
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;
815
1107
  /**
816
1108
  * Avatar component displaying a user image with fallback to initials.
817
1109
  *
@@ -819,9 +1111,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
819
1111
  * fails to load or no `src` is given, initials derived from `name` are
820
1112
  * shown instead.
821
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
+ *
822
1120
  * @example
823
1121
  * <af-avatar src="/photo.jpg" name="Jane Doe" alt="Jane Doe" size="lg" />
824
1122
  * <af-avatar name="John Smith" status="online" />
1123
+ * <af-avatar name="Jane Doe" colorSeed="user-uuid-7b3e2a4d" />
825
1124
  */
826
1125
  class AfAvatarComponent {
827
1126
  /** Image URL. Falls back to initials when missing or on load error. */
@@ -834,6 +1133,12 @@ class AfAvatarComponent {
834
1133
  alt = input('', ...(ngDevMode ? [{ debugName: "alt" }] : []));
835
1134
  /** Online status indicator. */
836
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" }] : []));
837
1142
  /** Tracks whether the image failed to load. */
838
1143
  imageError = signal(false, ...(ngDevMode ? [{ debugName: "imageError" }] : []));
839
1144
  /** Whether to render the `<img>` element. */
@@ -849,6 +1154,18 @@ class AfAvatarComponent {
849
1154
  }, ...(ngDevMode ? [{ debugName: "initials" }] : []));
850
1155
  /** Accessible label for the avatar. */
851
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" }] : []));
852
1169
  avatarClasses = computed(() => {
853
1170
  const classes = ['ct-avatar'];
854
1171
  if (this.size() !== 'md') {
@@ -860,9 +1177,26 @@ class AfAvatarComponent {
860
1177
  onImageError() {
861
1178
  this.imageError.set(true);
862
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
+ }
863
1193
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAvatarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
864
- 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: `
865
- <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()">
866
1200
  @if (showImage()) {
867
1201
  <img
868
1202
  class="ct-avatar__image"
@@ -886,7 +1220,11 @@ class AfAvatarComponent {
886
1220
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfAvatarComponent, decorators: [{
887
1221
  type: Component,
888
1222
  args: [{ selector: 'af-avatar', changeDetection: ChangeDetectionStrategy.OnPush, template: `
889
- <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()">
890
1228
  @if (showImage()) {
891
1229
  <img
892
1230
  class="ct-avatar__image"
@@ -906,18 +1244,40 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
906
1244
  }
907
1245
  </span>
908
1246
  `, styles: [":host{display:inline-block}\n"] }]
909
- }], 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 }] }] } });
910
1248
 
911
1249
  /**
912
- * Button component from the Construct Design System
1250
+ * Button component from the Construct Design System.
913
1251
  *
914
- * @example
915
- * <af-button variant="primary" (clicked)="handleClick()">Click me</af-button>
1252
+ * Wraps a native `<button>` element with design system tokens and
1253
+ * variant/size modifiers.
1254
+ *
1255
+ * @example Basic usage
1256
+ * <af-button variant="primary" (clicked)="save()">Save</af-button>
916
1257
  *
917
- * @example Icon-only button
1258
+ * @example Variants
1259
+ * <af-button variant="secondary">Secondary</af-button>
1260
+ * <af-button variant="ghost">Ghost</af-button>
1261
+ * <af-button variant="outline">Outline</af-button>
1262
+ * <af-button variant="danger">Danger</af-button>
1263
+ * <af-button variant="accent">Accent</af-button>
1264
+ * <af-button variant="link">Link</af-button>
1265
+ *
1266
+ * @example Icon-only button (ariaLabel required)
918
1267
  * <af-button variant="ghost" size="sm" iconOnly ariaLabel="Delete item">
919
1268
  * <af-icon name="delete" />
920
1269
  * </af-button>
1270
+ *
1271
+ * @example Disabled button
1272
+ * <af-button [disabled]="true">Cannot click</af-button>
1273
+ *
1274
+ * @accessibility
1275
+ * - Uses native `<button>` element — keyboard (Enter/Space) and screen reader support built-in.
1276
+ * - Disabled state uses the native `disabled` attribute.
1277
+ * - Icon-only buttons must provide `ariaLabel` for screen reader users.
1278
+ * A dev-mode warning is emitted if `ariaLabel` is missing on an icon-only button.
1279
+ * - Focus indicator: 2px outline via `:focus-visible` (design system CSS).
1280
+ * - Reduced motion: `transform` animation disabled via `prefers-reduced-motion`.
921
1281
  */
922
1282
  class AfButtonComponent {
923
1283
  /** Button variant/style */
@@ -949,6 +1309,11 @@ class AfButtonComponent {
949
1309
  }
950
1310
  return classes.join(' ');
951
1311
  }, ...(ngDevMode ? [{ debugName: "buttonClasses" }] : []));
1312
+ iconOnlyWarning = effect(() => {
1313
+ if (isDevMode() && this.iconOnly() && !this.ariaLabel()) {
1314
+ console.warn('af-button: iconOnly buttons require an ariaLabel for screen readers.');
1315
+ }
1316
+ }, ...(ngDevMode ? [{ debugName: "iconOnlyWarning" }] : []));
952
1317
  handleClick(event) {
953
1318
  if (!this.disabled()) {
954
1319
  this.clicked.emit(event);
@@ -983,7 +1348,88 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
983
1348
  }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], iconOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconOnly", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], clicked: [{ type: i0.Output, args: ["clicked"] }] } });
984
1349
 
985
1350
  /**
986
- * Input field component with form control support
1351
+ * Test harness for AfButtonComponent.
1352
+ *
1353
+ * Provides a semantic API for interacting with the button in tests,
1354
+ * abstracting DOM details behind readable method names.
1355
+ *
1356
+ * @example
1357
+ * const harness = new AfButtonHarness(fixture.nativeElement);
1358
+ * expect(harness.getText()).toBe('Save');
1359
+ * expect(harness.isDisabled()).toBe(false);
1360
+ * harness.click();
1361
+ */
1362
+ class AfButtonHarness {
1363
+ hostEl;
1364
+ constructor(container) {
1365
+ const el = container.querySelector('af-button');
1366
+ if (!el) {
1367
+ throw new Error('AfButtonHarness: af-button element not found in container.');
1368
+ }
1369
+ this.hostEl = el;
1370
+ }
1371
+ /** Returns the inner native `<button>` element. */
1372
+ getButtonElement() {
1373
+ const btn = this.hostEl.querySelector('button');
1374
+ if (!btn) {
1375
+ throw new Error('AfButtonHarness: inner <button> element not found.');
1376
+ }
1377
+ return btn;
1378
+ }
1379
+ /** Returns the trimmed text content of the button. */
1380
+ getText() {
1381
+ return this.getButtonElement().textContent?.trim() ?? '';
1382
+ }
1383
+ /** Returns whether the button is disabled. */
1384
+ isDisabled() {
1385
+ return this.getButtonElement().disabled;
1386
+ }
1387
+ /** Clicks the inner button element. */
1388
+ click() {
1389
+ this.getButtonElement().click();
1390
+ }
1391
+ /** Returns the `aria-label` attribute value, or `null` if absent. */
1392
+ getAriaLabel() {
1393
+ return this.getButtonElement().getAttribute('aria-label');
1394
+ }
1395
+ /** Returns the `title` attribute value, or `null` if absent. */
1396
+ getTitle() {
1397
+ return this.getButtonElement().getAttribute('title');
1398
+ }
1399
+ /** Returns the `type` attribute of the button. */
1400
+ getType() {
1401
+ return this.getButtonElement().type;
1402
+ }
1403
+ /** Returns the full `class` attribute string of the inner button. */
1404
+ getClasses() {
1405
+ return this.getButtonElement().className;
1406
+ }
1407
+ /** Returns whether the inner button has the given CSS class. */
1408
+ hasClass(className) {
1409
+ return this.getButtonElement().classList.contains(className);
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Injection token to override input screen-reader announcements.
1415
+ *
1416
+ * @example
1417
+ * providers: [{
1418
+ * provide: AF_INPUT_I18N,
1419
+ * useValue: { required: 'Pflichtfeld' },
1420
+ * }]
1421
+ */
1422
+ const AF_INPUT_I18N = new InjectionToken('AfInputI18n', {
1423
+ factory: () => ({
1424
+ required: 'required',
1425
+ }),
1426
+ });
1427
+
1428
+ /**
1429
+ * Input field component with form control support.
1430
+ *
1431
+ * Wraps a native `<input>` element with label, hint, error, and icon slots.
1432
+ * Implements `ControlValueAccessor` for seamless `ngModel` and `formControl` integration.
987
1433
  *
988
1434
  * @example
989
1435
  * <af-input
@@ -992,40 +1438,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
992
1438
  * placeholder="name@company.com"
993
1439
  * [(ngModel)]="email"
994
1440
  * hint="We will not share this."
995
- * ></af-input>
1441
+ * />
996
1442
  *
997
1443
  * @example
998
1444
  * <af-input
999
1445
  * label="Name"
1000
1446
  * [error]="nameError"
1001
1447
  * required
1002
- * ></af-input>
1448
+ * />
1003
1449
  */
1004
1450
  class AfInputComponent {
1005
1451
  static nextId = 0;
1006
- /** Input label */
1452
+ i18n = inject(AF_INPUT_I18N);
1453
+ /** Input label. */
1007
1454
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1008
- /** Input type */
1455
+ /** Input type. */
1009
1456
  type = input('text', ...(ngDevMode ? [{ debugName: "type" }] : []));
1010
- /** Placeholder text */
1457
+ /** Placeholder text. */
1011
1458
  placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1012
- /** Hint text shown below input */
1459
+ /** Hint text shown below input. */
1013
1460
  hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
1014
- /** Error message - shows error state and message */
1461
+ /** Error message shows error state and message. */
1015
1462
  error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
1016
- /** Whether input is required */
1017
- required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
1018
- /** Whether input is disabled */
1463
+ /** Whether input is required. */
1464
+ required = input(false, { ...(ngDevMode ? { debugName: "required" } : {}), transform: booleanAttribute });
1465
+ /** Whether input is disabled. */
1019
1466
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1020
- /** Icon position (if icon content is projected) */
1467
+ /** Icon position (if icon content is projected). */
1021
1468
  iconPosition = input(null, ...(ngDevMode ? [{ debugName: "iconPosition" }] : []));
1022
- /** Unique input ID */
1469
+ /** Unique input ID. */
1023
1470
  inputId = input(`af-input-${AfInputComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "inputId" }] : []));
1024
- value = '';
1471
+ /** @docs-private — internal form value managed by CVA. */
1472
+ value = signal('', ...(ngDevMode ? [{ debugName: "value" }] : []));
1025
1473
  onChange = () => { };
1026
1474
  onTouched = () => { };
1475
+ /** Computed hint element ID. */
1027
1476
  hintId = computed(() => `${this.inputId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
1477
+ /** Computed error element ID. */
1028
1478
  errorId = computed(() => `${this.inputId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
1479
+ /** Computed CSS classes for the inner input. */
1029
1480
  inputClasses = computed(() => {
1030
1481
  const classes = ['ct-input'];
1031
1482
  if (this.iconPosition()) {
@@ -1033,45 +1484,49 @@ class AfInputComponent {
1033
1484
  }
1034
1485
  return classes.join(' ');
1035
1486
  }, ...(ngDevMode ? [{ debugName: "inputClasses" }] : []));
1036
- getAriaDescribedBy() {
1487
+ /** Computed `aria-describedby` value linking to hint or error. */
1488
+ ariaDescribedBy = computed(() => {
1037
1489
  if (this.error())
1038
1490
  return this.errorId();
1039
1491
  if (this.hint())
1040
1492
  return this.hintId();
1041
1493
  return null;
1042
- }
1494
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
1043
1495
  onInput(event) {
1044
1496
  const target = event.target;
1045
- this.value = target.value;
1046
- this.onChange(this.value);
1497
+ this.value.set(target.value);
1498
+ this.onChange(this.value());
1047
1499
  }
1048
- /** ControlValueAccessor implementation */
1500
+ /** @docs-private */
1049
1501
  writeValue(value) {
1050
- this.value = value || '';
1502
+ this.value.set(value || '');
1051
1503
  }
1504
+ /** @docs-private */
1052
1505
  registerOnChange(fn) {
1053
1506
  this.onChange = fn;
1054
1507
  }
1508
+ /** @docs-private */
1055
1509
  registerOnTouched(fn) {
1056
1510
  this.onTouched = fn;
1057
1511
  }
1512
+ /** @docs-private */
1058
1513
  setDisabledState(isDisabled) {
1059
1514
  this.disabled.set(isDisabled);
1060
1515
  }
1061
1516
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1062
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfInputComponent, isStandalone: true, selector: "af-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", 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 }, iconPosition: { classPropertyName: "iconPosition", publicName: "iconPosition", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, providers: [
1517
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfInputComponent, isStandalone: true, selector: "af-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", 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 }, iconPosition: { classPropertyName: "iconPosition", publicName: "iconPosition", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange" }, host: { styleAttribute: "display: block" }, providers: [
1063
1518
  {
1064
1519
  provide: NG_VALUE_ACCESSOR,
1065
1520
  useExisting: forwardRef(() => AfInputComponent),
1066
- multi: true
1067
- }
1521
+ multi: true,
1522
+ },
1068
1523
  ], ngImport: i0, template: `
1069
1524
  <div class="ct-field" [class.ct-field--error]="error()">
1070
1525
  @if (label()) {
1071
1526
  <label class="ct-field__label" [attr.for]="inputId()">
1072
1527
  {{ label() }}
1073
1528
  @if (required()) {
1074
- <span aria-label="required"> *</span>
1529
+ <span [attr.aria-label]="i18n.required"> *</span>
1075
1530
  }
1076
1531
  </label>
1077
1532
  }
@@ -1080,7 +1535,7 @@ class AfInputComponent {
1080
1535
  <div class="ct-input-wrap">
1081
1536
  @if (iconPosition() === 'left') {
1082
1537
  <span class="ct-input__icon" aria-hidden="true">
1083
- <ng-content select="[icon]"></ng-content>
1538
+ <ng-content select="[icon]" />
1084
1539
  </span>
1085
1540
  }
1086
1541
  <input
@@ -1090,21 +1545,19 @@ class AfInputComponent {
1090
1545
  [disabled]="disabled()"
1091
1546
  [required]="required()"
1092
1547
  [attr.aria-invalid]="error() ? true : null"
1093
- [attr.aria-describedby]="getAriaDescribedBy()"
1548
+ [attr.aria-describedby]="ariaDescribedBy()"
1094
1549
  [class]="inputClasses()"
1095
- [value]="value"
1550
+ [value]="value()"
1096
1551
  (input)="onInput($event)"
1097
1552
  (blur)="onTouched()"
1098
1553
  />
1099
1554
  @if (iconPosition() === 'right') {
1100
1555
  <span class="ct-input__icon" aria-hidden="true">
1101
- <ng-content select="[icon]"></ng-content>
1556
+ <ng-content select="[icon]" />
1102
1557
  </span>
1103
1558
  }
1104
1559
  </div>
1105
- }
1106
-
1107
- @if (!iconPosition()) {
1560
+ } @else {
1108
1561
  <input
1109
1562
  [id]="inputId()"
1110
1563
  [type]="type()"
@@ -1112,9 +1565,9 @@ class AfInputComponent {
1112
1565
  [disabled]="disabled()"
1113
1566
  [required]="required()"
1114
1567
  [attr.aria-invalid]="error() ? true : null"
1115
- [attr.aria-describedby]="getAriaDescribedBy()"
1116
- class="ct-input"
1117
- [value]="value"
1568
+ [attr.aria-describedby]="ariaDescribedBy()"
1569
+ [class]="inputClasses()"
1570
+ [value]="value()"
1118
1571
  (input)="onInput($event)"
1119
1572
  (blur)="onTouched()"
1120
1573
  />
@@ -1127,28 +1580,35 @@ class AfInputComponent {
1127
1580
  }
1128
1581
 
1129
1582
  @if (error()) {
1130
- <div class="ct-field__error" [id]="errorId()">
1583
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1131
1584
  {{ error() }}
1132
1585
  </div>
1133
1586
  }
1134
1587
  </div>
1135
- `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1588
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1136
1589
  }
1137
1590
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfInputComponent, decorators: [{
1138
1591
  type: Component,
1139
- args: [{ selector: 'af-input', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1592
+ args: [{
1593
+ selector: 'af-input',
1594
+ changeDetection: ChangeDetectionStrategy.OnPush,
1595
+ providers: [
1140
1596
  {
1141
1597
  provide: NG_VALUE_ACCESSOR,
1142
1598
  useExisting: forwardRef(() => AfInputComponent),
1143
- multi: true
1144
- }
1145
- ], template: `
1599
+ multi: true,
1600
+ },
1601
+ ],
1602
+ host: {
1603
+ style: 'display: block',
1604
+ },
1605
+ template: `
1146
1606
  <div class="ct-field" [class.ct-field--error]="error()">
1147
1607
  @if (label()) {
1148
1608
  <label class="ct-field__label" [attr.for]="inputId()">
1149
1609
  {{ label() }}
1150
1610
  @if (required()) {
1151
- <span aria-label="required"> *</span>
1611
+ <span [attr.aria-label]="i18n.required"> *</span>
1152
1612
  }
1153
1613
  </label>
1154
1614
  }
@@ -1157,7 +1617,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1157
1617
  <div class="ct-input-wrap">
1158
1618
  @if (iconPosition() === 'left') {
1159
1619
  <span class="ct-input__icon" aria-hidden="true">
1160
- <ng-content select="[icon]"></ng-content>
1620
+ <ng-content select="[icon]" />
1161
1621
  </span>
1162
1622
  }
1163
1623
  <input
@@ -1167,21 +1627,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1167
1627
  [disabled]="disabled()"
1168
1628
  [required]="required()"
1169
1629
  [attr.aria-invalid]="error() ? true : null"
1170
- [attr.aria-describedby]="getAriaDescribedBy()"
1630
+ [attr.aria-describedby]="ariaDescribedBy()"
1171
1631
  [class]="inputClasses()"
1172
- [value]="value"
1632
+ [value]="value()"
1173
1633
  (input)="onInput($event)"
1174
1634
  (blur)="onTouched()"
1175
1635
  />
1176
1636
  @if (iconPosition() === 'right') {
1177
1637
  <span class="ct-input__icon" aria-hidden="true">
1178
- <ng-content select="[icon]"></ng-content>
1638
+ <ng-content select="[icon]" />
1179
1639
  </span>
1180
1640
  }
1181
1641
  </div>
1182
- }
1183
-
1184
- @if (!iconPosition()) {
1642
+ } @else {
1185
1643
  <input
1186
1644
  [id]="inputId()"
1187
1645
  [type]="type()"
@@ -1189,9 +1647,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1189
1647
  [disabled]="disabled()"
1190
1648
  [required]="required()"
1191
1649
  [attr.aria-invalid]="error() ? true : null"
1192
- [attr.aria-describedby]="getAriaDescribedBy()"
1193
- class="ct-input"
1194
- [value]="value"
1650
+ [attr.aria-describedby]="ariaDescribedBy()"
1651
+ [class]="inputClasses()"
1652
+ [value]="value()"
1195
1653
  (input)="onInput($event)"
1196
1654
  (blur)="onTouched()"
1197
1655
  />
@@ -1204,141 +1662,321 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1204
1662
  }
1205
1663
 
1206
1664
  @if (error()) {
1207
- <div class="ct-field__error" [id]="errorId()">
1665
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1208
1666
  {{ error() }}
1209
1667
  </div>
1210
1668
  }
1211
1669
  </div>
1212
- `, styles: [":host{display:block}\n"] }]
1670
+ `,
1671
+ }]
1213
1672
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", 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"] }], iconPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "iconPosition", required: false }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }] } });
1214
1673
 
1215
1674
  /**
1216
- * Select dropdown component with form control support
1675
+ * Test harness for AfInputComponent.
1676
+ *
1677
+ * Provides a semantic API for interacting with the input in tests,
1678
+ * abstracting DOM details behind readable method names.
1679
+ *
1680
+ * @example
1681
+ * const harness = new AfInputHarness(fixture.nativeElement);
1682
+ * expect(harness.getValue()).toBe('');
1683
+ * harness.setValue('hello');
1684
+ * expect(harness.getValue()).toBe('hello');
1685
+ */
1686
+ class AfInputHarness {
1687
+ hostEl;
1688
+ constructor(container) {
1689
+ const el = container.querySelector('af-input');
1690
+ if (!el) {
1691
+ throw new Error('AfInputHarness: af-input element not found in container.');
1692
+ }
1693
+ this.hostEl = el;
1694
+ }
1695
+ /** Returns the inner native `<input>` element. */
1696
+ getInputElement() {
1697
+ const input = this.hostEl.querySelector('input');
1698
+ if (!input) {
1699
+ throw new Error('AfInputHarness: inner <input> element not found.');
1700
+ }
1701
+ return input;
1702
+ }
1703
+ /** Returns the current value of the input. */
1704
+ getValue() {
1705
+ return this.getInputElement().value;
1706
+ }
1707
+ /** Sets the input value and dispatches an `input` event. */
1708
+ setValue(value) {
1709
+ const input = this.getInputElement();
1710
+ input.value = value;
1711
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1712
+ }
1713
+ /** Returns the label text, or `null` if no label is rendered. */
1714
+ getLabel() {
1715
+ const label = this.hostEl.querySelector('.ct-field__label');
1716
+ return label?.textContent?.trim() ?? null;
1717
+ }
1718
+ /** Returns the hint text, or `null` if no hint is rendered. */
1719
+ getHint() {
1720
+ const hint = this.hostEl.querySelector('.ct-field__hint');
1721
+ return hint?.textContent?.trim() ?? null;
1722
+ }
1723
+ /** Returns the error text, or `null` if no error is rendered. */
1724
+ getError() {
1725
+ const error = this.hostEl.querySelector('.ct-field__error');
1726
+ return error?.textContent?.trim() ?? null;
1727
+ }
1728
+ /** Returns whether the input is disabled. */
1729
+ isDisabled() {
1730
+ return this.getInputElement().disabled;
1731
+ }
1732
+ /** Returns whether the input is required. */
1733
+ isRequired() {
1734
+ return this.getInputElement().required;
1735
+ }
1736
+ /** Returns whether `aria-invalid` is set to `"true"`. */
1737
+ isInvalid() {
1738
+ return this.getInputElement().getAttribute('aria-invalid') === 'true';
1739
+ }
1740
+ /** Returns the `type` attribute of the input. */
1741
+ getType() {
1742
+ return this.getInputElement().type;
1743
+ }
1744
+ /** Returns the `placeholder` attribute of the input. */
1745
+ getPlaceholder() {
1746
+ return this.getInputElement().placeholder;
1747
+ }
1748
+ /** Returns the `aria-describedby` attribute value, or `null` if absent. */
1749
+ getAriaDescribedBy() {
1750
+ return this.getInputElement().getAttribute('aria-describedby');
1751
+ }
1752
+ /** Focuses the input element. */
1753
+ focus() {
1754
+ this.getInputElement().focus();
1755
+ }
1756
+ /** Blurs the input element and dispatches a `blur` event. */
1757
+ blur() {
1758
+ this.getInputElement().dispatchEvent(new Event('blur', { bubbles: true }));
1759
+ }
1760
+ /** Returns the `id` attribute of the input. */
1761
+ getId() {
1762
+ return this.getInputElement().id;
1763
+ }
1764
+ /** Returns whether the field wrapper has the error modifier class. */
1765
+ hasFieldError() {
1766
+ return this.hostEl.querySelector('.ct-field--error') !== null;
1767
+ }
1768
+ /** Returns whether an icon wrapper is rendered. */
1769
+ hasIcon() {
1770
+ return this.hostEl.querySelector('.ct-input__icon') !== null;
1771
+ }
1772
+ /** Returns the full `class` attribute string of the inner input. */
1773
+ getClasses() {
1774
+ return this.getInputElement().className;
1775
+ }
1776
+ /** Returns whether the inner input has the given CSS class. */
1777
+ hasClass(className) {
1778
+ return this.getInputElement().classList.contains(className);
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * Injection token to override select screen-reader announcements
1784
+ * and the fallback `aria-label`.
1217
1785
  *
1218
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
1219
1812
  * <af-select
1220
1813
  * label="Role"
1221
1814
  * [options]="roleOptions"
1222
1815
  * [(ngModel)]="selectedRole"
1223
1816
  * hint="Choose your primary role"
1224
- * ></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.
1225
1834
  */
1226
1835
  class AfSelectComponent {
1227
1836
  static nextId = 0;
1228
- /** Select label */
1837
+ i18n = inject(AF_SELECT_I18N);
1838
+ announcer = inject(AriaLiveAnnouncer);
1839
+ /** Label shown above the select. */
1229
1840
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1230
- /** Placeholder option */
1841
+ /** Placeholder option shown when no value is selected. */
1231
1842
  placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1232
- /** Options array */
1843
+ /** Available options. */
1233
1844
  options = input([], ...(ngDevMode ? [{ debugName: "options" }] : []));
1234
- /** Hint text shown below select */
1845
+ /** Hint text shown below the select. */
1235
1846
  hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
1236
- /** Error message */
1847
+ /** Error message — shows error state when non-empty. */
1237
1848
  error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
1238
- /** Whether select is required */
1849
+ /** Whether the field is required. */
1239
1850
  required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
1240
- /** Whether select is disabled */
1851
+ /** Whether the select is disabled. */
1241
1852
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1242
- /** Value comparison function (for object values) */
1853
+ /** Size variant. */
1854
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
1855
+ /** Value comparison function for object values. */
1243
1856
  compareWith = input((a, b) => a === b, ...(ngDevMode ? [{ debugName: "compareWith" }] : []));
1244
- /** Unique select ID */
1857
+ /** Unique select ID. */
1245
1858
  selectId = input(`af-select-${AfSelectComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "selectId" }] : []));
1246
- value = null;
1247
- onChangeCallback = () => { };
1859
+ /** Emits when the user changes the selected value. */
1860
+ valueChange = output();
1861
+ value = signal(null, ...(ngDevMode ? [{ debugName: "value" }] : []));
1862
+ onChange = () => { };
1248
1863
  onTouched = () => { };
1864
+ labelId = computed(() => `${this.selectId()}-label`, ...(ngDevMode ? [{ debugName: "labelId" }] : []));
1249
1865
  hintId = computed(() => `${this.selectId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
1250
1866
  errorId = computed(() => `${this.selectId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
1251
- 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(() => {
1252
1877
  if (this.error())
1253
1878
  return this.errorId();
1254
1879
  if (this.hint())
1255
1880
  return this.hintId();
1256
1881
  return null;
1257
- }
1258
- get isPlaceholderSelected() {
1882
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
1883
+ isPlaceholderSelected = computed(() => {
1259
1884
  if (!this.placeholder())
1260
1885
  return false;
1261
- if (this.value === null || this.value === undefined || this.value === '')
1886
+ const v = this.value();
1887
+ if (v === null || v === undefined || v === '')
1262
1888
  return true;
1263
1889
  return !this.hasMatchingOption();
1264
- }
1265
- hasMatchingOption() {
1266
- return this.options().some(option => this.compareWith()(option.value, this.value));
1267
- }
1890
+ }, ...(ngDevMode ? [{ debugName: "isPlaceholderSelected" }] : []));
1268
1891
  isOptionSelected(option) {
1269
- if (this.isPlaceholderSelected)
1892
+ if (this.isPlaceholderSelected())
1270
1893
  return false;
1271
- return this.compareWith()(option.value, this.value);
1894
+ return this.compareWith()(option.value, this.value());
1272
1895
  }
1273
- onChange(event) {
1896
+ handleChange(event) {
1274
1897
  const target = event.target;
1275
1898
  const index = target.selectedIndex;
1276
1899
  const offset = this.placeholder() ? 1 : 0;
1277
1900
  if (this.placeholder() && index === 0) {
1278
- this.value = null;
1279
- this.onChangeCallback(null);
1901
+ this.value.set(null);
1902
+ this.onChange(null);
1903
+ this.valueChange.emit(null);
1280
1904
  return;
1281
1905
  }
1282
1906
  const option = this.options()[index - offset];
1283
1907
  const nextValue = option ? option.value : null;
1284
- this.value = nextValue;
1285
- 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
+ }
1286
1914
  }
1287
- /** ControlValueAccessor implementation */
1915
+ /** @docs-private */
1288
1916
  writeValue(value) {
1289
- this.value = value ?? null;
1917
+ this.value.set(value ?? null);
1290
1918
  }
1919
+ /** @docs-private */
1291
1920
  registerOnChange(fn) {
1292
- this.onChangeCallback = fn;
1921
+ this.onChange = fn;
1293
1922
  }
1923
+ /** @docs-private */
1294
1924
  registerOnTouched(fn) {
1295
1925
  this.onTouched = fn;
1296
1926
  }
1927
+ /** @docs-private */
1297
1928
  setDisabledState(isDisabled) {
1298
1929
  this.disabled.set(isDisabled);
1299
1930
  }
1931
+ hasMatchingOption() {
1932
+ return this.options().some((option) => this.compareWith()(option.value, this.value()));
1933
+ }
1300
1934
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSelectComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1301
- 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: [
1302
1936
  {
1303
1937
  provide: NG_VALUE_ACCESSOR,
1304
1938
  useExisting: forwardRef(() => AfSelectComponent),
1305
- multi: true
1306
- }
1939
+ multi: true,
1940
+ },
1307
1941
  ], ngImport: i0, template: `
1308
1942
  <div class="ct-field" [class.ct-field--error]="error()">
1309
1943
  @if (label()) {
1310
- <label class="ct-field__label" [attr.for]="selectId()">
1944
+ <label class="ct-field__label" [id]="labelId()" [attr.for]="selectId()">
1311
1945
  {{ label() }}
1312
1946
  @if (required()) {
1313
- <span aria-label="required"> *</span>
1947
+ <span [attr.aria-label]="i18n.required"> *</span>
1314
1948
  }
1315
1949
  </label>
1316
1950
  }
1317
1951
 
1318
- <select
1319
- [id]="selectId()"
1320
- class="ct-select"
1321
- [disabled]="disabled()"
1322
- [required]="required()"
1323
- [attr.aria-invalid]="error() ? true : null"
1324
- [attr.aria-describedby]="getAriaDescribedBy()"
1325
- (change)="onChange($event)"
1326
- (blur)="onTouched()"
1327
- >
1328
- @if (placeholder()) {
1329
- <option value="" [disabled]="true" [selected]="isPlaceholderSelected">
1330
- {{ placeholder() }}
1331
- </option>
1332
- }
1333
- @for (option of options(); track option.value) {
1334
- <option
1335
- [selected]="isOptionSelected(option)"
1336
- [disabled]="option.disabled || false"
1337
- >
1338
- {{ option.label }}
1339
- </option>
1340
- }
1341
- </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>
1342
1980
 
1343
1981
  @if (hint() && !error()) {
1344
1982
  <div class="ct-field__hint" [id]="hintId()">
@@ -1347,56 +1985,67 @@ class AfSelectComponent {
1347
1985
  }
1348
1986
 
1349
1987
  @if (error()) {
1350
- <div class="ct-field__error" [id]="errorId()">
1988
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1351
1989
  {{ error() }}
1352
1990
  </div>
1353
1991
  }
1354
1992
  </div>
1355
- `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1993
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1356
1994
  }
1357
1995
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfSelectComponent, decorators: [{
1358
1996
  type: Component,
1359
- args: [{ selector: 'af-select', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1997
+ args: [{
1998
+ selector: 'af-select',
1999
+ changeDetection: ChangeDetectionStrategy.OnPush,
2000
+ providers: [
1360
2001
  {
1361
2002
  provide: NG_VALUE_ACCESSOR,
1362
2003
  useExisting: forwardRef(() => AfSelectComponent),
1363
- multi: true
1364
- }
1365
- ], template: `
2004
+ multi: true,
2005
+ },
2006
+ ],
2007
+ host: {
2008
+ style: 'display: block',
2009
+ },
2010
+ template: `
1366
2011
  <div class="ct-field" [class.ct-field--error]="error()">
1367
2012
  @if (label()) {
1368
- <label class="ct-field__label" [attr.for]="selectId()">
2013
+ <label class="ct-field__label" [id]="labelId()" [attr.for]="selectId()">
1369
2014
  {{ label() }}
1370
2015
  @if (required()) {
1371
- <span aria-label="required"> *</span>
2016
+ <span [attr.aria-label]="i18n.required"> *</span>
1372
2017
  }
1373
2018
  </label>
1374
2019
  }
1375
2020
 
1376
- <select
1377
- [id]="selectId()"
1378
- class="ct-select"
1379
- [disabled]="disabled()"
1380
- [required]="required()"
1381
- [attr.aria-invalid]="error() ? true : null"
1382
- [attr.aria-describedby]="getAriaDescribedBy()"
1383
- (change)="onChange($event)"
1384
- (blur)="onTouched()"
1385
- >
1386
- @if (placeholder()) {
1387
- <option value="" [disabled]="true" [selected]="isPlaceholderSelected">
1388
- {{ placeholder() }}
1389
- </option>
1390
- }
1391
- @for (option of options(); track option.value) {
1392
- <option
1393
- [selected]="isOptionSelected(option)"
1394
- [disabled]="option.disabled || false"
1395
- >
1396
- {{ option.label }}
1397
- </option>
1398
- }
1399
- </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>
1400
2049
 
1401
2050
  @if (hint() && !error()) {
1402
2051
  <div class="ct-field__hint" [id]="hintId()">
@@ -1405,13 +2054,139 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1405
2054
  }
1406
2055
 
1407
2056
  @if (error()) {
1408
- <div class="ct-field__error" [id]="errorId()">
2057
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1409
2058
  {{ error() }}
1410
2059
  </div>
1411
2060
  }
1412
2061
  </div>
1413
- `, styles: [":host{display:block}\n"] }]
1414
- }], 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
+ }
1415
2190
 
1416
2191
  /**
1417
2192
  * Textarea component with form control support
@@ -3579,121 +4354,419 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
3579
4354
  `, styles: [":host{display:block}\n"] }]
3580
4355
  }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }] } });
3581
4356
 
4357
+ const MONTH_NAMES = [
4358
+ 'January',
4359
+ 'February',
4360
+ 'March',
4361
+ 'April',
4362
+ 'May',
4363
+ 'June',
4364
+ 'July',
4365
+ 'August',
4366
+ 'September',
4367
+ 'October',
4368
+ 'November',
4369
+ 'December',
4370
+ ];
4371
+ const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
4372
+ const WEEKDAY_LABELS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
3582
4373
  /**
3583
- * Datepicker component with calendar popup
4374
+ * Datepicker component with calendar popup, month/year views, range selection,
4375
+ * min/max constraints, disabled dates, and full keyboard navigation.
4376
+ *
4377
+ * Implements WAI-ARIA Date Picker Dialog pattern with roving tabindex.
3584
4378
  *
3585
4379
  * @example
3586
4380
  * <af-datepicker
3587
- * label="Select date"
4381
+ * label="Start date"
3588
4382
  * placeholder="Pick a date"
3589
- * [(ngModel)]="selectedDate">
3590
- * </af-datepicker>
4383
+ * [(ngModel)]="selectedDate"
4384
+ * [min]="minDate"
4385
+ * [max]="maxDate"
4386
+ * hint="Choose a date within the project timeline"
4387
+ * />
4388
+ *
4389
+ * @example
4390
+ * <af-datepicker
4391
+ * label="Period"
4392
+ * mode="range"
4393
+ * [(ngModel)]="dateRange"
4394
+ * valueFormat="iso"
4395
+ * />
3591
4396
  */
3592
4397
  class AfDatepickerComponent {
3593
4398
  static nextId = 0;
3594
- /** Input label */
4399
+ // ── Public Inputs ────────────────────────────────────────────
4400
+ /** Field label */
3595
4401
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
3596
- /** Placeholder text */
4402
+ /** Input placeholder text */
3597
4403
  placeholder = input('Select date', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
3598
- /** Whether datepicker is disabled */
4404
+ /** Whether the datepicker is disabled */
3599
4405
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
3600
- /** Date format for display */
4406
+ /** Display format for the selected date */
3601
4407
  dateFormat = input('MMM dd, yyyy', ...(ngDevMode ? [{ debugName: "dateFormat" }] : []));
3602
- /** Unique input ID */
4408
+ /** Unique ID for the input element */
3603
4409
  inputId = input(`af-datepicker-${AfDatepickerComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "inputId" }] : []));
3604
- /** Selected date change event */
4410
+ /** Minimum selectable date */
4411
+ min = input(null, ...(ngDevMode ? [{ debugName: "min" }] : []));
4412
+ /** Maximum selectable date */
4413
+ max = input(null, ...(ngDevMode ? [{ debugName: "max" }] : []));
4414
+ /** Array of specific dates that cannot be selected */
4415
+ disabledDates = input([], ...(ngDevMode ? [{ debugName: "disabledDates" }] : []));
4416
+ /**
4417
+ * Predicate function to determine if a date is selectable.
4418
+ * Return `true` to allow selection, `false` to disable.
4419
+ */
4420
+ dateFilter = input(null, ...(ngDevMode ? [{ debugName: "dateFilter" }] : []));
4421
+ /** Hint text displayed below the input */
4422
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
4423
+ /** Error message — displays error state and message */
4424
+ error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
4425
+ /** Whether the field is required */
4426
+ required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
4427
+ /** Selection mode: single date or date range */
4428
+ mode = input('single', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4429
+ /**
4430
+ * Value format for ControlValueAccessor.
4431
+ * `'date'` emits `Date` objects, `'iso'` emits ISO date strings (`yyyy-MM-dd`).
4432
+ */
4433
+ valueFormat = input('date', ...(ngDevMode ? [{ debugName: "valueFormat" }] : []));
4434
+ /** Emitted when a single date is selected */
3605
4435
  dateChange = output();
3606
- inputRef = viewChild('input', ...(ngDevMode ? [{ debugName: "inputRef" }] : []));
3607
- popoverRef = viewChild('popover', ...(ngDevMode ? [{ debugName: "popoverRef" }] : []));
3608
- weekdayLabels = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
3609
- monthNames = [
3610
- 'January', 'February', 'March', 'April', 'May', 'June',
3611
- 'July', 'August', 'September', 'October', 'November', 'December'
3612
- ];
4436
+ /** Emitted when a date range is selected (range mode only) */
4437
+ rangeChange = output();
4438
+ // ── Template References ──────────────────────────────────────
4439
+ inputRef = viewChild('inputEl', ...(ngDevMode ? [{ debugName: "inputRef" }] : []));
4440
+ popoverRef = viewChild('popoverEl', ...(ngDevMode ? [{ debugName: "popoverRef" }] : []));
4441
+ // ── Constants ────────────────────────────────────────────────
4442
+ weekdayLabels = WEEKDAY_LABELS;
4443
+ weekdayFullLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
4444
+ // ── Internal State ───────────────────────────────────────────
3613
4445
  selectedDate = signal(null, ...(ngDevMode ? [{ debugName: "selectedDate" }] : []));
4446
+ rangeStart = signal(null, ...(ngDevMode ? [{ debugName: "rangeStart" }] : []));
4447
+ rangeEnd = signal(null, ...(ngDevMode ? [{ debugName: "rangeEnd" }] : []));
4448
+ rangeSelecting = signal(false, ...(ngDevMode ? [{ debugName: "rangeSelecting" }] : []));
3614
4449
  currentMonth = signal(new Date().getMonth(), ...(ngDevMode ? [{ debugName: "currentMonth" }] : []));
3615
4450
  currentYear = signal(new Date().getFullYear(), ...(ngDevMode ? [{ debugName: "currentYear" }] : []));
3616
4451
  isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
3617
4452
  focusedDate = signal(null, ...(ngDevMode ? [{ debugName: "focusedDate" }] : []));
4453
+ currentView = signal('days', ...(ngDevMode ? [{ debugName: "currentView" }] : []));
4454
+ focusedMonth = signal(new Date().getMonth(), ...(ngDevMode ? [{ debugName: "focusedMonth" }] : []));
4455
+ focusedYear = signal(new Date().getFullYear(), ...(ngDevMode ? [{ debugName: "focusedYear" }] : []));
4456
+ yearPageStart = signal(Math.floor(new Date().getFullYear() / 12) * 12, ...(ngDevMode ? [{ debugName: "yearPageStart" }] : []));
3618
4457
  onChange = () => { };
3619
4458
  onTouched = () => { };
4459
+ onValidatorChange = () => { };
4460
+ // ── Computed ─────────────────────────────────────────────────
4461
+ parsedMin = computed(() => this.parseDate(this.min()), ...(ngDevMode ? [{ debugName: "parsedMin" }] : []));
4462
+ parsedMax = computed(() => this.parseDate(this.max()), ...(ngDevMode ? [{ debugName: "parsedMax" }] : []));
4463
+ popoverId = computed(() => `${this.inputId()}-popover`, ...(ngDevMode ? [{ debugName: "popoverId" }] : []));
4464
+ hintId = computed(() => `${this.inputId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
4465
+ errorId = computed(() => `${this.inputId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
4466
+ ariaDescribedBy = computed(() => {
4467
+ if (this.error())
4468
+ return this.errorId();
4469
+ if (this.hint())
4470
+ return this.hintId();
4471
+ return null;
4472
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
4473
+ dialogAriaLabel = computed(() => {
4474
+ if (this.mode() === 'range')
4475
+ return 'Choose date range';
4476
+ return 'Choose date';
4477
+ }, ...(ngDevMode ? [{ debugName: "dialogAriaLabel" }] : []));
4478
+ headerTitle = computed(() => {
4479
+ switch (this.currentView()) {
4480
+ case 'days':
4481
+ return `${MONTH_NAMES[this.currentMonth()]} ${this.currentYear()}`;
4482
+ case 'months':
4483
+ return `${this.currentYear()}`;
4484
+ case 'years':
4485
+ return `${this.yearPageStart()} – ${this.yearPageStart() + 11}`;
4486
+ }
4487
+ }, ...(ngDevMode ? [{ debugName: "headerTitle" }] : []));
4488
+ titleAriaLabel = computed(() => {
4489
+ switch (this.currentView()) {
4490
+ case 'days':
4491
+ return 'Switch to month view';
4492
+ case 'months':
4493
+ return 'Switch to year view';
4494
+ case 'years':
4495
+ return null;
4496
+ }
4497
+ }, ...(ngDevMode ? [{ debugName: "titleAriaLabel" }] : []));
4498
+ prevButtonAriaLabel = computed(() => {
4499
+ switch (this.currentView()) {
4500
+ case 'days':
4501
+ return 'Previous month';
4502
+ case 'months':
4503
+ return 'Previous year';
4504
+ case 'years':
4505
+ return 'Previous 12 years';
4506
+ }
4507
+ }, ...(ngDevMode ? [{ debugName: "prevButtonAriaLabel" }] : []));
4508
+ nextButtonAriaLabel = computed(() => {
4509
+ switch (this.currentView()) {
4510
+ case 'days':
4511
+ return 'Next month';
4512
+ case 'months':
4513
+ return 'Next year';
4514
+ case 'years':
4515
+ return 'Next 12 years';
4516
+ }
4517
+ }, ...(ngDevMode ? [{ debugName: "nextButtonAriaLabel" }] : []));
4518
+ gridAriaLabel = computed(() => `${MONTH_NAMES[this.currentMonth()]} ${this.currentYear()}`, ...(ngDevMode ? [{ debugName: "gridAriaLabel" }] : []));
4519
+ hasClearableValue = computed(() => {
4520
+ if (this.mode() === 'range') {
4521
+ return this.rangeStart() !== null || this.rangeEnd() !== null;
4522
+ }
4523
+ return this.selectedDate() !== null;
4524
+ }, ...(ngDevMode ? [{ debugName: "hasClearableValue" }] : []));
4525
+ formattedValue = computed(() => {
4526
+ if (this.mode() === 'range') {
4527
+ const start = this.rangeStart();
4528
+ const end = this.rangeEnd();
4529
+ if (!start && !end)
4530
+ return '';
4531
+ const startStr = start ? this.formatDate(start) : '...';
4532
+ const endStr = end ? this.formatDate(end) : '...';
4533
+ return `${startStr} – ${endStr}`;
4534
+ }
4535
+ const date = this.selectedDate();
4536
+ return date ? this.formatDate(date) : '';
4537
+ }, ...(ngDevMode ? [{ debugName: "formattedValue" }] : []));
4538
+ isTodayDisabled = computed(() => this.isDateDisabled(new Date()), ...(ngDevMode ? [{ debugName: "isTodayDisabled" }] : []));
3620
4539
  calendarDays = computed(() => {
3621
- return this.generateCalendarDays();
4540
+ const year = this.currentYear();
4541
+ const month = this.currentMonth();
4542
+ const today = new Date();
4543
+ const selected = this.selectedDate();
4544
+ const rStart = this.rangeStart();
4545
+ const rEnd = this.rangeEnd();
4546
+ const previewEnd = this.mode() === 'range' && this.rangeSelecting() ? this.focusedDate() : null;
4547
+ const effectiveEnd = rEnd ?? previewEnd;
4548
+ const firstDay = new Date(year, month, 1);
4549
+ const lastDay = new Date(year, month + 1, 0);
4550
+ const days = [];
4551
+ let startPad = firstDay.getDay() - 1;
4552
+ if (startPad < 0)
4553
+ startPad = 6;
4554
+ const prevLast = new Date(year, month, 0).getDate();
4555
+ for (let i = startPad - 1; i >= 0; i--) {
4556
+ days.push(this.buildDay(new Date(year, month - 1, prevLast - i), false, today, selected, rStart, effectiveEnd));
4557
+ }
4558
+ for (let i = 1; i <= lastDay.getDate(); i++) {
4559
+ days.push(this.buildDay(new Date(year, month, i), true, today, selected, rStart, effectiveEnd));
4560
+ }
4561
+ const remaining = 42 - days.length;
4562
+ for (let i = 1; i <= remaining; i++) {
4563
+ days.push(this.buildDay(new Date(year, month + 1, i), false, today, selected, rStart, effectiveEnd));
4564
+ }
4565
+ return days;
3622
4566
  }, ...(ngDevMode ? [{ debugName: "calendarDays" }] : []));
3623
- formattedDate = computed(() => {
3624
- const date = this.selectedDate();
3625
- if (!date)
3626
- return '';
3627
- return this.formatDate(date);
3628
- }, ...(ngDevMode ? [{ debugName: "formattedDate" }] : []));
3629
- popoverId = computed(() => `${this.inputId()}-popover`, ...(ngDevMode ? [{ debugName: "popoverId" }] : []));
4567
+ monthItems = computed(() => {
4568
+ const year = this.currentYear();
4569
+ const selected = this.selectedDate();
4570
+ const min = this.parsedMin();
4571
+ const max = this.parsedMax();
4572
+ return Array.from({ length: 12 }, (_, i) => {
4573
+ const isDisabled = this.isMonthDisabled(year, i, min, max);
4574
+ return {
4575
+ index: i,
4576
+ label: MONTH_NAMES[i],
4577
+ shortLabel: MONTH_SHORT[i],
4578
+ isSelected: selected ? selected.getMonth() === i && selected.getFullYear() === year : false,
4579
+ isDisabled,
4580
+ };
4581
+ });
4582
+ }, ...(ngDevMode ? [{ debugName: "monthItems" }] : []));
4583
+ yearItems = computed(() => {
4584
+ const start = this.yearPageStart();
4585
+ const selected = this.selectedDate();
4586
+ const min = this.parsedMin();
4587
+ const max = this.parsedMax();
4588
+ return Array.from({ length: 12 }, (_, i) => {
4589
+ const value = start + i;
4590
+ return {
4591
+ value,
4592
+ isSelected: selected ? selected.getFullYear() === value : false,
4593
+ isDisabled: this.isYearDisabled(value, min, max),
4594
+ };
4595
+ });
4596
+ }, ...(ngDevMode ? [{ debugName: "yearItems" }] : []));
4597
+ // ── Open / Close ─────────────────────────────────────────────
3630
4598
  toggle() {
3631
4599
  if (this.isOpen()) {
3632
- this.close();
4600
+ this.close(true);
3633
4601
  }
3634
4602
  else {
3635
4603
  this.open();
3636
4604
  }
3637
4605
  }
3638
- selectDate(date) {
4606
+ /** Opens the calendar popover */
4607
+ open() {
4608
+ if (this.disabled() || this.isOpen())
4609
+ return;
4610
+ const selected = this.selectedDate();
4611
+ const focusDate = selected ?? new Date();
4612
+ this.currentMonth.set(focusDate.getMonth());
4613
+ this.currentYear.set(focusDate.getFullYear());
4614
+ this.focusedDate.set(focusDate);
4615
+ this.focusedMonth.set(focusDate.getMonth());
4616
+ this.focusedYear.set(focusDate.getFullYear());
4617
+ this.currentView.set('days');
4618
+ this.isOpen.set(true);
4619
+ queueMicrotask(() => this.focusDayButton(focusDate));
4620
+ }
4621
+ /** Closes the calendar popover */
4622
+ close(returnFocus = false) {
4623
+ if (!this.isOpen())
4624
+ return;
4625
+ this.isOpen.set(false);
4626
+ this.onTouched();
4627
+ if (returnFocus) {
4628
+ this.inputRef()?.nativeElement.focus();
4629
+ }
4630
+ }
4631
+ // ── Day Selection ────────────────────────────────────────────
4632
+ /** Handles click on a calendar day */
4633
+ onDayClick(day) {
4634
+ if (day.isDisabled || day.isUnavailable)
4635
+ return;
4636
+ if (this.mode() === 'range') {
4637
+ this.selectRangeDate(day.date);
4638
+ }
4639
+ else {
4640
+ this.selectSingleDate(day.date);
4641
+ }
4642
+ }
4643
+ /** Selects a single date and closes the popover */
4644
+ selectSingleDate(date) {
3639
4645
  this.selectedDate.set(date);
3640
4646
  this.focusedDate.set(date);
3641
- this.onChange(date);
4647
+ this.emitSingleValue(date);
3642
4648
  this.dateChange.emit(date);
3643
4649
  this.close(true);
3644
4650
  }
3645
- previousMonth() {
3646
- this.shiftMonth(-1);
3647
- }
3648
- nextMonth() {
3649
- this.shiftMonth(1);
4651
+ /** Handles range date selection (two-click: start then end) */
4652
+ selectRangeDate(date) {
4653
+ if (!this.rangeSelecting()) {
4654
+ this.rangeStart.set(date);
4655
+ this.rangeEnd.set(null);
4656
+ this.rangeSelecting.set(true);
4657
+ this.focusedDate.set(date);
4658
+ }
4659
+ else {
4660
+ let start = this.rangeStart();
4661
+ let end = date;
4662
+ if (this.compareDays(end, start) < 0) {
4663
+ [start, end] = [end, start];
4664
+ }
4665
+ this.rangeStart.set(start);
4666
+ this.rangeEnd.set(end);
4667
+ this.rangeSelecting.set(false);
4668
+ this.emitRangeValue(start, end);
4669
+ this.rangeChange.emit({ start, end });
4670
+ this.close(true);
4671
+ }
3650
4672
  }
3651
- generateCalendarDays() {
3652
- const year = this.currentYear();
3653
- const month = this.currentMonth();
3654
- const firstDay = new Date(year, month, 1);
3655
- const lastDay = new Date(year, month + 1, 0);
4673
+ /** Navigates to today and selects it (single mode) or focuses it */
4674
+ goToToday() {
3656
4675
  const today = new Date();
3657
- const selected = this.selectedDate();
3658
- const days = [];
3659
- // Get day of week (0 = Sunday, adjust to Monday = 0)
3660
- let startDay = firstDay.getDay() - 1;
3661
- if (startDay < 0)
3662
- startDay = 6;
3663
- // Previous month days
3664
- const prevMonthLastDay = new Date(year, month, 0).getDate();
3665
- for (let i = startDay - 1; i >= 0; i--) {
3666
- const date = new Date(year, month - 1, prevMonthLastDay - i);
3667
- days.push({
3668
- date,
3669
- isCurrentMonth: false,
3670
- isToday: this.isSameDay(date, today),
3671
- isSelected: selected ? this.isSameDay(date, selected) : false
3672
- });
4676
+ if (this.isDateDisabled(today))
4677
+ return;
4678
+ this.currentMonth.set(today.getMonth());
4679
+ this.currentYear.set(today.getFullYear());
4680
+ this.focusedDate.set(today);
4681
+ if (this.mode() === 'single') {
4682
+ this.selectSingleDate(today);
3673
4683
  }
3674
- // Current month days
3675
- for (let i = 1; i <= lastDay.getDate(); i++) {
3676
- const date = new Date(year, month, i);
3677
- days.push({
3678
- date,
3679
- isCurrentMonth: true,
3680
- isToday: this.isSameDay(date, today),
3681
- isSelected: selected ? this.isSameDay(date, selected) : false
3682
- });
4684
+ else {
4685
+ this.selectRangeDate(today);
3683
4686
  }
3684
- // Next month days to fill grid (6 rows * 7 days = 42)
3685
- const remainingDays = 42 - days.length;
3686
- for (let i = 1; i <= remainingDays; i++) {
3687
- const date = new Date(year, month + 1, i);
3688
- days.push({
3689
- date,
3690
- isCurrentMonth: false,
3691
- isToday: this.isSameDay(date, today),
3692
- isSelected: selected ? this.isSameDay(date, selected) : false
3693
- });
4687
+ }
4688
+ /** Clears the selected value */
4689
+ clearValue(event) {
4690
+ event.stopPropagation();
4691
+ if (this.mode() === 'range') {
4692
+ this.rangeStart.set(null);
4693
+ this.rangeEnd.set(null);
4694
+ this.rangeSelecting.set(false);
4695
+ this.emitRangeValue(null, null);
4696
+ }
4697
+ else {
4698
+ this.selectedDate.set(null);
4699
+ this.emitSingleValue(null);
4700
+ }
4701
+ this.inputRef()?.nativeElement.focus();
4702
+ }
4703
+ // ── View Navigation ──────────────────────────────────────────
4704
+ /** Drills up: days -> months -> years */
4705
+ drillUp() {
4706
+ const view = this.currentView();
4707
+ if (view === 'days') {
4708
+ this.focusedMonth.set(this.currentMonth());
4709
+ this.currentView.set('months');
4710
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4711
+ }
4712
+ else if (view === 'months') {
4713
+ this.focusedYear.set(this.currentYear());
4714
+ this.yearPageStart.set(Math.floor(this.currentYear() / 12) * 12);
4715
+ this.currentView.set('years');
4716
+ queueMicrotask(() => this.focusYearButton(this.focusedYear()));
4717
+ }
4718
+ }
4719
+ /** Navigates to previous period based on current view */
4720
+ navigatePrevious() {
4721
+ switch (this.currentView()) {
4722
+ case 'days':
4723
+ this.shiftMonth(-1);
4724
+ break;
4725
+ case 'months':
4726
+ this.currentYear.update((y) => y - 1);
4727
+ break;
4728
+ case 'years':
4729
+ this.yearPageStart.update((s) => s - 12);
4730
+ break;
4731
+ }
4732
+ }
4733
+ /** Navigates to next period based on current view */
4734
+ navigateNext() {
4735
+ switch (this.currentView()) {
4736
+ case 'days':
4737
+ this.shiftMonth(1);
4738
+ break;
4739
+ case 'months':
4740
+ this.currentYear.update((y) => y + 1);
4741
+ break;
4742
+ case 'years':
4743
+ this.yearPageStart.update((s) => s + 12);
4744
+ break;
3694
4745
  }
3695
- return days;
3696
4746
  }
4747
+ /** Selects a month from the month view and switches to days */
4748
+ selectMonth(monthIndex) {
4749
+ this.currentMonth.set(monthIndex);
4750
+ this.focusedMonth.set(monthIndex);
4751
+ this.currentView.set('days');
4752
+ const focusDate = new Date(this.currentYear(), monthIndex, 1);
4753
+ this.focusedDate.set(focusDate);
4754
+ queueMicrotask(() => this.focusDayButton(focusDate));
4755
+ }
4756
+ /** Selects a year from the year view and switches to months */
4757
+ selectYear(year) {
4758
+ this.currentYear.set(year);
4759
+ this.focusedYear.set(year);
4760
+ this.currentView.set('months');
4761
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4762
+ }
4763
+ // ── Day Helpers for Template ─────────────────────────────────
4764
+ /** Returns true if the given day matches the keyboard-focused date */
4765
+ isDayHighlighted(day) {
4766
+ const focused = this.focusedDate();
4767
+ return focused !== null && this.isSameDay(day.date, focused);
4768
+ }
4769
+ /** Returns the tabindex for a day button (roving tabindex pattern) */
3697
4770
  getDayTabIndex(day) {
3698
4771
  const focused = this.focusedDate();
3699
4772
  if (focused && this.isSameDay(day.date, focused))
@@ -3702,41 +4775,72 @@ class AfDatepickerComponent {
3702
4775
  return 0;
3703
4776
  return -1;
3704
4777
  }
4778
+ /** Returns true if the given month index matches the keyboard-focused month */
4779
+ isMonthHighlighted(monthIndex) {
4780
+ return this.currentView() === 'months' && this.focusedMonth() === monthIndex;
4781
+ }
4782
+ /** Returns the tabindex for a month button */
4783
+ getMonthTabIndex(monthIndex) {
4784
+ return this.focusedMonth() === monthIndex ? 0 : -1;
4785
+ }
4786
+ /** Returns true if the given year matches the keyboard-focused year */
4787
+ isYearHighlighted(year) {
4788
+ return this.currentView() === 'years' && this.focusedYear() === year;
4789
+ }
4790
+ /** Returns the tabindex for a year button */
4791
+ getYearTabIndex(year) {
4792
+ return this.focusedYear() === year ? 0 : -1;
4793
+ }
4794
+ /** Returns a date key string for DOM identification */
3705
4795
  getDateKey(date) {
3706
- const year = date.getFullYear();
3707
- const month = String(date.getMonth() + 1).padStart(2, '0');
3708
- const day = String(date.getDate()).padStart(2, '0');
3709
- return `${year}-${month}-${day}`;
4796
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
3710
4797
  }
4798
+ // ── Keyboard Handlers ────────────────────────────────────────
3711
4799
  onInputKeydown(event) {
3712
4800
  if (this.disabled())
3713
4801
  return;
3714
- if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
3715
- event.preventDefault();
3716
- this.open();
3717
- }
3718
- else if (event.key === 'Escape') {
3719
- event.preventDefault();
3720
- this.close(true);
4802
+ switch (event.key) {
4803
+ case 'ArrowDown':
4804
+ case 'Enter':
4805
+ case ' ':
4806
+ event.preventDefault();
4807
+ this.open();
4808
+ break;
4809
+ case 'Escape':
4810
+ if (this.isOpen()) {
4811
+ event.preventDefault();
4812
+ this.close(true);
4813
+ }
4814
+ break;
4815
+ case 'Delete':
4816
+ case 'Backspace':
4817
+ if (this.hasClearableValue()) {
4818
+ event.preventDefault();
4819
+ this.clearValue(event);
4820
+ }
4821
+ break;
3721
4822
  }
3722
4823
  }
3723
- onGridKeydown(event) {
4824
+ /** Keyboard navigation within the day grid */
4825
+ onDayGridKeydown(event) {
3724
4826
  if (!this.isOpen())
3725
4827
  return;
3726
- const focused = this.focusedDate() || this.selectedDate() || new Date(this.currentYear(), this.currentMonth(), 1);
4828
+ const focused = this.focusedDate() ??
4829
+ this.selectedDate() ??
4830
+ new Date(this.currentYear(), this.currentMonth(), 1);
3727
4831
  let nextDate = null;
3728
4832
  switch (event.key) {
3729
4833
  case 'ArrowRight':
3730
- nextDate = this.addDays(focused, 1);
4834
+ nextDate = this.findNextEnabledDate(focused, 1);
3731
4835
  break;
3732
4836
  case 'ArrowLeft':
3733
- nextDate = this.addDays(focused, -1);
4837
+ nextDate = this.findNextEnabledDate(focused, -1);
3734
4838
  break;
3735
4839
  case 'ArrowDown':
3736
- nextDate = this.addDays(focused, 7);
4840
+ nextDate = this.findNextEnabledDate(focused, 7);
3737
4841
  break;
3738
4842
  case 'ArrowUp':
3739
- nextDate = this.addDays(focused, -7);
4843
+ nextDate = this.findNextEnabledDate(focused, -7);
3740
4844
  break;
3741
4845
  case 'Home':
3742
4846
  nextDate = this.addDays(focused, -this.getWeekdayIndex(focused));
@@ -3745,15 +4849,17 @@ class AfDatepickerComponent {
3745
4849
  nextDate = this.addDays(focused, 6 - this.getWeekdayIndex(focused));
3746
4850
  break;
3747
4851
  case 'PageUp':
3748
- nextDate = this.addMonths(focused, -1);
4852
+ nextDate = event.shiftKey ? this.addYears(focused, -1) : this.addMonths(focused, -1);
3749
4853
  break;
3750
4854
  case 'PageDown':
3751
- nextDate = this.addMonths(focused, 1);
4855
+ nextDate = event.shiftKey ? this.addYears(focused, 1) : this.addMonths(focused, 1);
3752
4856
  break;
3753
4857
  case 'Enter':
3754
4858
  case ' ':
3755
4859
  event.preventDefault();
3756
- this.selectDate(focused);
4860
+ if (!this.isDateDisabled(focused)) {
4861
+ this.onDayClick({ date: focused });
4862
+ }
3757
4863
  return;
3758
4864
  case 'Escape':
3759
4865
  event.preventDefault();
@@ -3767,10 +4873,100 @@ class AfDatepickerComponent {
3767
4873
  this.setFocusedDate(nextDate);
3768
4874
  }
3769
4875
  }
3770
- onEscape() {
3771
- if (this.isOpen()) {
3772
- this.close(true);
4876
+ /** Keyboard navigation within the month grid */
4877
+ onMonthGridKeydown(event) {
4878
+ let nextMonth = this.focusedMonth();
4879
+ switch (event.key) {
4880
+ case 'ArrowRight':
4881
+ nextMonth = Math.min(11, nextMonth + 1);
4882
+ break;
4883
+ case 'ArrowLeft':
4884
+ nextMonth = Math.max(0, nextMonth - 1);
4885
+ break;
4886
+ case 'ArrowDown':
4887
+ nextMonth = Math.min(11, nextMonth + 3);
4888
+ break;
4889
+ case 'ArrowUp':
4890
+ nextMonth = Math.max(0, nextMonth - 3);
4891
+ break;
4892
+ case 'Home':
4893
+ nextMonth = 0;
4894
+ break;
4895
+ case 'End':
4896
+ nextMonth = 11;
4897
+ break;
4898
+ case 'Enter':
4899
+ case ' ':
4900
+ event.preventDefault();
4901
+ if (!this.isMonthDisabled(this.currentYear(), nextMonth, this.parsedMin(), this.parsedMax())) {
4902
+ this.selectMonth(nextMonth);
4903
+ }
4904
+ return;
4905
+ case 'Escape':
4906
+ event.preventDefault();
4907
+ this.currentView.set('days');
4908
+ queueMicrotask(() => this.focusDayButton(this.focusedDate() ?? new Date()));
4909
+ return;
4910
+ default:
4911
+ return;
3773
4912
  }
4913
+ event.preventDefault();
4914
+ this.focusedMonth.set(nextMonth);
4915
+ queueMicrotask(() => this.focusMonthButton(nextMonth));
4916
+ }
4917
+ /** Keyboard navigation within the year grid */
4918
+ onYearGridKeydown(event) {
4919
+ let nextYear = this.focusedYear();
4920
+ const pageStart = this.yearPageStart();
4921
+ switch (event.key) {
4922
+ case 'ArrowRight':
4923
+ nextYear += 1;
4924
+ break;
4925
+ case 'ArrowLeft':
4926
+ nextYear -= 1;
4927
+ break;
4928
+ case 'ArrowDown':
4929
+ nextYear += 3;
4930
+ break;
4931
+ case 'ArrowUp':
4932
+ nextYear -= 3;
4933
+ break;
4934
+ case 'Home':
4935
+ nextYear = pageStart;
4936
+ break;
4937
+ case 'End':
4938
+ nextYear = pageStart + 11;
4939
+ break;
4940
+ case 'PageUp':
4941
+ nextYear -= 12;
4942
+ break;
4943
+ case 'PageDown':
4944
+ nextYear += 12;
4945
+ break;
4946
+ case 'Enter':
4947
+ case ' ':
4948
+ event.preventDefault();
4949
+ if (!this.isYearDisabled(nextYear, this.parsedMin(), this.parsedMax())) {
4950
+ this.selectYear(nextYear);
4951
+ }
4952
+ return;
4953
+ case 'Escape':
4954
+ event.preventDefault();
4955
+ this.currentView.set('months');
4956
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4957
+ return;
4958
+ default:
4959
+ return;
4960
+ }
4961
+ event.preventDefault();
4962
+ if (nextYear < pageStart) {
4963
+ this.yearPageStart.update((s) => s - 12);
4964
+ }
4965
+ else if (nextYear >= pageStart + 12) {
4966
+ this.yearPageStart.update((s) => s + 12);
4967
+ }
4968
+ this.focusedYear.set(nextYear);
4969
+ queueMicrotask(() => this.focusYearButton(nextYear));
3774
4970
  }
3775
4971
  onDocumentClick(event) {
3776
4972
  const target = event.target;
@@ -3778,24 +4974,204 @@ class AfDatepickerComponent {
3778
4974
  this.close(false);
3779
4975
  }
3780
4976
  }
3781
- open() {
3782
- if (this.disabled() || this.isOpen())
4977
+ // ── ControlValueAccessor ─────────────────────────────────────
4978
+ writeValue(value) {
4979
+ if (this.mode() === 'range') {
4980
+ this.writeRangeValue(value);
4981
+ }
4982
+ else {
4983
+ this.writeSingleValue(value);
4984
+ }
4985
+ }
4986
+ registerOnChange(fn) {
4987
+ this.onChange = fn;
4988
+ }
4989
+ registerOnTouched(fn) {
4990
+ this.onTouched = fn;
4991
+ }
4992
+ setDisabledState(isDisabled) {
4993
+ this.disabled.set(isDisabled);
4994
+ }
4995
+ // ── Validator ────────────────────────────────────────────────
4996
+ validate(control) {
4997
+ const value = control.value;
4998
+ if (this.mode() === 'range') {
4999
+ return this.validateRange(value);
5000
+ }
5001
+ return this.validateSingle(value);
5002
+ }
5003
+ registerOnValidatorChange(fn) {
5004
+ this.onValidatorChange = fn;
5005
+ }
5006
+ // ── Private: Value Handling ──────────────────────────────────
5007
+ writeSingleValue(value) {
5008
+ const date = this.coerceToDate(value);
5009
+ this.selectedDate.set(date);
5010
+ if (date) {
5011
+ this.currentMonth.set(date.getMonth());
5012
+ this.currentYear.set(date.getFullYear());
5013
+ this.focusedDate.set(date);
5014
+ }
5015
+ else {
5016
+ this.focusedDate.set(null);
5017
+ }
5018
+ }
5019
+ writeRangeValue(value) {
5020
+ if (!value || typeof value !== 'object') {
5021
+ this.rangeStart.set(null);
5022
+ this.rangeEnd.set(null);
5023
+ this.rangeSelecting.set(false);
3783
5024
  return;
3784
- const selected = this.selectedDate();
3785
- const focusDate = selected || new Date();
3786
- this.currentMonth.set(focusDate.getMonth());
3787
- this.currentYear.set(focusDate.getFullYear());
3788
- this.focusedDate.set(focusDate);
3789
- this.isOpen.set(true);
3790
- queueMicrotask(() => this.focusDayButton(focusDate));
5025
+ }
5026
+ const range = value;
5027
+ this.rangeStart.set(this.coerceToDate(range['start']));
5028
+ this.rangeEnd.set(this.coerceToDate(range['end']));
5029
+ this.rangeSelecting.set(false);
5030
+ const focus = this.rangeStart() ?? this.rangeEnd();
5031
+ if (focus) {
5032
+ this.currentMonth.set(focus.getMonth());
5033
+ this.currentYear.set(focus.getFullYear());
5034
+ }
3791
5035
  }
3792
- close(returnFocus = false) {
3793
- this.isOpen.set(false);
3794
- this.onTouched();
3795
- if (returnFocus) {
3796
- this.inputRef()?.nativeElement.focus();
5036
+ emitSingleValue(date) {
5037
+ if (this.valueFormat() === 'iso') {
5038
+ this.onChange(date ? this.toIsoString(date) : null);
5039
+ }
5040
+ else {
5041
+ this.onChange(date);
3797
5042
  }
5043
+ this.onValidatorChange();
3798
5044
  }
5045
+ emitRangeValue(start, end) {
5046
+ if (this.valueFormat() === 'iso') {
5047
+ this.onChange({
5048
+ start: start ? this.toIsoString(start) : null,
5049
+ end: end ? this.toIsoString(end) : null,
5050
+ });
5051
+ }
5052
+ else {
5053
+ this.onChange({ start, end });
5054
+ }
5055
+ this.onValidatorChange();
5056
+ }
5057
+ // ── Private: Validation ──────────────────────────────────────
5058
+ validateSingle(value) {
5059
+ if (!value)
5060
+ return null;
5061
+ const date = this.coerceToDate(value);
5062
+ if (!date)
5063
+ return null;
5064
+ const min = this.parsedMin();
5065
+ const max = this.parsedMax();
5066
+ if (min && this.compareDays(date, min) < 0) {
5067
+ return { afDatepickerMin: { min, actual: date } };
5068
+ }
5069
+ if (max && this.compareDays(date, max) > 0) {
5070
+ return { afDatepickerMax: { max, actual: date } };
5071
+ }
5072
+ if (this.isDateUnavailable(date)) {
5073
+ return { afDatepickerFilter: true };
5074
+ }
5075
+ return null;
5076
+ }
5077
+ validateRange(value) {
5078
+ if (!value || typeof value !== 'object')
5079
+ return null;
5080
+ const range = value;
5081
+ const start = this.coerceToDate(range['start']);
5082
+ const end = this.coerceToDate(range['end']);
5083
+ const errors = {};
5084
+ const min = this.parsedMin();
5085
+ const max = this.parsedMax();
5086
+ if (start && min && this.compareDays(start, min) < 0) {
5087
+ errors['afDatepickerMin'] = { min, actual: start };
5088
+ }
5089
+ if (end && max && this.compareDays(end, max) > 0) {
5090
+ errors['afDatepickerMax'] = { max, actual: end };
5091
+ }
5092
+ if (start && end && this.compareDays(start, end) > 0) {
5093
+ errors['afDatepickerRange'] = { start, end };
5094
+ }
5095
+ return Object.keys(errors).length > 0 ? errors : null;
5096
+ }
5097
+ // ── Private: Calendar Building ───────────────────────────────
5098
+ buildDay(date, isCurrentMonth, today, selected, rangeStart, rangeEnd) {
5099
+ const isToday = this.isSameDay(date, today);
5100
+ const isSelected = this.mode() === 'single' && selected !== null ? this.isSameDay(date, selected) : false;
5101
+ const isDisabled = this.isDateDisabled(date);
5102
+ const isUnavailable = this.isDateUnavailable(date);
5103
+ let isInRange = false;
5104
+ let isRangeStart = false;
5105
+ let isRangeEnd = false;
5106
+ if (this.mode() === 'range' && rangeStart) {
5107
+ isRangeStart = this.isSameDay(date, rangeStart);
5108
+ if (rangeEnd) {
5109
+ const [lo, hi] = this.compareDays(rangeStart, rangeEnd) <= 0
5110
+ ? [rangeStart, rangeEnd]
5111
+ : [rangeEnd, rangeStart];
5112
+ isRangeStart = this.isSameDay(date, lo);
5113
+ isRangeEnd = this.isSameDay(date, hi);
5114
+ isInRange =
5115
+ !isRangeStart &&
5116
+ !isRangeEnd &&
5117
+ this.compareDays(date, lo) > 0 &&
5118
+ this.compareDays(date, hi) < 0;
5119
+ }
5120
+ }
5121
+ return {
5122
+ date,
5123
+ isCurrentMonth,
5124
+ isToday,
5125
+ isSelected,
5126
+ isDisabled,
5127
+ isUnavailable,
5128
+ isInRange,
5129
+ isRangeStart,
5130
+ isRangeEnd,
5131
+ };
5132
+ }
5133
+ // ── Private: Disability Checks ───────────────────────────────
5134
+ /** Checks whether a date falls outside min/max bounds */
5135
+ isDateDisabled(date) {
5136
+ const min = this.parsedMin();
5137
+ const max = this.parsedMax();
5138
+ if (min && this.compareDays(date, min) < 0)
5139
+ return true;
5140
+ if (max && this.compareDays(date, max) > 0)
5141
+ return true;
5142
+ return false;
5143
+ }
5144
+ /** Checks whether a date is explicitly unavailable (disabledDates or dateFilter) */
5145
+ isDateUnavailable(date) {
5146
+ const disabled = this.disabledDates();
5147
+ if (disabled.some((d) => this.isSameDay(d, date)))
5148
+ return true;
5149
+ const filter = this.dateFilter();
5150
+ if (filter && !filter(date))
5151
+ return true;
5152
+ return false;
5153
+ }
5154
+ isMonthDisabled(year, month, min, max) {
5155
+ if (min) {
5156
+ const minMonth = new Date(min.getFullYear(), min.getMonth(), 1);
5157
+ if (new Date(year, month + 1, 0) < minMonth)
5158
+ return true;
5159
+ }
5160
+ if (max) {
5161
+ const maxMonth = new Date(max.getFullYear(), max.getMonth() + 1, 0);
5162
+ if (new Date(year, month, 1) > maxMonth)
5163
+ return true;
5164
+ }
5165
+ return false;
5166
+ }
5167
+ isYearDisabled(year, min, max) {
5168
+ if (min && year < min.getFullYear())
5169
+ return true;
5170
+ if (max && year > max.getFullYear())
5171
+ return true;
5172
+ return false;
5173
+ }
5174
+ // ── Private: Focus Management ────────────────────────────────
3799
5175
  setFocusedDate(date) {
3800
5176
  this.currentMonth.set(date.getMonth());
3801
5177
  this.currentYear.set(date.getFullYear());
@@ -3810,6 +5186,41 @@ class AfDatepickerComponent {
3810
5186
  const button = popover.querySelector(`.ct-datepicker__day[data-date="${key}"]`);
3811
5187
  button?.focus();
3812
5188
  }
5189
+ focusMonthButton(monthIndex) {
5190
+ const popover = this.popoverRef()?.nativeElement;
5191
+ if (!popover)
5192
+ return;
5193
+ const buttons = popover.querySelectorAll('.ct-datepicker__month');
5194
+ buttons[monthIndex]?.focus();
5195
+ }
5196
+ focusYearButton(year) {
5197
+ const popover = this.popoverRef()?.nativeElement;
5198
+ if (!popover)
5199
+ return;
5200
+ const buttons = popover.querySelectorAll('.ct-datepicker__year');
5201
+ const pageStart = this.yearPageStart();
5202
+ const index = year - pageStart;
5203
+ if (index >= 0 && index < buttons.length) {
5204
+ buttons[index]?.focus();
5205
+ }
5206
+ }
5207
+ /** Finds the next non-disabled date in the given direction */
5208
+ findNextEnabledDate(from, step) {
5209
+ let next = this.addDays(from, step);
5210
+ let attempts = 0;
5211
+ const singleStep = step > 0 ? 1 : -1;
5212
+ while (this.isDateDisabled(next) && attempts < 365) {
5213
+ next = this.addDays(next, singleStep);
5214
+ attempts++;
5215
+ }
5216
+ return next;
5217
+ }
5218
+ // ── Private: Date Arithmetic ─────────────────────────────────
5219
+ shiftMonth(delta) {
5220
+ const base = this.focusedDate() ?? new Date(this.currentYear(), this.currentMonth(), 1);
5221
+ const next = this.addMonths(base, delta);
5222
+ this.setFocusedDate(next);
5223
+ }
3813
5224
  addDays(date, delta) {
3814
5225
  const next = new Date(date);
3815
5226
  next.setDate(date.getDate() + delta);
@@ -3820,135 +5231,255 @@ class AfDatepickerComponent {
3820
5231
  next.setMonth(date.getMonth() + delta);
3821
5232
  return next;
3822
5233
  }
5234
+ addYears(date, delta) {
5235
+ const next = new Date(date);
5236
+ next.setFullYear(date.getFullYear() + delta);
5237
+ return next;
5238
+ }
3823
5239
  getWeekdayIndex(date) {
3824
5240
  const day = date.getDay();
3825
5241
  return day === 0 ? 6 : day - 1;
3826
5242
  }
3827
- shiftMonth(delta) {
3828
- const base = this.focusedDate() || new Date(this.currentYear(), this.currentMonth(), 1);
3829
- const next = this.addMonths(base, delta);
3830
- this.setFocusedDate(next);
5243
+ // ── Private: Date Comparison & Parsing ───────────────────────
5244
+ isSameDay(a, b) {
5245
+ return (a.getFullYear() === b.getFullYear() &&
5246
+ a.getMonth() === b.getMonth() &&
5247
+ a.getDate() === b.getDate());
3831
5248
  }
3832
- isSameDay(date1, date2) {
3833
- return (date1.getFullYear() === date2.getFullYear() &&
3834
- date1.getMonth() === date2.getMonth() &&
3835
- date1.getDate() === date2.getDate());
5249
+ compareDays(a, b) {
5250
+ const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
5251
+ const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
5252
+ return da - db;
5253
+ }
5254
+ parseDate(value) {
5255
+ if (!value)
5256
+ return null;
5257
+ if (value instanceof Date)
5258
+ return value;
5259
+ if (typeof value === 'string') {
5260
+ const parsed = new Date(value);
5261
+ return isNaN(parsed.getTime()) ? null : parsed;
5262
+ }
5263
+ return null;
5264
+ }
5265
+ coerceToDate(value) {
5266
+ if (!value)
5267
+ return null;
5268
+ if (value instanceof Date)
5269
+ return value;
5270
+ if (typeof value === 'string') {
5271
+ const parsed = new Date(value);
5272
+ return isNaN(parsed.getTime()) ? null : parsed;
5273
+ }
5274
+ return null;
5275
+ }
5276
+ toIsoString(date) {
5277
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
3836
5278
  }
3837
5279
  formatDate(date) {
3838
5280
  const tokens = {
3839
5281
  yyyy: String(date.getFullYear()),
3840
- MMM: this.monthNames[date.getMonth()].substring(0, 3),
5282
+ MMM: MONTH_SHORT[date.getMonth()],
3841
5283
  MM: String(date.getMonth() + 1).padStart(2, '0'),
3842
5284
  dd: String(date.getDate()).padStart(2, '0'),
3843
- d: String(date.getDate())
5285
+ d: String(date.getDate()),
3844
5286
  };
3845
- return this.dateFormat().replace(/yyyy|MMM|MM|dd|d/g, token => tokens[token] ?? token);
3846
- }
3847
- // ControlValueAccessor implementation
3848
- writeValue(value) {
3849
- if (value) {
3850
- this.selectedDate.set(value);
3851
- this.currentMonth.set(value.getMonth());
3852
- this.currentYear.set(value.getFullYear());
3853
- this.focusedDate.set(value);
3854
- }
3855
- else {
3856
- this.selectedDate.set(null);
3857
- this.focusedDate.set(null);
3858
- }
3859
- }
3860
- registerOnChange(fn) {
3861
- this.onChange = fn;
3862
- }
3863
- registerOnTouched(fn) {
3864
- this.onTouched = fn;
3865
- }
3866
- setDisabledState(isDisabled) {
3867
- this.disabled.set(isDisabled);
5287
+ return this.dateFormat().replace(/yyyy|MMM|MM|dd|d/g, (token) => tokens[token] ?? token);
3868
5288
  }
3869
5289
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDatepickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3870
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDatepickerComponent, isStandalone: true, selector: "af-datepicker", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dateFormat: { classPropertyName: "dateFormat", publicName: "dateFormat", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", dateChange: "dateChange" }, host: { listeners: { "document:keydown.escape": "onEscape()", "document:click": "onDocumentClick($event)" } }, providers: [
5290
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfDatepickerComponent, isStandalone: true, selector: "af-datepicker", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, dateFormat: { classPropertyName: "dateFormat", publicName: "dateFormat", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, disabledDates: { classPropertyName: "disabledDates", publicName: "disabledDates", isSignal: true, isRequired: false, transformFunction: null }, dateFilter: { classPropertyName: "dateFilter", publicName: "dateFilter", 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 }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", dateChange: "dateChange", rangeChange: "rangeChange" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, providers: [
3871
5291
  {
3872
5292
  provide: NG_VALUE_ACCESSOR,
3873
5293
  useExisting: forwardRef(() => AfDatepickerComponent),
3874
- multi: true
3875
- }
3876
- ], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["input"], descendants: true, isSignal: true }, { propertyName: "popoverRef", first: true, predicate: ["popover"], descendants: true, isSignal: true }], ngImport: i0, template: `
3877
- <div class="ct-field">
5294
+ multi: true,
5295
+ },
5296
+ {
5297
+ provide: NG_VALIDATORS,
5298
+ useExisting: forwardRef(() => AfDatepickerComponent),
5299
+ multi: true,
5300
+ },
5301
+ ], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "popoverRef", first: true, predicate: ["popoverEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
5302
+ <div class="ct-field" [class.ct-field--error]="error()">
3878
5303
  @if (label()) {
3879
- <label class="ct-field__label" [attr.for]="inputId()">{{ label() }}</label>
5304
+ <label class="ct-field__label" [attr.for]="inputId()">
5305
+ {{ label() }}
5306
+ @if (required()) {
5307
+ <span aria-label="required"> *</span>
5308
+ }
5309
+ </label>
3880
5310
  }
3881
5311
 
3882
5312
  <div class="ct-datepicker" [attr.data-state]="isOpen() ? 'open' : 'closed'">
3883
- <input
3884
- #input
3885
- class="ct-input"
3886
- type="text"
3887
- [id]="inputId()"
3888
- [placeholder]="placeholder()"
3889
- [value]="formattedDate()"
3890
- [disabled]="disabled()"
3891
- [attr.aria-haspopup]="'dialog'"
3892
- [attr.aria-expanded]="isOpen()"
3893
- [attr.aria-controls]="popoverId()"
3894
- [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
3895
- (click)="toggle()"
3896
- (keydown)="onInputKeydown($event)"
3897
- (blur)="onTouched()"
3898
- readonly
3899
- />
5313
+ <div class="ct-datepicker__input-wrap">
5314
+ <input
5315
+ #inputEl
5316
+ class="ct-input"
5317
+ type="text"
5318
+ [id]="inputId()"
5319
+ [placeholder]="placeholder()"
5320
+ [value]="formattedValue()"
5321
+ [disabled]="disabled()"
5322
+ [required]="required()"
5323
+ [attr.aria-haspopup]="'dialog'"
5324
+ [attr.aria-expanded]="isOpen()"
5325
+ [attr.aria-controls]="popoverId()"
5326
+ [attr.aria-invalid]="error() ? true : null"
5327
+ [attr.aria-describedby]="ariaDescribedBy()"
5328
+ [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
5329
+ (click)="toggle()"
5330
+ (keydown)="onInputKeydown($event)"
5331
+ (blur)="onTouched()"
5332
+ readonly
5333
+ />
5334
+ @if (hasClearableValue() && !disabled()) {
5335
+ <button
5336
+ class="ct-datepicker__clear"
5337
+ type="button"
5338
+ aria-label="Clear date"
5339
+ (click)="clearValue($event)">
5340
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
5341
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
5342
+ stroke-linejoin="round" aria-hidden="true">
5343
+ <line x1="18" y1="6" x2="6" y2="18"></line>
5344
+ <line x1="6" y1="6" x2="18" y2="18"></line>
5345
+ </svg>
5346
+ </button>
5347
+ }
5348
+ </div>
3900
5349
 
3901
- @if (isOpen()) {
3902
- <div
3903
- #popover
3904
- class="ct-datepicker__popover"
3905
- role="dialog"
3906
- [id]="popoverId()"
3907
- aria-label="Choose date">
3908
- <div class="ct-datepicker__header">
3909
- <button
3910
- class="ct-button ct-button--ghost ct-button--icon"
3911
- aria-label="Previous month"
3912
- type="button"
3913
- (click)="previousMonth()">
3914
- &#8249;
3915
- </button>
3916
- <div class="ct-datepicker__title">
3917
- {{ monthNames[currentMonth()] }} {{ currentYear() }}
3918
- </div>
3919
- <button
3920
- class="ct-button ct-button--ghost ct-button--icon"
3921
- aria-label="Next month"
3922
- type="button"
3923
- (click)="nextMonth()">
3924
- &#8250;
3925
- </button>
3926
- </div>
5350
+ <div
5351
+ #popoverEl
5352
+ class="ct-datepicker__popover"
5353
+ role="dialog"
5354
+ [id]="popoverId()"
5355
+ [attr.aria-label]="dialogAriaLabel()"
5356
+ [attr.aria-hidden]="!isOpen() || null">
5357
+
5358
+ <div class="ct-datepicker__header">
5359
+ <button
5360
+ class="ct-button ct-button--ghost ct-button--icon"
5361
+ type="button"
5362
+ [attr.aria-label]="prevButtonAriaLabel()"
5363
+ (click)="navigatePrevious()">
5364
+ &#8249;
5365
+ </button>
5366
+ <button
5367
+ class="ct-datepicker__title"
5368
+ type="button"
5369
+ [attr.aria-label]="titleAriaLabel()"
5370
+ (click)="drillUp()">
5371
+ {{ headerTitle() }}
5372
+ </button>
5373
+ <button
5374
+ class="ct-button ct-button--ghost ct-button--icon"
5375
+ type="button"
5376
+ [attr.aria-label]="nextButtonAriaLabel()"
5377
+ (click)="navigateNext()">
5378
+ &#8250;
5379
+ </button>
5380
+ </div>
3927
5381
 
3928
- <div class="ct-datepicker__grid" (keydown)="onGridKeydown($event)">
3929
- @for (day of weekdayLabels; track $index) {
3930
- <div class="ct-datepicker__weekday">{{ day }}</div>
3931
- }
3932
- @for (day of calendarDays(); track day.date.getTime()) {
5382
+ @switch (currentView()) {
5383
+ @case ('days') {
5384
+ <div
5385
+ class="ct-datepicker__grid"
5386
+ role="grid"
5387
+ [attr.aria-label]="gridAriaLabel()"
5388
+ (keydown)="onDayGridKeydown($event)">
5389
+ <div class="ct-datepicker__row" role="row">
5390
+ @for (day of weekdayLabels; track $index) {
5391
+ <div class="ct-datepicker__weekday" role="columnheader" [attr.aria-label]="weekdayFullLabels[$index]">
5392
+ {{ day }}
5393
+ </div>
5394
+ }
5395
+ </div>
5396
+ @for (day of calendarDays(); track day.date.getTime()) {
5397
+ <button
5398
+ class="ct-datepicker__day"
5399
+ [attr.data-date]="getDateKey(day.date)"
5400
+ [attr.data-outside]="!day.isCurrentMonth || null"
5401
+ [attr.data-today]="day.isToday || null"
5402
+ [attr.data-unavailable]="day.isUnavailable || null"
5403
+ [attr.data-highlighted]="isDayHighlighted(day) || null"
5404
+ [attr.data-in-range]="day.isInRange || null"
5405
+ [attr.data-range-start]="day.isRangeStart || null"
5406
+ [attr.data-range-end]="day.isRangeEnd || null"
5407
+ [attr.aria-selected]="day.isSelected ? 'true' : null"
5408
+ [attr.aria-current]="day.isToday ? 'date' : null"
5409
+ [attr.aria-disabled]="day.isDisabled || day.isUnavailable ? 'true' : null"
5410
+ [disabled]="day.isDisabled || day.isUnavailable"
5411
+ [attr.tabindex]="getDayTabIndex(day)"
5412
+ role="gridcell"
5413
+ type="button"
5414
+ (click)="onDayClick(day)">
5415
+ {{ day.date.getDate() }}
5416
+ </button>
5417
+ }
5418
+ </div>
5419
+ <div class="ct-datepicker__footer">
3933
5420
  <button
3934
- class="ct-datepicker__day"
3935
- [attr.data-date]="getDateKey(day.date)"
3936
- [attr.data-outside]="!day.isCurrentMonth"
3937
- [attr.data-today]="day.isToday"
3938
- [attr.aria-selected]="day.isSelected ? 'true' : null"
3939
- [attr.aria-current]="day.isToday ? 'date' : null"
3940
- [attr.tabindex]="getDayTabIndex(day)"
5421
+ class="ct-datepicker__today"
3941
5422
  type="button"
3942
- (click)="selectDate(day.date)">
3943
- {{ day.date.getDate() }}
5423
+ [disabled]="isTodayDisabled()"
5424
+ (click)="goToToday()">
5425
+ Today
3944
5426
  </button>
3945
- }
3946
- </div>
3947
- </div>
3948
- }
5427
+ </div>
5428
+ }
5429
+ @case ('months') {
5430
+ <div
5431
+ class="ct-datepicker__month-grid"
5432
+ role="grid"
5433
+ aria-label="Select month"
5434
+ (keydown)="onMonthGridKeydown($event)">
5435
+ @for (m of monthItems(); track m.index) {
5436
+ <button
5437
+ class="ct-datepicker__month"
5438
+ [attr.aria-selected]="m.isSelected ? 'true' : null"
5439
+ [attr.data-highlighted]="isMonthHighlighted(m.index) || null"
5440
+ [disabled]="m.isDisabled"
5441
+ [attr.tabindex]="getMonthTabIndex(m.index)"
5442
+ role="gridcell"
5443
+ type="button"
5444
+ (click)="selectMonth(m.index)">
5445
+ {{ m.shortLabel }}
5446
+ </button>
5447
+ }
5448
+ </div>
5449
+ }
5450
+ @case ('years') {
5451
+ <div
5452
+ class="ct-datepicker__year-grid"
5453
+ role="grid"
5454
+ aria-label="Select year"
5455
+ (keydown)="onYearGridKeydown($event)">
5456
+ @for (y of yearItems(); track y.value) {
5457
+ <button
5458
+ class="ct-datepicker__year"
5459
+ [attr.aria-selected]="y.isSelected ? 'true' : null"
5460
+ [attr.data-highlighted]="isYearHighlighted(y.value) || null"
5461
+ [disabled]="y.isDisabled"
5462
+ [attr.tabindex]="getYearTabIndex(y.value)"
5463
+ role="gridcell"
5464
+ type="button"
5465
+ (click)="selectYear(y.value)">
5466
+ {{ y.value }}
5467
+ </button>
5468
+ }
5469
+ </div>
5470
+ }
5471
+ }
5472
+ </div>
3949
5473
  </div>
5474
+
5475
+ @if (hint() && !error()) {
5476
+ <div class="ct-field__hint" [id]="hintId()">{{ hint() }}</div>
5477
+ }
5478
+ @if (error()) {
5479
+ <div class="ct-field__error" [id]="errorId()">{{ error() }}</div>
5480
+ }
3950
5481
  </div>
3951
- `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5482
+ `, isInline: true, styles: [":host{display:block}.ct-datepicker__input-wrap{position:relative;display:flex;align-items:center}.ct-datepicker__input-wrap .ct-input{width:100%;padding-inline-end:2.25rem}.ct-datepicker__clear{position:absolute;inset-inline-end:.5rem;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;cursor:pointer;color:var(--color-text-muted);padding:.25rem;border-radius:var(--radius-sm)}.ct-datepicker__clear:hover{color:var(--color-text-primary)}.ct-datepicker__clear:focus-visible{outline:var(--border-medium) solid var(--color-focus-ring);outline-offset:2px}.ct-datepicker__footer{display:flex;justify-content:center}.ct-datepicker__today{appearance:none;border:none;background:transparent;color:var(--color-brand-primary);cursor:pointer;font-size:var(--font-size-sm);padding:var(--space-1) var(--space-3);border-radius:var(--radius-sm);font-weight:var(--font-weight-medium, 500)}.ct-datepicker__today:hover:not(:disabled){background:var(--color-bg-muted)}.ct-datepicker__today:focus-visible{outline:var(--border-medium) solid var(--color-focus-ring);outline-offset:2px}.ct-datepicker__today:disabled{opacity:var(--opacity-disabled, .5);cursor:not-allowed}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3952
5483
  }
3953
5484
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDatepickerComponent, decorators: [{
3954
5485
  type: Component,
@@ -3956,88 +5487,198 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
3956
5487
  {
3957
5488
  provide: NG_VALUE_ACCESSOR,
3958
5489
  useExisting: forwardRef(() => AfDatepickerComponent),
3959
- multi: true
3960
- }
5490
+ multi: true,
5491
+ },
5492
+ {
5493
+ provide: NG_VALIDATORS,
5494
+ useExisting: forwardRef(() => AfDatepickerComponent),
5495
+ multi: true,
5496
+ },
3961
5497
  ], host: {
3962
- '(document:keydown.escape)': 'onEscape()',
3963
5498
  '(document:click)': 'onDocumentClick($event)',
3964
5499
  }, template: `
3965
- <div class="ct-field">
5500
+ <div class="ct-field" [class.ct-field--error]="error()">
3966
5501
  @if (label()) {
3967
- <label class="ct-field__label" [attr.for]="inputId()">{{ label() }}</label>
5502
+ <label class="ct-field__label" [attr.for]="inputId()">
5503
+ {{ label() }}
5504
+ @if (required()) {
5505
+ <span aria-label="required"> *</span>
5506
+ }
5507
+ </label>
3968
5508
  }
3969
5509
 
3970
5510
  <div class="ct-datepicker" [attr.data-state]="isOpen() ? 'open' : 'closed'">
3971
- <input
3972
- #input
3973
- class="ct-input"
3974
- type="text"
3975
- [id]="inputId()"
3976
- [placeholder]="placeholder()"
3977
- [value]="formattedDate()"
3978
- [disabled]="disabled()"
3979
- [attr.aria-haspopup]="'dialog'"
3980
- [attr.aria-expanded]="isOpen()"
3981
- [attr.aria-controls]="popoverId()"
3982
- [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
3983
- (click)="toggle()"
3984
- (keydown)="onInputKeydown($event)"
3985
- (blur)="onTouched()"
3986
- readonly
3987
- />
5511
+ <div class="ct-datepicker__input-wrap">
5512
+ <input
5513
+ #inputEl
5514
+ class="ct-input"
5515
+ type="text"
5516
+ [id]="inputId()"
5517
+ [placeholder]="placeholder()"
5518
+ [value]="formattedValue()"
5519
+ [disabled]="disabled()"
5520
+ [required]="required()"
5521
+ [attr.aria-haspopup]="'dialog'"
5522
+ [attr.aria-expanded]="isOpen()"
5523
+ [attr.aria-controls]="popoverId()"
5524
+ [attr.aria-invalid]="error() ? true : null"
5525
+ [attr.aria-describedby]="ariaDescribedBy()"
5526
+ [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
5527
+ (click)="toggle()"
5528
+ (keydown)="onInputKeydown($event)"
5529
+ (blur)="onTouched()"
5530
+ readonly
5531
+ />
5532
+ @if (hasClearableValue() && !disabled()) {
5533
+ <button
5534
+ class="ct-datepicker__clear"
5535
+ type="button"
5536
+ aria-label="Clear date"
5537
+ (click)="clearValue($event)">
5538
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
5539
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
5540
+ stroke-linejoin="round" aria-hidden="true">
5541
+ <line x1="18" y1="6" x2="6" y2="18"></line>
5542
+ <line x1="6" y1="6" x2="18" y2="18"></line>
5543
+ </svg>
5544
+ </button>
5545
+ }
5546
+ </div>
3988
5547
 
3989
- @if (isOpen()) {
3990
- <div
3991
- #popover
3992
- class="ct-datepicker__popover"
3993
- role="dialog"
3994
- [id]="popoverId()"
3995
- aria-label="Choose date">
3996
- <div class="ct-datepicker__header">
3997
- <button
3998
- class="ct-button ct-button--ghost ct-button--icon"
3999
- aria-label="Previous month"
4000
- type="button"
4001
- (click)="previousMonth()">
4002
- &#8249;
4003
- </button>
4004
- <div class="ct-datepicker__title">
4005
- {{ monthNames[currentMonth()] }} {{ currentYear() }}
4006
- </div>
4007
- <button
4008
- class="ct-button ct-button--ghost ct-button--icon"
4009
- aria-label="Next month"
4010
- type="button"
4011
- (click)="nextMonth()">
4012
- &#8250;
4013
- </button>
4014
- </div>
5548
+ <div
5549
+ #popoverEl
5550
+ class="ct-datepicker__popover"
5551
+ role="dialog"
5552
+ [id]="popoverId()"
5553
+ [attr.aria-label]="dialogAriaLabel()"
5554
+ [attr.aria-hidden]="!isOpen() || null">
5555
+
5556
+ <div class="ct-datepicker__header">
5557
+ <button
5558
+ class="ct-button ct-button--ghost ct-button--icon"
5559
+ type="button"
5560
+ [attr.aria-label]="prevButtonAriaLabel()"
5561
+ (click)="navigatePrevious()">
5562
+ &#8249;
5563
+ </button>
5564
+ <button
5565
+ class="ct-datepicker__title"
5566
+ type="button"
5567
+ [attr.aria-label]="titleAriaLabel()"
5568
+ (click)="drillUp()">
5569
+ {{ headerTitle() }}
5570
+ </button>
5571
+ <button
5572
+ class="ct-button ct-button--ghost ct-button--icon"
5573
+ type="button"
5574
+ [attr.aria-label]="nextButtonAriaLabel()"
5575
+ (click)="navigateNext()">
5576
+ &#8250;
5577
+ </button>
5578
+ </div>
4015
5579
 
4016
- <div class="ct-datepicker__grid" (keydown)="onGridKeydown($event)">
4017
- @for (day of weekdayLabels; track $index) {
4018
- <div class="ct-datepicker__weekday">{{ day }}</div>
4019
- }
4020
- @for (day of calendarDays(); track day.date.getTime()) {
5580
+ @switch (currentView()) {
5581
+ @case ('days') {
5582
+ <div
5583
+ class="ct-datepicker__grid"
5584
+ role="grid"
5585
+ [attr.aria-label]="gridAriaLabel()"
5586
+ (keydown)="onDayGridKeydown($event)">
5587
+ <div class="ct-datepicker__row" role="row">
5588
+ @for (day of weekdayLabels; track $index) {
5589
+ <div class="ct-datepicker__weekday" role="columnheader" [attr.aria-label]="weekdayFullLabels[$index]">
5590
+ {{ day }}
5591
+ </div>
5592
+ }
5593
+ </div>
5594
+ @for (day of calendarDays(); track day.date.getTime()) {
5595
+ <button
5596
+ class="ct-datepicker__day"
5597
+ [attr.data-date]="getDateKey(day.date)"
5598
+ [attr.data-outside]="!day.isCurrentMonth || null"
5599
+ [attr.data-today]="day.isToday || null"
5600
+ [attr.data-unavailable]="day.isUnavailable || null"
5601
+ [attr.data-highlighted]="isDayHighlighted(day) || null"
5602
+ [attr.data-in-range]="day.isInRange || null"
5603
+ [attr.data-range-start]="day.isRangeStart || null"
5604
+ [attr.data-range-end]="day.isRangeEnd || null"
5605
+ [attr.aria-selected]="day.isSelected ? 'true' : null"
5606
+ [attr.aria-current]="day.isToday ? 'date' : null"
5607
+ [attr.aria-disabled]="day.isDisabled || day.isUnavailable ? 'true' : null"
5608
+ [disabled]="day.isDisabled || day.isUnavailable"
5609
+ [attr.tabindex]="getDayTabIndex(day)"
5610
+ role="gridcell"
5611
+ type="button"
5612
+ (click)="onDayClick(day)">
5613
+ {{ day.date.getDate() }}
5614
+ </button>
5615
+ }
5616
+ </div>
5617
+ <div class="ct-datepicker__footer">
4021
5618
  <button
4022
- class="ct-datepicker__day"
4023
- [attr.data-date]="getDateKey(day.date)"
4024
- [attr.data-outside]="!day.isCurrentMonth"
4025
- [attr.data-today]="day.isToday"
4026
- [attr.aria-selected]="day.isSelected ? 'true' : null"
4027
- [attr.aria-current]="day.isToday ? 'date' : null"
4028
- [attr.tabindex]="getDayTabIndex(day)"
5619
+ class="ct-datepicker__today"
4029
5620
  type="button"
4030
- (click)="selectDate(day.date)">
4031
- {{ day.date.getDate() }}
5621
+ [disabled]="isTodayDisabled()"
5622
+ (click)="goToToday()">
5623
+ Today
4032
5624
  </button>
4033
- }
4034
- </div>
4035
- </div>
4036
- }
5625
+ </div>
5626
+ }
5627
+ @case ('months') {
5628
+ <div
5629
+ class="ct-datepicker__month-grid"
5630
+ role="grid"
5631
+ aria-label="Select month"
5632
+ (keydown)="onMonthGridKeydown($event)">
5633
+ @for (m of monthItems(); track m.index) {
5634
+ <button
5635
+ class="ct-datepicker__month"
5636
+ [attr.aria-selected]="m.isSelected ? 'true' : null"
5637
+ [attr.data-highlighted]="isMonthHighlighted(m.index) || null"
5638
+ [disabled]="m.isDisabled"
5639
+ [attr.tabindex]="getMonthTabIndex(m.index)"
5640
+ role="gridcell"
5641
+ type="button"
5642
+ (click)="selectMonth(m.index)">
5643
+ {{ m.shortLabel }}
5644
+ </button>
5645
+ }
5646
+ </div>
5647
+ }
5648
+ @case ('years') {
5649
+ <div
5650
+ class="ct-datepicker__year-grid"
5651
+ role="grid"
5652
+ aria-label="Select year"
5653
+ (keydown)="onYearGridKeydown($event)">
5654
+ @for (y of yearItems(); track y.value) {
5655
+ <button
5656
+ class="ct-datepicker__year"
5657
+ [attr.aria-selected]="y.isSelected ? 'true' : null"
5658
+ [attr.data-highlighted]="isYearHighlighted(y.value) || null"
5659
+ [disabled]="y.isDisabled"
5660
+ [attr.tabindex]="getYearTabIndex(y.value)"
5661
+ role="gridcell"
5662
+ type="button"
5663
+ (click)="selectYear(y.value)">
5664
+ {{ y.value }}
5665
+ </button>
5666
+ }
5667
+ </div>
5668
+ }
5669
+ }
5670
+ </div>
4037
5671
  </div>
5672
+
5673
+ @if (hint() && !error()) {
5674
+ <div class="ct-field__hint" [id]="hintId()">{{ hint() }}</div>
5675
+ }
5676
+ @if (error()) {
5677
+ <div class="ct-field__error" [id]="errorId()">{{ error() }}</div>
5678
+ }
4038
5679
  </div>
4039
- `, styles: [":host{display:block}\n"] }]
4040
- }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], dateFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateFormat", required: false }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }], dateChange: [{ type: i0.Output, args: ["dateChange"] }], inputRef: [{ type: i0.ViewChild, args: ['input', { isSignal: true }] }], popoverRef: [{ type: i0.ViewChild, args: ['popover', { isSignal: true }] }] } });
5680
+ `, styles: [":host{display:block}.ct-datepicker__input-wrap{position:relative;display:flex;align-items:center}.ct-datepicker__input-wrap .ct-input{width:100%;padding-inline-end:2.25rem}.ct-datepicker__clear{position:absolute;inset-inline-end:.5rem;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;cursor:pointer;color:var(--color-text-muted);padding:.25rem;border-radius:var(--radius-sm)}.ct-datepicker__clear:hover{color:var(--color-text-primary)}.ct-datepicker__clear:focus-visible{outline:var(--border-medium) solid var(--color-focus-ring);outline-offset:2px}.ct-datepicker__footer{display:flex;justify-content:center}.ct-datepicker__today{appearance:none;border:none;background:transparent;color:var(--color-brand-primary);cursor:pointer;font-size:var(--font-size-sm);padding:var(--space-1) var(--space-3);border-radius:var(--radius-sm);font-weight:var(--font-weight-medium, 500)}.ct-datepicker__today:hover:not(:disabled){background:var(--color-bg-muted)}.ct-datepicker__today:focus-visible{outline:var(--border-medium) solid var(--color-focus-ring);outline-offset:2px}.ct-datepicker__today:disabled{opacity:var(--opacity-disabled, .5);cursor:not-allowed}\n"] }]
5681
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], dateFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateFormat", required: false }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], disabledDates: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabledDates", required: false }] }], dateFilter: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateFilter", 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 }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], dateChange: [{ type: i0.Output, args: ["dateChange"] }], rangeChange: [{ type: i0.Output, args: ["rangeChange"] }], inputRef: [{ type: i0.ViewChild, args: ['inputEl', { isSignal: true }] }], popoverRef: [{ type: i0.ViewChild, args: ['popoverEl', { isSignal: true }] }] } });
4041
5682
 
4042
5683
  /**
4043
5684
  * Chip component for labels, tags, statuses, and interactive filters.
@@ -4648,21 +6289,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4648
6289
  }], ctorParameters: () => [], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "afTooltip", required: false }] }], afTooltipPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "afTooltipPosition", required: false }] }], afTooltipDelay: [{ type: i0.Input, args: [{ isSignal: true, alias: "afTooltipDelay", required: false }] }], afTooltipDisabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "afTooltipDisabled", required: false }] }] } });
4649
6290
 
4650
6291
  /**
4651
- * Badge component for status indicators
6292
+ * Badge component for status indicators, labels, and counts.
4652
6293
  *
4653
- * @example
4654
- * <af-badge variant="success" icon="+">Approved</af-badge>
4655
- * <af-badge variant="danger">Blocked</af-badge>
6294
+ * Wraps the Construct Design System `ct-badge` with semantic color
6295
+ * variants and optional icon or dot indicators.
6296
+ *
6297
+ * @example Basic usage
6298
+ * <af-badge variant="success">Approved</af-badge>
6299
+ *
6300
+ * @example With icon
6301
+ * <af-badge variant="danger" icon="+">Blocked</af-badge>
6302
+ *
6303
+ * @example With dot indicator
6304
+ * <af-badge variant="info" dot>Online</af-badge>
6305
+ *
6306
+ * @example Status badge for screen readers
6307
+ * <af-badge variant="warning" role="status" ariaLabel="Build status: failing">
6308
+ * Failing
6309
+ * </af-badge>
6310
+ *
6311
+ * @accessibility
6312
+ * - Non-interactive element — no keyboard navigation required.
6313
+ * - Decorative elements (icon, dot) are hidden from screen readers via `aria-hidden`.
6314
+ * - Use `ariaLabel` when the badge has no visible text or when the visual content
6315
+ * alone does not convey the full meaning.
6316
+ * - Set `role="status"` when the badge reflects a live value that screen readers
6317
+ * should announce on change.
4656
6318
  */
4657
6319
  class AfBadgeComponent {
6320
+ /** ARIA role, e.g. `"status"` for live status badges. */
6321
+ role = input('', ...(ngDevMode ? [{ debugName: "role" }] : []));
4658
6322
  /** Accessible label, useful when the badge has no visible text. */
4659
6323
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4660
- /** Color variant. */
6324
+ /** Semantic color variant. */
4661
6325
  variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : []));
4662
- /** Icon character to display */
6326
+ /** Icon character to display before the label. */
4663
6327
  icon = input('', ...(ngDevMode ? [{ debugName: "icon" }] : []));
4664
- /** Show a dot indicator instead of icon */
4665
- dot = input(false, ...(ngDevMode ? [{ debugName: "dot" }] : []));
6328
+ /** Show a dot indicator instead of an icon. */
6329
+ dot = input(false, { ...(ngDevMode ? { debugName: "dot" } : {}), transform: booleanAttribute });
6330
+ /** Computed CSS classes combining base class and variant/icon modifiers. */
4666
6331
  badgeClasses = computed(() => {
4667
6332
  const classes = ['ct-badge'];
4668
6333
  if (this.variant() !== 'default') {
@@ -4674,32 +6339,91 @@ class AfBadgeComponent {
4674
6339
  return classes.join(' ');
4675
6340
  }, ...(ngDevMode ? [{ debugName: "badgeClasses" }] : []));
4676
6341
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4677
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfBadgeComponent, isStandalone: true, selector: "af-badge", inputs: { ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, dot: { classPropertyName: "dot", publicName: "dot", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
4678
- <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
4679
- @if (icon()) {
4680
- <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
4681
- }
4682
- @if (dot()) {
4683
- <span class="ct-badge__dot" aria-hidden="true"></span>
4684
- }
4685
- <ng-content />
4686
- </span>
4687
- `, isInline: true, styles: [":host{display:inline-block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6342
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfBadgeComponent, isStandalone: true, selector: "af-badge", inputs: { role: { classPropertyName: "role", publicName: "role", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, dot: { classPropertyName: "dot", publicName: "dot", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "badgeClasses()", "attr.role": "role() || null", "attr.aria-label": "ariaLabel() || null" } }, ngImport: i0, template: `
6343
+ @if (icon()) {
6344
+ <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
6345
+ }
6346
+ @if (dot()) {
6347
+ <span class="ct-badge__dot" aria-hidden="true"></span>
6348
+ }
6349
+ <ng-content />
6350
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4688
6351
  }
4689
6352
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, decorators: [{
4690
6353
  type: Component,
4691
- args: [{ selector: 'af-badge', changeDetection: ChangeDetectionStrategy.OnPush, template: `
4692
- <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
4693
- @if (icon()) {
4694
- <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
4695
- }
4696
- @if (dot()) {
4697
- <span class="ct-badge__dot" aria-hidden="true"></span>
4698
- }
4699
- <ng-content />
4700
- </span>
4701
- `, styles: [":host{display:inline-block}\n"] }]
4702
- }], propDecorators: { ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
6354
+ args: [{
6355
+ selector: 'af-badge',
6356
+ changeDetection: ChangeDetectionStrategy.OnPush,
6357
+ host: {
6358
+ '[class]': 'badgeClasses()',
6359
+ '[attr.role]': 'role() || null',
6360
+ '[attr.aria-label]': 'ariaLabel() || null',
6361
+ },
6362
+ template: `
6363
+ @if (icon()) {
6364
+ <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
6365
+ }
6366
+ @if (dot()) {
6367
+ <span class="ct-badge__dot" aria-hidden="true"></span>
6368
+ }
6369
+ <ng-content />
6370
+ `,
6371
+ }]
6372
+ }], propDecorators: { role: [{ type: i0.Input, args: [{ isSignal: true, alias: "role", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }] } });
6373
+
6374
+ /**
6375
+ * Test harness for AfBadgeComponent.
6376
+ *
6377
+ * Provides a semantic API for querying badge state in tests,
6378
+ * abstracting DOM details behind readable method names.
6379
+ *
6380
+ * @example
6381
+ * const harness = new AfBadgeHarness(fixture.nativeElement);
6382
+ * expect(harness.getText()).toBe('Approved');
6383
+ * expect(harness.hasClass('ct-badge--success')).toBe(true);
6384
+ */
6385
+ class AfBadgeHarness {
6386
+ hostEl;
6387
+ constructor(container) {
6388
+ const el = container.querySelector('af-badge');
6389
+ if (!el) {
6390
+ throw new Error('AfBadgeHarness: af-badge element not found in container.');
6391
+ }
6392
+ this.hostEl = el;
6393
+ }
6394
+ /** Returns the trimmed text content of the badge (projected content only). */
6395
+ getText() {
6396
+ return this.hostEl.textContent?.trim() ?? '';
6397
+ }
6398
+ /** Returns the full `class` attribute string of the host element. */
6399
+ getClasses() {
6400
+ return this.hostEl.className;
6401
+ }
6402
+ /** Returns whether the host element has the given CSS class. */
6403
+ hasClass(className) {
6404
+ return this.hostEl.classList.contains(className);
6405
+ }
6406
+ /** Returns the `aria-label` attribute value, or `null` if absent. */
6407
+ getAriaLabel() {
6408
+ return this.hostEl.getAttribute('aria-label');
6409
+ }
6410
+ /** Returns the `role` attribute value, or `null` if absent. */
6411
+ getRole() {
6412
+ return this.hostEl.getAttribute('role');
6413
+ }
6414
+ /** Returns whether the badge contains an icon element. */
6415
+ hasIcon() {
6416
+ return this.hostEl.querySelector('.ct-badge__icon') !== null;
6417
+ }
6418
+ /** Returns whether the badge contains a dot indicator. */
6419
+ hasDot() {
6420
+ return this.hostEl.querySelector('.ct-badge__dot') !== null;
6421
+ }
6422
+ /** Returns the text content of the icon element, or empty string if absent. */
6423
+ getIconText() {
6424
+ return this.hostEl.querySelector('.ct-badge__icon')?.textContent?.trim() ?? '';
6425
+ }
6426
+ }
4703
6427
 
4704
6428
  /**
4705
6429
  * Progress bar for showing completion state
@@ -6366,7 +8090,7 @@ class AfFileUploadComponent {
6366
8090
  {{ liveAnnouncement() }}
6367
8091
  </span>
6368
8092
  </div>
6369
- `, isInline: true, styles: [":host{display:block}.af-file-upload__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: AfButtonComponent, selector: "af-button", inputs: ["variant", "size", "type", "disabled", "iconOnly", "ariaLabel", "title"], outputs: ["clicked"] }, { kind: "component", type: AfBadgeComponent, selector: "af-badge", inputs: ["ariaLabel", "variant", "icon", "dot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8093
+ `, isInline: true, styles: [":host{display:block}.af-file-upload__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: AfButtonComponent, selector: "af-button", inputs: ["variant", "size", "type", "disabled", "iconOnly", "ariaLabel", "title"], outputs: ["clicked"] }, { kind: "component", type: AfBadgeComponent, selector: "af-badge", inputs: ["role", "ariaLabel", "variant", "icon", "dot"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6370
8094
  }
6371
8095
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfFileUploadComponent, decorators: [{
6372
8096
  type: Component,
@@ -6465,6 +8189,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6465
8189
  `, styles: [":host{display:block}.af-file-upload__sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}\n"] }]
6466
8190
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], error: [{ type: i0.Input, args: [{ isSignal: true, alias: "error", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSize", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", 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"] }], validationErrors: [{ type: i0.Output, args: ["validationErrors"] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }] } });
6467
8191
 
8192
+ /**
8193
+ * Injection token to override select-menu screen-reader announcements
8194
+ * and the fallback `aria-label`.
8195
+ *
8196
+ * @example
8197
+ * providers: [{
8198
+ * provide: AF_SELECT_MENU_I18N,
8199
+ * useValue: {
8200
+ * selectOption: 'Option auswählen',
8201
+ * opened: '{count} Optionen verfügbar',
8202
+ * closed: 'Auswahl geschlossen',
8203
+ * selected: '{label} ausgewählt',
8204
+ * deselected: '{label} abgewählt',
8205
+ * countSelected: '{count} Optionen ausgewählt',
8206
+ * },
8207
+ * }]
8208
+ */
8209
+ const AF_SELECT_MENU_I18N = new InjectionToken('AfSelectMenuI18n', {
8210
+ factory: () => ({
8211
+ selectOption: 'Select option',
8212
+ opened: '{count} options available',
8213
+ closed: 'Selection closed',
8214
+ selected: '{label} selected',
8215
+ deselected: '{label} deselected',
8216
+ countSelected: '{count} options selected',
8217
+ }),
8218
+ });
8219
+
6468
8220
  /**
6469
8221
  * Custom dropdown select component with keyboard navigation
6470
8222
  * and full ARIA listbox pattern for single and multi-select.
@@ -6485,9 +8237,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6485
8237
  * [multiple]="true"
6486
8238
  * [formControl]="rolesControl">
6487
8239
  * </af-select-menu>
8240
+ *
8241
+ * @accessibility
8242
+ * - Implements the WAI-ARIA Listbox pattern with a combobox trigger.
8243
+ * - Keyboard: ArrowDown/Up to move highlight, Enter/Space to select,
8244
+ * Escape to close, Home/End to jump, Tab to select-and-close (single) or close (multi).
8245
+ * - Focus stays on the combobox trigger; `aria-activedescendant` tracks the highlighted option.
8246
+ * - Screen-reader announcements via {@link AriaLiveAnnouncer} for open/close/selection changes.
8247
+ * - All user-facing strings are configurable via {@link AF_SELECT_MENU_I18N} for i18n.
8248
+ * - Uses CSS logical properties for RTL layout support.
8249
+ * - `aria-describedby` links to hint or error text; `aria-invalid` is set on error state.
8250
+ * - Disabled options are marked with `aria-disabled` and skipped during keyboard navigation.
6488
8251
  */
6489
8252
  class AfSelectMenuComponent {
6490
8253
  static nextId = 0;
8254
+ i18n = inject(AF_SELECT_MENU_I18N);
8255
+ announcer = inject(AriaLiveAnnouncer);
6491
8256
  /** Label shown above the select */
6492
8257
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
6493
8258
  /** Placeholder text when nothing is selected */
@@ -6605,6 +8370,8 @@ class AfSelectMenuComponent {
6605
8370
  this.isOpen.set(true);
6606
8371
  this.highlightedIndex.set(this.findSelectedOrFirstIndex());
6607
8372
  this.scrollHighlightedIntoView();
8373
+ const enabledCount = this.options().filter((o) => !o.disabled).length;
8374
+ this.announcer.announce(this.i18n.opened.replace('{count}', String(enabledCount)));
6608
8375
  }
6609
8376
  /** Closes the listbox */
6610
8377
  closeListbox() {
@@ -6612,6 +8379,7 @@ class AfSelectMenuComponent {
6612
8379
  return;
6613
8380
  this.isOpen.set(false);
6614
8381
  this.highlightedIndex.set(-1);
8382
+ this.announcer.announce(this.i18n.closed);
6615
8383
  }
6616
8384
  /** Selects or toggles an option */
6617
8385
  selectOption(option) {
@@ -6621,15 +8389,21 @@ class AfSelectMenuComponent {
6621
8389
  if (this.multiple()) {
6622
8390
  const current = this.value() ?? [];
6623
8391
  const idx = current.findIndex((v) => cmp(v, option.value));
6624
- const next = idx >= 0
8392
+ const wasSelected = idx >= 0;
8393
+ const next = wasSelected
6625
8394
  ? [...current.slice(0, idx), ...current.slice(idx + 1)]
6626
8395
  : [...current, option.value];
6627
8396
  this.value.set(next);
6628
8397
  this.onChange(next);
8398
+ const msg = wasSelected
8399
+ ? this.i18n.deselected.replace('{label}', option.label)
8400
+ : this.i18n.selected.replace('{label}', option.label);
8401
+ this.announcer.announce(`${msg}, ${this.i18n.countSelected.replace('{count}', String(next.length))}`);
6629
8402
  }
6630
8403
  else {
6631
8404
  this.value.set(option.value);
6632
8405
  this.onChange(option.value);
8406
+ this.announcer.announce(this.i18n.selected.replace('{label}', option.label));
6633
8407
  this.closeListbox();
6634
8408
  this.triggerRef()?.nativeElement.focus();
6635
8409
  }
@@ -6821,7 +8595,7 @@ class AfSelectMenuComponent {
6821
8595
  aria-haspopup="listbox"
6822
8596
  [attr.aria-controls]="listboxId"
6823
8597
  [attr.aria-labelledby]="label() ? labelId : null"
6824
- [attr.aria-label]="label() ? null : 'Select option'"
8598
+ [attr.aria-label]="label() ? null : i18n.selectOption"
6825
8599
  [attr.aria-activedescendant]="activeDescendantId()"
6826
8600
  [attr.aria-invalid]="error() ? true : null"
6827
8601
  [attr.aria-describedby]="getAriaDescribedBy()"
@@ -6928,7 +8702,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6928
8702
  aria-haspopup="listbox"
6929
8703
  [attr.aria-controls]="listboxId"
6930
8704
  [attr.aria-labelledby]="label() ? labelId : null"
6931
- [attr.aria-label]="label() ? null : 'Select option'"
8705
+ [attr.aria-label]="label() ? null : i18n.selectOption"
6932
8706
  [attr.aria-activedescendant]="activeDescendantId()"
6933
8707
  [attr.aria-invalid]="error() ? true : null"
6934
8708
  [attr.aria-describedby]="getAriaDescribedBy()"
@@ -7002,6 +8776,141 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
7002
8776
  `, styles: [":host{display:block}\n"] }]
7003
8777
  }], 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"] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], componentId: [{ type: i0.Input, args: [{ isSignal: true, alias: "componentId", required: false }] }], triggerRef: [{ type: i0.ViewChild, args: ['triggerEl', { isSignal: true }] }], listboxRef: [{ type: i0.ViewChild, args: ['listboxEl', { isSignal: true }] }], menuRef: [{ type: i0.ViewChild, args: ['menuEl', { isSignal: true }] }] } });
7004
8778
 
8779
+ /**
8780
+ * Test harness for AfSelectMenuComponent.
8781
+ *
8782
+ * Provides a semantic API for interacting with the select-menu in tests,
8783
+ * abstracting DOM details behind readable method names.
8784
+ *
8785
+ * @example
8786
+ * const harness = new AfSelectMenuHarness(fixture.nativeElement);
8787
+ * harness.click();
8788
+ * expect(harness.isOpen()).toBe(true);
8789
+ * expect(harness.getOptionCount()).toBe(4);
8790
+ * harness.clickOption(1);
8791
+ * expect(harness.getTriggerText()).toBe('Banana');
8792
+ */
8793
+ class AfSelectMenuHarness {
8794
+ hostEl;
8795
+ constructor(container) {
8796
+ const el = container.querySelector('af-select-menu');
8797
+ if (!el) {
8798
+ throw new Error('AfSelectMenuHarness: af-select-menu element not found in container.');
8799
+ }
8800
+ this.hostEl = el;
8801
+ }
8802
+ /** Returns the trigger `<button>` element. */
8803
+ getTriggerElement() {
8804
+ const btn = this.hostEl.querySelector('.ct-select-menu__trigger');
8805
+ if (!btn) {
8806
+ throw new Error('AfSelectMenuHarness: trigger button not found.');
8807
+ }
8808
+ return btn;
8809
+ }
8810
+ /** Returns the trimmed display text of the trigger. */
8811
+ getTriggerText() {
8812
+ const value = this.hostEl.querySelector('.ct-select-menu__value');
8813
+ return value?.textContent?.trim() ?? '';
8814
+ }
8815
+ /** Clicks the trigger button. */
8816
+ click() {
8817
+ this.getTriggerElement().click();
8818
+ }
8819
+ /** Returns whether the trigger is disabled. */
8820
+ isDisabled() {
8821
+ return this.getTriggerElement().disabled;
8822
+ }
8823
+ /** Returns whether the listbox is currently open. */
8824
+ isOpen() {
8825
+ const menu = this.hostEl.querySelector('.ct-select-menu');
8826
+ return menu?.getAttribute('data-state') === 'open';
8827
+ }
8828
+ /** Returns the `aria-expanded` attribute value. */
8829
+ getAriaExpanded() {
8830
+ return this.getTriggerElement().getAttribute('aria-expanded');
8831
+ }
8832
+ /** Returns the `aria-label` attribute value. */
8833
+ getAriaLabel() {
8834
+ return this.getTriggerElement().getAttribute('aria-label');
8835
+ }
8836
+ /** Returns the `aria-labelledby` attribute value. */
8837
+ getAriaLabelledBy() {
8838
+ return this.getTriggerElement().getAttribute('aria-labelledby');
8839
+ }
8840
+ /** Returns the `aria-activedescendant` attribute value. */
8841
+ getAriaActiveDescendant() {
8842
+ return this.getTriggerElement().getAttribute('aria-activedescendant');
8843
+ }
8844
+ /** Returns the `aria-invalid` attribute value. */
8845
+ getAriaInvalid() {
8846
+ return this.getTriggerElement().getAttribute('aria-invalid');
8847
+ }
8848
+ /** Returns the `aria-required` attribute value. */
8849
+ getAriaRequired() {
8850
+ return this.getTriggerElement().getAttribute('aria-required');
8851
+ }
8852
+ /** Returns the `aria-describedby` attribute value. */
8853
+ getAriaDescribedBy() {
8854
+ return this.getTriggerElement().getAttribute('aria-describedby');
8855
+ }
8856
+ /** Returns all option elements inside the listbox. */
8857
+ getOptions() {
8858
+ return Array.from(this.hostEl.querySelectorAll('[role="option"]'));
8859
+ }
8860
+ /** Returns the trimmed text of the option at the given index. */
8861
+ getOptionText(index) {
8862
+ const options = this.getOptions();
8863
+ if (index < 0 || index >= options.length) {
8864
+ throw new Error(`AfSelectMenuHarness: option index ${index} out of bounds (${options.length} options).`);
8865
+ }
8866
+ return options[index].textContent?.trim() ?? '';
8867
+ }
8868
+ /** Returns the number of options. */
8869
+ getOptionCount() {
8870
+ return this.getOptions().length;
8871
+ }
8872
+ /** Returns whether the option at the given index is selected. */
8873
+ isOptionSelected(index) {
8874
+ return this.getOptions()[index]?.getAttribute('aria-selected') === 'true';
8875
+ }
8876
+ /** Returns whether the option at the given index is disabled. */
8877
+ isOptionDisabled(index) {
8878
+ return this.getOptions()[index]?.getAttribute('aria-disabled') === 'true';
8879
+ }
8880
+ /** Returns whether the option at the given index is highlighted. */
8881
+ isOptionHighlighted(index) {
8882
+ return this.getOptions()[index]?.hasAttribute('data-highlighted') ?? false;
8883
+ }
8884
+ /** Clicks the option at the given index. */
8885
+ clickOption(index) {
8886
+ const options = this.getOptions();
8887
+ if (index < 0 || index >= options.length) {
8888
+ throw new Error(`AfSelectMenuHarness: option index ${index} out of bounds (${options.length} options).`);
8889
+ }
8890
+ options[index].click();
8891
+ }
8892
+ /** Returns the trimmed label text, or empty string if no label. */
8893
+ getLabelText() {
8894
+ const label = this.hostEl.querySelector('.ct-field__label');
8895
+ return label?.textContent?.trim() ?? '';
8896
+ }
8897
+ /** Returns the trimmed hint text, or empty string if no hint. */
8898
+ getHintText() {
8899
+ const hint = this.hostEl.querySelector('.ct-field__hint');
8900
+ return hint?.textContent?.trim() ?? '';
8901
+ }
8902
+ /** Returns the trimmed error text, or empty string if no error. */
8903
+ getErrorText() {
8904
+ const error = this.hostEl.querySelector('.ct-field__error');
8905
+ return error?.textContent?.trim() ?? '';
8906
+ }
8907
+ /** Returns whether the select-menu wrapper has the given CSS class. */
8908
+ hasClass(className) {
8909
+ const menu = this.hostEl.querySelector('.ct-select-menu');
8910
+ return menu?.classList.contains(className) ?? false;
8911
+ }
8912
+ }
8913
+
7005
8914
  let nextId = 0;
7006
8915
  /**
7007
8916
  * Individual navigation item used within af-navbar or af-toolbar.
@@ -8483,5 +10392,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
8483
10392
  * Generated bundle index. Do not edit.
8484
10393
  */
8485
10394
 
8486
- export { AfAccordionComponent, AfAccordionItemComponent, AfAlertComponent, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectMenuComponent, 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 };
8487
10396
  //# sourceMappingURL=neuravision-ng-construct.mjs.map