@neuravision/ng-construct 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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.
@@ -909,15 +1195,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
909
1195
  }], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: false }] }] } });
910
1196
 
911
1197
  /**
912
- * Button component from the Construct Design System
1198
+ * Button component from the Construct Design System.
913
1199
  *
914
- * @example
915
- * <af-button variant="primary" (clicked)="handleClick()">Click me</af-button>
1200
+ * Wraps a native `<button>` element with design system tokens and
1201
+ * variant/size modifiers.
1202
+ *
1203
+ * @example Basic usage
1204
+ * <af-button variant="primary" (clicked)="save()">Save</af-button>
916
1205
  *
917
- * @example Icon-only button
1206
+ * @example Variants
1207
+ * <af-button variant="secondary">Secondary</af-button>
1208
+ * <af-button variant="ghost">Ghost</af-button>
1209
+ * <af-button variant="outline">Outline</af-button>
1210
+ * <af-button variant="danger">Danger</af-button>
1211
+ * <af-button variant="accent">Accent</af-button>
1212
+ * <af-button variant="link">Link</af-button>
1213
+ *
1214
+ * @example Icon-only button (ariaLabel required)
918
1215
  * <af-button variant="ghost" size="sm" iconOnly ariaLabel="Delete item">
919
1216
  * <af-icon name="delete" />
920
1217
  * </af-button>
1218
+ *
1219
+ * @example Disabled button
1220
+ * <af-button [disabled]="true">Cannot click</af-button>
1221
+ *
1222
+ * @accessibility
1223
+ * - Uses native `<button>` element — keyboard (Enter/Space) and screen reader support built-in.
1224
+ * - Disabled state uses the native `disabled` attribute.
1225
+ * - Icon-only buttons must provide `ariaLabel` for screen reader users.
1226
+ * A dev-mode warning is emitted if `ariaLabel` is missing on an icon-only button.
1227
+ * - Focus indicator: 2px outline via `:focus-visible` (design system CSS).
1228
+ * - Reduced motion: `transform` animation disabled via `prefers-reduced-motion`.
921
1229
  */
922
1230
  class AfButtonComponent {
923
1231
  /** Button variant/style */
@@ -949,6 +1257,11 @@ class AfButtonComponent {
949
1257
  }
950
1258
  return classes.join(' ');
951
1259
  }, ...(ngDevMode ? [{ debugName: "buttonClasses" }] : []));
1260
+ iconOnlyWarning = effect(() => {
1261
+ if (isDevMode() && this.iconOnly() && !this.ariaLabel()) {
1262
+ console.warn('af-button: iconOnly buttons require an ariaLabel for screen readers.');
1263
+ }
1264
+ }, ...(ngDevMode ? [{ debugName: "iconOnlyWarning" }] : []));
952
1265
  handleClick(event) {
953
1266
  if (!this.disabled()) {
954
1267
  this.clicked.emit(event);
@@ -983,7 +1296,88 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
983
1296
  }], 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
1297
 
985
1298
  /**
986
- * Input field component with form control support
1299
+ * Test harness for AfButtonComponent.
1300
+ *
1301
+ * Provides a semantic API for interacting with the button in tests,
1302
+ * abstracting DOM details behind readable method names.
1303
+ *
1304
+ * @example
1305
+ * const harness = new AfButtonHarness(fixture.nativeElement);
1306
+ * expect(harness.getText()).toBe('Save');
1307
+ * expect(harness.isDisabled()).toBe(false);
1308
+ * harness.click();
1309
+ */
1310
+ class AfButtonHarness {
1311
+ hostEl;
1312
+ constructor(container) {
1313
+ const el = container.querySelector('af-button');
1314
+ if (!el) {
1315
+ throw new Error('AfButtonHarness: af-button element not found in container.');
1316
+ }
1317
+ this.hostEl = el;
1318
+ }
1319
+ /** Returns the inner native `<button>` element. */
1320
+ getButtonElement() {
1321
+ const btn = this.hostEl.querySelector('button');
1322
+ if (!btn) {
1323
+ throw new Error('AfButtonHarness: inner <button> element not found.');
1324
+ }
1325
+ return btn;
1326
+ }
1327
+ /** Returns the trimmed text content of the button. */
1328
+ getText() {
1329
+ return this.getButtonElement().textContent?.trim() ?? '';
1330
+ }
1331
+ /** Returns whether the button is disabled. */
1332
+ isDisabled() {
1333
+ return this.getButtonElement().disabled;
1334
+ }
1335
+ /** Clicks the inner button element. */
1336
+ click() {
1337
+ this.getButtonElement().click();
1338
+ }
1339
+ /** Returns the `aria-label` attribute value, or `null` if absent. */
1340
+ getAriaLabel() {
1341
+ return this.getButtonElement().getAttribute('aria-label');
1342
+ }
1343
+ /** Returns the `title` attribute value, or `null` if absent. */
1344
+ getTitle() {
1345
+ return this.getButtonElement().getAttribute('title');
1346
+ }
1347
+ /** Returns the `type` attribute of the button. */
1348
+ getType() {
1349
+ return this.getButtonElement().type;
1350
+ }
1351
+ /** Returns the full `class` attribute string of the inner button. */
1352
+ getClasses() {
1353
+ return this.getButtonElement().className;
1354
+ }
1355
+ /** Returns whether the inner button has the given CSS class. */
1356
+ hasClass(className) {
1357
+ return this.getButtonElement().classList.contains(className);
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Injection token to override input screen-reader announcements.
1363
+ *
1364
+ * @example
1365
+ * providers: [{
1366
+ * provide: AF_INPUT_I18N,
1367
+ * useValue: { required: 'Pflichtfeld' },
1368
+ * }]
1369
+ */
1370
+ const AF_INPUT_I18N = new InjectionToken('AfInputI18n', {
1371
+ factory: () => ({
1372
+ required: 'required',
1373
+ }),
1374
+ });
1375
+
1376
+ /**
1377
+ * Input field component with form control support.
1378
+ *
1379
+ * Wraps a native `<input>` element with label, hint, error, and icon slots.
1380
+ * Implements `ControlValueAccessor` for seamless `ngModel` and `formControl` integration.
987
1381
  *
988
1382
  * @example
989
1383
  * <af-input
@@ -992,40 +1386,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
992
1386
  * placeholder="name@company.com"
993
1387
  * [(ngModel)]="email"
994
1388
  * hint="We will not share this."
995
- * ></af-input>
1389
+ * />
996
1390
  *
997
1391
  * @example
998
1392
  * <af-input
999
1393
  * label="Name"
1000
1394
  * [error]="nameError"
1001
1395
  * required
1002
- * ></af-input>
1396
+ * />
1003
1397
  */
1004
1398
  class AfInputComponent {
1005
1399
  static nextId = 0;
1006
- /** Input label */
1400
+ i18n = inject(AF_INPUT_I18N);
1401
+ /** Input label. */
1007
1402
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
1008
- /** Input type */
1403
+ /** Input type. */
1009
1404
  type = input('text', ...(ngDevMode ? [{ debugName: "type" }] : []));
1010
- /** Placeholder text */
1405
+ /** Placeholder text. */
1011
1406
  placeholder = input('', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
1012
- /** Hint text shown below input */
1407
+ /** Hint text shown below input. */
1013
1408
  hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
1014
- /** Error message - shows error state and message */
1409
+ /** Error message shows error state and message. */
1015
1410
  error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
1016
- /** Whether input is required */
1017
- required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
1018
- /** Whether input is disabled */
1411
+ /** Whether input is required. */
1412
+ required = input(false, { ...(ngDevMode ? { debugName: "required" } : {}), transform: booleanAttribute });
1413
+ /** Whether input is disabled. */
1019
1414
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
1020
- /** Icon position (if icon content is projected) */
1415
+ /** Icon position (if icon content is projected). */
1021
1416
  iconPosition = input(null, ...(ngDevMode ? [{ debugName: "iconPosition" }] : []));
1022
- /** Unique input ID */
1417
+ /** Unique input ID. */
1023
1418
  inputId = input(`af-input-${AfInputComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "inputId" }] : []));
1024
- value = '';
1419
+ /** @docs-private — internal form value managed by CVA. */
1420
+ value = signal('', ...(ngDevMode ? [{ debugName: "value" }] : []));
1025
1421
  onChange = () => { };
1026
1422
  onTouched = () => { };
1423
+ /** Computed hint element ID. */
1027
1424
  hintId = computed(() => `${this.inputId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
1425
+ /** Computed error element ID. */
1028
1426
  errorId = computed(() => `${this.inputId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
1427
+ /** Computed CSS classes for the inner input. */
1029
1428
  inputClasses = computed(() => {
1030
1429
  const classes = ['ct-input'];
1031
1430
  if (this.iconPosition()) {
@@ -1033,45 +1432,49 @@ class AfInputComponent {
1033
1432
  }
1034
1433
  return classes.join(' ');
1035
1434
  }, ...(ngDevMode ? [{ debugName: "inputClasses" }] : []));
1036
- getAriaDescribedBy() {
1435
+ /** Computed `aria-describedby` value linking to hint or error. */
1436
+ ariaDescribedBy = computed(() => {
1037
1437
  if (this.error())
1038
1438
  return this.errorId();
1039
1439
  if (this.hint())
1040
1440
  return this.hintId();
1041
1441
  return null;
1042
- }
1442
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
1043
1443
  onInput(event) {
1044
1444
  const target = event.target;
1045
- this.value = target.value;
1046
- this.onChange(this.value);
1445
+ this.value.set(target.value);
1446
+ this.onChange(this.value());
1047
1447
  }
1048
- /** ControlValueAccessor implementation */
1448
+ /** @docs-private */
1049
1449
  writeValue(value) {
1050
- this.value = value || '';
1450
+ this.value.set(value || '');
1051
1451
  }
1452
+ /** @docs-private */
1052
1453
  registerOnChange(fn) {
1053
1454
  this.onChange = fn;
1054
1455
  }
1456
+ /** @docs-private */
1055
1457
  registerOnTouched(fn) {
1056
1458
  this.onTouched = fn;
1057
1459
  }
1460
+ /** @docs-private */
1058
1461
  setDisabledState(isDisabled) {
1059
1462
  this.disabled.set(isDisabled);
1060
1463
  }
1061
1464
  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: [
1465
+ 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
1466
  {
1064
1467
  provide: NG_VALUE_ACCESSOR,
1065
1468
  useExisting: forwardRef(() => AfInputComponent),
1066
- multi: true
1067
- }
1469
+ multi: true,
1470
+ },
1068
1471
  ], ngImport: i0, template: `
1069
1472
  <div class="ct-field" [class.ct-field--error]="error()">
1070
1473
  @if (label()) {
1071
1474
  <label class="ct-field__label" [attr.for]="inputId()">
1072
1475
  {{ label() }}
1073
1476
  @if (required()) {
1074
- <span aria-label="required"> *</span>
1477
+ <span [attr.aria-label]="i18n.required"> *</span>
1075
1478
  }
1076
1479
  </label>
1077
1480
  }
@@ -1080,7 +1483,7 @@ class AfInputComponent {
1080
1483
  <div class="ct-input-wrap">
1081
1484
  @if (iconPosition() === 'left') {
1082
1485
  <span class="ct-input__icon" aria-hidden="true">
1083
- <ng-content select="[icon]"></ng-content>
1486
+ <ng-content select="[icon]" />
1084
1487
  </span>
1085
1488
  }
1086
1489
  <input
@@ -1090,21 +1493,19 @@ class AfInputComponent {
1090
1493
  [disabled]="disabled()"
1091
1494
  [required]="required()"
1092
1495
  [attr.aria-invalid]="error() ? true : null"
1093
- [attr.aria-describedby]="getAriaDescribedBy()"
1496
+ [attr.aria-describedby]="ariaDescribedBy()"
1094
1497
  [class]="inputClasses()"
1095
- [value]="value"
1498
+ [value]="value()"
1096
1499
  (input)="onInput($event)"
1097
1500
  (blur)="onTouched()"
1098
1501
  />
1099
1502
  @if (iconPosition() === 'right') {
1100
1503
  <span class="ct-input__icon" aria-hidden="true">
1101
- <ng-content select="[icon]"></ng-content>
1504
+ <ng-content select="[icon]" />
1102
1505
  </span>
1103
1506
  }
1104
1507
  </div>
1105
- }
1106
-
1107
- @if (!iconPosition()) {
1508
+ } @else {
1108
1509
  <input
1109
1510
  [id]="inputId()"
1110
1511
  [type]="type()"
@@ -1112,9 +1513,9 @@ class AfInputComponent {
1112
1513
  [disabled]="disabled()"
1113
1514
  [required]="required()"
1114
1515
  [attr.aria-invalid]="error() ? true : null"
1115
- [attr.aria-describedby]="getAriaDescribedBy()"
1116
- class="ct-input"
1117
- [value]="value"
1516
+ [attr.aria-describedby]="ariaDescribedBy()"
1517
+ [class]="inputClasses()"
1518
+ [value]="value()"
1118
1519
  (input)="onInput($event)"
1119
1520
  (blur)="onTouched()"
1120
1521
  />
@@ -1127,28 +1528,35 @@ class AfInputComponent {
1127
1528
  }
1128
1529
 
1129
1530
  @if (error()) {
1130
- <div class="ct-field__error" [id]="errorId()">
1531
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1131
1532
  {{ error() }}
1132
1533
  </div>
1133
1534
  }
1134
1535
  </div>
1135
- `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1536
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1136
1537
  }
1137
1538
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfInputComponent, decorators: [{
1138
1539
  type: Component,
1139
- args: [{ selector: 'af-input', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1540
+ args: [{
1541
+ selector: 'af-input',
1542
+ changeDetection: ChangeDetectionStrategy.OnPush,
1543
+ providers: [
1140
1544
  {
1141
1545
  provide: NG_VALUE_ACCESSOR,
1142
1546
  useExisting: forwardRef(() => AfInputComponent),
1143
- multi: true
1144
- }
1145
- ], template: `
1547
+ multi: true,
1548
+ },
1549
+ ],
1550
+ host: {
1551
+ style: 'display: block',
1552
+ },
1553
+ template: `
1146
1554
  <div class="ct-field" [class.ct-field--error]="error()">
1147
1555
  @if (label()) {
1148
1556
  <label class="ct-field__label" [attr.for]="inputId()">
1149
1557
  {{ label() }}
1150
1558
  @if (required()) {
1151
- <span aria-label="required"> *</span>
1559
+ <span [attr.aria-label]="i18n.required"> *</span>
1152
1560
  }
1153
1561
  </label>
1154
1562
  }
@@ -1157,7 +1565,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1157
1565
  <div class="ct-input-wrap">
1158
1566
  @if (iconPosition() === 'left') {
1159
1567
  <span class="ct-input__icon" aria-hidden="true">
1160
- <ng-content select="[icon]"></ng-content>
1568
+ <ng-content select="[icon]" />
1161
1569
  </span>
1162
1570
  }
1163
1571
  <input
@@ -1167,21 +1575,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1167
1575
  [disabled]="disabled()"
1168
1576
  [required]="required()"
1169
1577
  [attr.aria-invalid]="error() ? true : null"
1170
- [attr.aria-describedby]="getAriaDescribedBy()"
1578
+ [attr.aria-describedby]="ariaDescribedBy()"
1171
1579
  [class]="inputClasses()"
1172
- [value]="value"
1580
+ [value]="value()"
1173
1581
  (input)="onInput($event)"
1174
1582
  (blur)="onTouched()"
1175
1583
  />
1176
1584
  @if (iconPosition() === 'right') {
1177
1585
  <span class="ct-input__icon" aria-hidden="true">
1178
- <ng-content select="[icon]"></ng-content>
1586
+ <ng-content select="[icon]" />
1179
1587
  </span>
1180
1588
  }
1181
1589
  </div>
1182
- }
1183
-
1184
- @if (!iconPosition()) {
1590
+ } @else {
1185
1591
  <input
1186
1592
  [id]="inputId()"
1187
1593
  [type]="type()"
@@ -1189,9 +1595,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1189
1595
  [disabled]="disabled()"
1190
1596
  [required]="required()"
1191
1597
  [attr.aria-invalid]="error() ? true : null"
1192
- [attr.aria-describedby]="getAriaDescribedBy()"
1193
- class="ct-input"
1194
- [value]="value"
1598
+ [attr.aria-describedby]="ariaDescribedBy()"
1599
+ [class]="inputClasses()"
1600
+ [value]="value()"
1195
1601
  (input)="onInput($event)"
1196
1602
  (blur)="onTouched()"
1197
1603
  />
@@ -1204,14 +1610,123 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
1204
1610
  }
1205
1611
 
1206
1612
  @if (error()) {
1207
- <div class="ct-field__error" [id]="errorId()">
1613
+ <div class="ct-field__error" role="alert" [id]="errorId()">
1208
1614
  {{ error() }}
1209
1615
  </div>
1210
1616
  }
1211
1617
  </div>
1212
- `, styles: [":host{display:block}\n"] }]
1618
+ `,
1619
+ }]
1213
1620
  }], 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
1621
 
1622
+ /**
1623
+ * Test harness for AfInputComponent.
1624
+ *
1625
+ * Provides a semantic API for interacting with the input in tests,
1626
+ * abstracting DOM details behind readable method names.
1627
+ *
1628
+ * @example
1629
+ * const harness = new AfInputHarness(fixture.nativeElement);
1630
+ * expect(harness.getValue()).toBe('');
1631
+ * harness.setValue('hello');
1632
+ * expect(harness.getValue()).toBe('hello');
1633
+ */
1634
+ class AfInputHarness {
1635
+ hostEl;
1636
+ constructor(container) {
1637
+ const el = container.querySelector('af-input');
1638
+ if (!el) {
1639
+ throw new Error('AfInputHarness: af-input element not found in container.');
1640
+ }
1641
+ this.hostEl = el;
1642
+ }
1643
+ /** Returns the inner native `<input>` element. */
1644
+ getInputElement() {
1645
+ const input = this.hostEl.querySelector('input');
1646
+ if (!input) {
1647
+ throw new Error('AfInputHarness: inner <input> element not found.');
1648
+ }
1649
+ return input;
1650
+ }
1651
+ /** Returns the current value of the input. */
1652
+ getValue() {
1653
+ return this.getInputElement().value;
1654
+ }
1655
+ /** Sets the input value and dispatches an `input` event. */
1656
+ setValue(value) {
1657
+ const input = this.getInputElement();
1658
+ input.value = value;
1659
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1660
+ }
1661
+ /** Returns the label text, or `null` if no label is rendered. */
1662
+ getLabel() {
1663
+ const label = this.hostEl.querySelector('.ct-field__label');
1664
+ return label?.textContent?.trim() ?? null;
1665
+ }
1666
+ /** Returns the hint text, or `null` if no hint is rendered. */
1667
+ getHint() {
1668
+ const hint = this.hostEl.querySelector('.ct-field__hint');
1669
+ return hint?.textContent?.trim() ?? null;
1670
+ }
1671
+ /** Returns the error text, or `null` if no error is rendered. */
1672
+ getError() {
1673
+ const error = this.hostEl.querySelector('.ct-field__error');
1674
+ return error?.textContent?.trim() ?? null;
1675
+ }
1676
+ /** Returns whether the input is disabled. */
1677
+ isDisabled() {
1678
+ return this.getInputElement().disabled;
1679
+ }
1680
+ /** Returns whether the input is required. */
1681
+ isRequired() {
1682
+ return this.getInputElement().required;
1683
+ }
1684
+ /** Returns whether `aria-invalid` is set to `"true"`. */
1685
+ isInvalid() {
1686
+ return this.getInputElement().getAttribute('aria-invalid') === 'true';
1687
+ }
1688
+ /** Returns the `type` attribute of the input. */
1689
+ getType() {
1690
+ return this.getInputElement().type;
1691
+ }
1692
+ /** Returns the `placeholder` attribute of the input. */
1693
+ getPlaceholder() {
1694
+ return this.getInputElement().placeholder;
1695
+ }
1696
+ /** Returns the `aria-describedby` attribute value, or `null` if absent. */
1697
+ getAriaDescribedBy() {
1698
+ return this.getInputElement().getAttribute('aria-describedby');
1699
+ }
1700
+ /** Focuses the input element. */
1701
+ focus() {
1702
+ this.getInputElement().focus();
1703
+ }
1704
+ /** Blurs the input element and dispatches a `blur` event. */
1705
+ blur() {
1706
+ this.getInputElement().dispatchEvent(new Event('blur', { bubbles: true }));
1707
+ }
1708
+ /** Returns the `id` attribute of the input. */
1709
+ getId() {
1710
+ return this.getInputElement().id;
1711
+ }
1712
+ /** Returns whether the field wrapper has the error modifier class. */
1713
+ hasFieldError() {
1714
+ return this.hostEl.querySelector('.ct-field--error') !== null;
1715
+ }
1716
+ /** Returns whether an icon wrapper is rendered. */
1717
+ hasIcon() {
1718
+ return this.hostEl.querySelector('.ct-input__icon') !== null;
1719
+ }
1720
+ /** Returns the full `class` attribute string of the inner input. */
1721
+ getClasses() {
1722
+ return this.getInputElement().className;
1723
+ }
1724
+ /** Returns whether the inner input has the given CSS class. */
1725
+ hasClass(className) {
1726
+ return this.getInputElement().classList.contains(className);
1727
+ }
1728
+ }
1729
+
1215
1730
  /**
1216
1731
  * Select dropdown component with form control support
1217
1732
  *
@@ -3579,164 +4094,493 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
3579
4094
  `, styles: [":host{display:block}\n"] }]
3580
4095
  }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }] } });
3581
4096
 
4097
+ const MONTH_NAMES = [
4098
+ 'January',
4099
+ 'February',
4100
+ 'March',
4101
+ 'April',
4102
+ 'May',
4103
+ 'June',
4104
+ 'July',
4105
+ 'August',
4106
+ 'September',
4107
+ 'October',
4108
+ 'November',
4109
+ 'December',
4110
+ ];
4111
+ const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
4112
+ const WEEKDAY_LABELS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
3582
4113
  /**
3583
- * Datepicker component with calendar popup
4114
+ * Datepicker component with calendar popup, month/year views, range selection,
4115
+ * min/max constraints, disabled dates, and full keyboard navigation.
4116
+ *
4117
+ * Implements WAI-ARIA Date Picker Dialog pattern with roving tabindex.
3584
4118
  *
3585
4119
  * @example
3586
4120
  * <af-datepicker
3587
- * label="Select date"
4121
+ * label="Start date"
3588
4122
  * placeholder="Pick a date"
3589
- * [(ngModel)]="selectedDate">
3590
- * </af-datepicker>
4123
+ * [(ngModel)]="selectedDate"
4124
+ * [min]="minDate"
4125
+ * [max]="maxDate"
4126
+ * hint="Choose a date within the project timeline"
4127
+ * />
4128
+ *
4129
+ * @example
4130
+ * <af-datepicker
4131
+ * label="Period"
4132
+ * mode="range"
4133
+ * [(ngModel)]="dateRange"
4134
+ * valueFormat="iso"
4135
+ * />
3591
4136
  */
3592
4137
  class AfDatepickerComponent {
3593
4138
  static nextId = 0;
3594
- /** Input label */
4139
+ // ── Public Inputs ────────────────────────────────────────────
4140
+ /** Field label */
3595
4141
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
3596
- /** Placeholder text */
4142
+ /** Input placeholder text */
3597
4143
  placeholder = input('Select date', ...(ngDevMode ? [{ debugName: "placeholder" }] : []));
3598
- /** Whether datepicker is disabled */
4144
+ /** Whether the datepicker is disabled */
3599
4145
  disabled = model(false, ...(ngDevMode ? [{ debugName: "disabled" }] : []));
3600
- /** Date format for display */
4146
+ /** Display format for the selected date */
3601
4147
  dateFormat = input('MMM dd, yyyy', ...(ngDevMode ? [{ debugName: "dateFormat" }] : []));
3602
- /** Unique input ID */
4148
+ /** Unique ID for the input element */
3603
4149
  inputId = input(`af-datepicker-${AfDatepickerComponent.nextId++}`, ...(ngDevMode ? [{ debugName: "inputId" }] : []));
3604
- /** Selected date change event */
4150
+ /** Minimum selectable date */
4151
+ min = input(null, ...(ngDevMode ? [{ debugName: "min" }] : []));
4152
+ /** Maximum selectable date */
4153
+ max = input(null, ...(ngDevMode ? [{ debugName: "max" }] : []));
4154
+ /** Array of specific dates that cannot be selected */
4155
+ disabledDates = input([], ...(ngDevMode ? [{ debugName: "disabledDates" }] : []));
4156
+ /**
4157
+ * Predicate function to determine if a date is selectable.
4158
+ * Return `true` to allow selection, `false` to disable.
4159
+ */
4160
+ dateFilter = input(null, ...(ngDevMode ? [{ debugName: "dateFilter" }] : []));
4161
+ /** Hint text displayed below the input */
4162
+ hint = input('', ...(ngDevMode ? [{ debugName: "hint" }] : []));
4163
+ /** Error message — displays error state and message */
4164
+ error = input('', ...(ngDevMode ? [{ debugName: "error" }] : []));
4165
+ /** Whether the field is required */
4166
+ required = input(false, ...(ngDevMode ? [{ debugName: "required" }] : []));
4167
+ /** Selection mode: single date or date range */
4168
+ mode = input('single', ...(ngDevMode ? [{ debugName: "mode" }] : []));
4169
+ /**
4170
+ * Value format for ControlValueAccessor.
4171
+ * `'date'` emits `Date` objects, `'iso'` emits ISO date strings (`yyyy-MM-dd`).
4172
+ */
4173
+ valueFormat = input('date', ...(ngDevMode ? [{ debugName: "valueFormat" }] : []));
4174
+ /** Emitted when a single date is selected */
3605
4175
  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
- ];
4176
+ /** Emitted when a date range is selected (range mode only) */
4177
+ rangeChange = output();
4178
+ // ── Template References ──────────────────────────────────────
4179
+ inputRef = viewChild('inputEl', ...(ngDevMode ? [{ debugName: "inputRef" }] : []));
4180
+ popoverRef = viewChild('popoverEl', ...(ngDevMode ? [{ debugName: "popoverRef" }] : []));
4181
+ // ── Constants ────────────────────────────────────────────────
4182
+ weekdayLabels = WEEKDAY_LABELS;
4183
+ weekdayFullLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
4184
+ // ── Internal State ───────────────────────────────────────────
3613
4185
  selectedDate = signal(null, ...(ngDevMode ? [{ debugName: "selectedDate" }] : []));
4186
+ rangeStart = signal(null, ...(ngDevMode ? [{ debugName: "rangeStart" }] : []));
4187
+ rangeEnd = signal(null, ...(ngDevMode ? [{ debugName: "rangeEnd" }] : []));
4188
+ rangeSelecting = signal(false, ...(ngDevMode ? [{ debugName: "rangeSelecting" }] : []));
3614
4189
  currentMonth = signal(new Date().getMonth(), ...(ngDevMode ? [{ debugName: "currentMonth" }] : []));
3615
4190
  currentYear = signal(new Date().getFullYear(), ...(ngDevMode ? [{ debugName: "currentYear" }] : []));
3616
4191
  isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
3617
4192
  focusedDate = signal(null, ...(ngDevMode ? [{ debugName: "focusedDate" }] : []));
4193
+ currentView = signal('days', ...(ngDevMode ? [{ debugName: "currentView" }] : []));
4194
+ focusedMonth = signal(new Date().getMonth(), ...(ngDevMode ? [{ debugName: "focusedMonth" }] : []));
4195
+ focusedYear = signal(new Date().getFullYear(), ...(ngDevMode ? [{ debugName: "focusedYear" }] : []));
4196
+ yearPageStart = signal(Math.floor(new Date().getFullYear() / 12) * 12, ...(ngDevMode ? [{ debugName: "yearPageStart" }] : []));
3618
4197
  onChange = () => { };
3619
4198
  onTouched = () => { };
3620
- calendarDays = computed(() => {
3621
- return this.generateCalendarDays();
3622
- }, ...(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" }] : []));
4199
+ onValidatorChange = () => { };
4200
+ // ── Computed ─────────────────────────────────────────────────
4201
+ parsedMin = computed(() => this.parseDate(this.min()), ...(ngDevMode ? [{ debugName: "parsedMin" }] : []));
4202
+ parsedMax = computed(() => this.parseDate(this.max()), ...(ngDevMode ? [{ debugName: "parsedMax" }] : []));
3629
4203
  popoverId = computed(() => `${this.inputId()}-popover`, ...(ngDevMode ? [{ debugName: "popoverId" }] : []));
3630
- toggle() {
3631
- if (this.isOpen()) {
3632
- this.close();
3633
- }
3634
- else {
3635
- this.open();
4204
+ hintId = computed(() => `${this.inputId()}-hint`, ...(ngDevMode ? [{ debugName: "hintId" }] : []));
4205
+ errorId = computed(() => `${this.inputId()}-error`, ...(ngDevMode ? [{ debugName: "errorId" }] : []));
4206
+ ariaDescribedBy = computed(() => {
4207
+ if (this.error())
4208
+ return this.errorId();
4209
+ if (this.hint())
4210
+ return this.hintId();
4211
+ return null;
4212
+ }, ...(ngDevMode ? [{ debugName: "ariaDescribedBy" }] : []));
4213
+ dialogAriaLabel = computed(() => {
4214
+ if (this.mode() === 'range')
4215
+ return 'Choose date range';
4216
+ return 'Choose date';
4217
+ }, ...(ngDevMode ? [{ debugName: "dialogAriaLabel" }] : []));
4218
+ headerTitle = computed(() => {
4219
+ switch (this.currentView()) {
4220
+ case 'days':
4221
+ return `${MONTH_NAMES[this.currentMonth()]} ${this.currentYear()}`;
4222
+ case 'months':
4223
+ return `${this.currentYear()}`;
4224
+ case 'years':
4225
+ return `${this.yearPageStart()} – ${this.yearPageStart() + 11}`;
4226
+ }
4227
+ }, ...(ngDevMode ? [{ debugName: "headerTitle" }] : []));
4228
+ titleAriaLabel = computed(() => {
4229
+ switch (this.currentView()) {
4230
+ case 'days':
4231
+ return 'Switch to month view';
4232
+ case 'months':
4233
+ return 'Switch to year view';
4234
+ case 'years':
4235
+ return null;
4236
+ }
4237
+ }, ...(ngDevMode ? [{ debugName: "titleAriaLabel" }] : []));
4238
+ prevButtonAriaLabel = computed(() => {
4239
+ switch (this.currentView()) {
4240
+ case 'days':
4241
+ return 'Previous month';
4242
+ case 'months':
4243
+ return 'Previous year';
4244
+ case 'years':
4245
+ return 'Previous 12 years';
4246
+ }
4247
+ }, ...(ngDevMode ? [{ debugName: "prevButtonAriaLabel" }] : []));
4248
+ nextButtonAriaLabel = computed(() => {
4249
+ switch (this.currentView()) {
4250
+ case 'days':
4251
+ return 'Next month';
4252
+ case 'months':
4253
+ return 'Next year';
4254
+ case 'years':
4255
+ return 'Next 12 years';
4256
+ }
4257
+ }, ...(ngDevMode ? [{ debugName: "nextButtonAriaLabel" }] : []));
4258
+ gridAriaLabel = computed(() => `${MONTH_NAMES[this.currentMonth()]} ${this.currentYear()}`, ...(ngDevMode ? [{ debugName: "gridAriaLabel" }] : []));
4259
+ hasClearableValue = computed(() => {
4260
+ if (this.mode() === 'range') {
4261
+ return this.rangeStart() !== null || this.rangeEnd() !== null;
4262
+ }
4263
+ return this.selectedDate() !== null;
4264
+ }, ...(ngDevMode ? [{ debugName: "hasClearableValue" }] : []));
4265
+ formattedValue = computed(() => {
4266
+ if (this.mode() === 'range') {
4267
+ const start = this.rangeStart();
4268
+ const end = this.rangeEnd();
4269
+ if (!start && !end)
4270
+ return '';
4271
+ const startStr = start ? this.formatDate(start) : '...';
4272
+ const endStr = end ? this.formatDate(end) : '...';
4273
+ return `${startStr} – ${endStr}`;
3636
4274
  }
3637
- }
3638
- selectDate(date) {
3639
- this.selectedDate.set(date);
3640
- this.focusedDate.set(date);
3641
- this.onChange(date);
3642
- this.dateChange.emit(date);
3643
- this.close(true);
3644
- }
3645
- previousMonth() {
3646
- this.shiftMonth(-1);
3647
- }
3648
- nextMonth() {
3649
- this.shiftMonth(1);
3650
- }
3651
- generateCalendarDays() {
4275
+ const date = this.selectedDate();
4276
+ return date ? this.formatDate(date) : '';
4277
+ }, ...(ngDevMode ? [{ debugName: "formattedValue" }] : []));
4278
+ isTodayDisabled = computed(() => this.isDateDisabled(new Date()), ...(ngDevMode ? [{ debugName: "isTodayDisabled" }] : []));
4279
+ calendarDays = computed(() => {
3652
4280
  const year = this.currentYear();
3653
4281
  const month = this.currentMonth();
3654
- const firstDay = new Date(year, month, 1);
3655
- const lastDay = new Date(year, month + 1, 0);
3656
4282
  const today = new Date();
3657
4283
  const selected = this.selectedDate();
4284
+ const rStart = this.rangeStart();
4285
+ const rEnd = this.rangeEnd();
4286
+ const previewEnd = this.mode() === 'range' && this.rangeSelecting() ? this.focusedDate() : null;
4287
+ const effectiveEnd = rEnd ?? previewEnd;
4288
+ const firstDay = new Date(year, month, 1);
4289
+ const lastDay = new Date(year, month + 1, 0);
3658
4290
  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
- });
4291
+ let startPad = firstDay.getDay() - 1;
4292
+ if (startPad < 0)
4293
+ startPad = 6;
4294
+ const prevLast = new Date(year, month, 0).getDate();
4295
+ for (let i = startPad - 1; i >= 0; i--) {
4296
+ days.push(this.buildDay(new Date(year, month - 1, prevLast - i), false, today, selected, rStart, effectiveEnd));
3673
4297
  }
3674
- // Current month days
3675
4298
  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
- });
4299
+ days.push(this.buildDay(new Date(year, month, i), true, today, selected, rStart, effectiveEnd));
3683
4300
  }
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
- });
4301
+ const remaining = 42 - days.length;
4302
+ for (let i = 1; i <= remaining; i++) {
4303
+ days.push(this.buildDay(new Date(year, month + 1, i), false, today, selected, rStart, effectiveEnd));
3694
4304
  }
3695
4305
  return days;
4306
+ }, ...(ngDevMode ? [{ debugName: "calendarDays" }] : []));
4307
+ monthItems = computed(() => {
4308
+ const year = this.currentYear();
4309
+ const selected = this.selectedDate();
4310
+ const min = this.parsedMin();
4311
+ const max = this.parsedMax();
4312
+ return Array.from({ length: 12 }, (_, i) => {
4313
+ const isDisabled = this.isMonthDisabled(year, i, min, max);
4314
+ return {
4315
+ index: i,
4316
+ label: MONTH_NAMES[i],
4317
+ shortLabel: MONTH_SHORT[i],
4318
+ isSelected: selected ? selected.getMonth() === i && selected.getFullYear() === year : false,
4319
+ isDisabled,
4320
+ };
4321
+ });
4322
+ }, ...(ngDevMode ? [{ debugName: "monthItems" }] : []));
4323
+ yearItems = computed(() => {
4324
+ const start = this.yearPageStart();
4325
+ const selected = this.selectedDate();
4326
+ const min = this.parsedMin();
4327
+ const max = this.parsedMax();
4328
+ return Array.from({ length: 12 }, (_, i) => {
4329
+ const value = start + i;
4330
+ return {
4331
+ value,
4332
+ isSelected: selected ? selected.getFullYear() === value : false,
4333
+ isDisabled: this.isYearDisabled(value, min, max),
4334
+ };
4335
+ });
4336
+ }, ...(ngDevMode ? [{ debugName: "yearItems" }] : []));
4337
+ // ── Open / Close ─────────────────────────────────────────────
4338
+ toggle() {
4339
+ if (this.isOpen()) {
4340
+ this.close(true);
4341
+ }
4342
+ else {
4343
+ this.open();
4344
+ }
3696
4345
  }
3697
- getDayTabIndex(day) {
3698
- const focused = this.focusedDate();
3699
- if (focused && this.isSameDay(day.date, focused))
3700
- return 0;
3701
- if (!focused && day.isSelected)
3702
- return 0;
3703
- return -1;
3704
- }
3705
- 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}`;
3710
- }
3711
- onInputKeydown(event) {
3712
- if (this.disabled())
4346
+ /** Opens the calendar popover */
4347
+ open() {
4348
+ if (this.disabled() || this.isOpen())
3713
4349
  return;
3714
- if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
3715
- event.preventDefault();
3716
- this.open();
4350
+ const selected = this.selectedDate();
4351
+ const focusDate = selected ?? new Date();
4352
+ this.currentMonth.set(focusDate.getMonth());
4353
+ this.currentYear.set(focusDate.getFullYear());
4354
+ this.focusedDate.set(focusDate);
4355
+ this.focusedMonth.set(focusDate.getMonth());
4356
+ this.focusedYear.set(focusDate.getFullYear());
4357
+ this.currentView.set('days');
4358
+ this.isOpen.set(true);
4359
+ queueMicrotask(() => this.focusDayButton(focusDate));
4360
+ }
4361
+ /** Closes the calendar popover */
4362
+ close(returnFocus = false) {
4363
+ if (!this.isOpen())
4364
+ return;
4365
+ this.isOpen.set(false);
4366
+ this.onTouched();
4367
+ if (returnFocus) {
4368
+ this.inputRef()?.nativeElement.focus();
3717
4369
  }
3718
- else if (event.key === 'Escape') {
3719
- event.preventDefault();
4370
+ }
4371
+ // ── Day Selection ────────────────────────────────────────────
4372
+ /** Handles click on a calendar day */
4373
+ onDayClick(day) {
4374
+ if (day.isDisabled || day.isUnavailable)
4375
+ return;
4376
+ if (this.mode() === 'range') {
4377
+ this.selectRangeDate(day.date);
4378
+ }
4379
+ else {
4380
+ this.selectSingleDate(day.date);
4381
+ }
4382
+ }
4383
+ /** Selects a single date and closes the popover */
4384
+ selectSingleDate(date) {
4385
+ this.selectedDate.set(date);
4386
+ this.focusedDate.set(date);
4387
+ this.emitSingleValue(date);
4388
+ this.dateChange.emit(date);
4389
+ this.close(true);
4390
+ }
4391
+ /** Handles range date selection (two-click: start then end) */
4392
+ selectRangeDate(date) {
4393
+ if (!this.rangeSelecting()) {
4394
+ this.rangeStart.set(date);
4395
+ this.rangeEnd.set(null);
4396
+ this.rangeSelecting.set(true);
4397
+ this.focusedDate.set(date);
4398
+ }
4399
+ else {
4400
+ let start = this.rangeStart();
4401
+ let end = date;
4402
+ if (this.compareDays(end, start) < 0) {
4403
+ [start, end] = [end, start];
4404
+ }
4405
+ this.rangeStart.set(start);
4406
+ this.rangeEnd.set(end);
4407
+ this.rangeSelecting.set(false);
4408
+ this.emitRangeValue(start, end);
4409
+ this.rangeChange.emit({ start, end });
3720
4410
  this.close(true);
3721
4411
  }
3722
4412
  }
3723
- onGridKeydown(event) {
4413
+ /** Navigates to today and selects it (single mode) or focuses it */
4414
+ goToToday() {
4415
+ const today = new Date();
4416
+ if (this.isDateDisabled(today))
4417
+ return;
4418
+ this.currentMonth.set(today.getMonth());
4419
+ this.currentYear.set(today.getFullYear());
4420
+ this.focusedDate.set(today);
4421
+ if (this.mode() === 'single') {
4422
+ this.selectSingleDate(today);
4423
+ }
4424
+ else {
4425
+ this.selectRangeDate(today);
4426
+ }
4427
+ }
4428
+ /** Clears the selected value */
4429
+ clearValue(event) {
4430
+ event.stopPropagation();
4431
+ if (this.mode() === 'range') {
4432
+ this.rangeStart.set(null);
4433
+ this.rangeEnd.set(null);
4434
+ this.rangeSelecting.set(false);
4435
+ this.emitRangeValue(null, null);
4436
+ }
4437
+ else {
4438
+ this.selectedDate.set(null);
4439
+ this.emitSingleValue(null);
4440
+ }
4441
+ this.inputRef()?.nativeElement.focus();
4442
+ }
4443
+ // ── View Navigation ──────────────────────────────────────────
4444
+ /** Drills up: days -> months -> years */
4445
+ drillUp() {
4446
+ const view = this.currentView();
4447
+ if (view === 'days') {
4448
+ this.focusedMonth.set(this.currentMonth());
4449
+ this.currentView.set('months');
4450
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4451
+ }
4452
+ else if (view === 'months') {
4453
+ this.focusedYear.set(this.currentYear());
4454
+ this.yearPageStart.set(Math.floor(this.currentYear() / 12) * 12);
4455
+ this.currentView.set('years');
4456
+ queueMicrotask(() => this.focusYearButton(this.focusedYear()));
4457
+ }
4458
+ }
4459
+ /** Navigates to previous period based on current view */
4460
+ navigatePrevious() {
4461
+ switch (this.currentView()) {
4462
+ case 'days':
4463
+ this.shiftMonth(-1);
4464
+ break;
4465
+ case 'months':
4466
+ this.currentYear.update((y) => y - 1);
4467
+ break;
4468
+ case 'years':
4469
+ this.yearPageStart.update((s) => s - 12);
4470
+ break;
4471
+ }
4472
+ }
4473
+ /** Navigates to next period based on current view */
4474
+ navigateNext() {
4475
+ switch (this.currentView()) {
4476
+ case 'days':
4477
+ this.shiftMonth(1);
4478
+ break;
4479
+ case 'months':
4480
+ this.currentYear.update((y) => y + 1);
4481
+ break;
4482
+ case 'years':
4483
+ this.yearPageStart.update((s) => s + 12);
4484
+ break;
4485
+ }
4486
+ }
4487
+ /** Selects a month from the month view and switches to days */
4488
+ selectMonth(monthIndex) {
4489
+ this.currentMonth.set(monthIndex);
4490
+ this.focusedMonth.set(monthIndex);
4491
+ this.currentView.set('days');
4492
+ const focusDate = new Date(this.currentYear(), monthIndex, 1);
4493
+ this.focusedDate.set(focusDate);
4494
+ queueMicrotask(() => this.focusDayButton(focusDate));
4495
+ }
4496
+ /** Selects a year from the year view and switches to months */
4497
+ selectYear(year) {
4498
+ this.currentYear.set(year);
4499
+ this.focusedYear.set(year);
4500
+ this.currentView.set('months');
4501
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4502
+ }
4503
+ // ── Day Helpers for Template ─────────────────────────────────
4504
+ /** Returns true if the given day matches the keyboard-focused date */
4505
+ isDayHighlighted(day) {
4506
+ const focused = this.focusedDate();
4507
+ return focused !== null && this.isSameDay(day.date, focused);
4508
+ }
4509
+ /** Returns the tabindex for a day button (roving tabindex pattern) */
4510
+ getDayTabIndex(day) {
4511
+ const focused = this.focusedDate();
4512
+ if (focused && this.isSameDay(day.date, focused))
4513
+ return 0;
4514
+ if (!focused && day.isSelected)
4515
+ return 0;
4516
+ return -1;
4517
+ }
4518
+ /** Returns true if the given month index matches the keyboard-focused month */
4519
+ isMonthHighlighted(monthIndex) {
4520
+ return this.currentView() === 'months' && this.focusedMonth() === monthIndex;
4521
+ }
4522
+ /** Returns the tabindex for a month button */
4523
+ getMonthTabIndex(monthIndex) {
4524
+ return this.focusedMonth() === monthIndex ? 0 : -1;
4525
+ }
4526
+ /** Returns true if the given year matches the keyboard-focused year */
4527
+ isYearHighlighted(year) {
4528
+ return this.currentView() === 'years' && this.focusedYear() === year;
4529
+ }
4530
+ /** Returns the tabindex for a year button */
4531
+ getYearTabIndex(year) {
4532
+ return this.focusedYear() === year ? 0 : -1;
4533
+ }
4534
+ /** Returns a date key string for DOM identification */
4535
+ getDateKey(date) {
4536
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
4537
+ }
4538
+ // ── Keyboard Handlers ────────────────────────────────────────
4539
+ onInputKeydown(event) {
4540
+ if (this.disabled())
4541
+ return;
4542
+ switch (event.key) {
4543
+ case 'ArrowDown':
4544
+ case 'Enter':
4545
+ case ' ':
4546
+ event.preventDefault();
4547
+ this.open();
4548
+ break;
4549
+ case 'Escape':
4550
+ if (this.isOpen()) {
4551
+ event.preventDefault();
4552
+ this.close(true);
4553
+ }
4554
+ break;
4555
+ case 'Delete':
4556
+ case 'Backspace':
4557
+ if (this.hasClearableValue()) {
4558
+ event.preventDefault();
4559
+ this.clearValue(event);
4560
+ }
4561
+ break;
4562
+ }
4563
+ }
4564
+ /** Keyboard navigation within the day grid */
4565
+ onDayGridKeydown(event) {
3724
4566
  if (!this.isOpen())
3725
4567
  return;
3726
- const focused = this.focusedDate() || this.selectedDate() || new Date(this.currentYear(), this.currentMonth(), 1);
4568
+ const focused = this.focusedDate() ??
4569
+ this.selectedDate() ??
4570
+ new Date(this.currentYear(), this.currentMonth(), 1);
3727
4571
  let nextDate = null;
3728
4572
  switch (event.key) {
3729
4573
  case 'ArrowRight':
3730
- nextDate = this.addDays(focused, 1);
4574
+ nextDate = this.findNextEnabledDate(focused, 1);
3731
4575
  break;
3732
4576
  case 'ArrowLeft':
3733
- nextDate = this.addDays(focused, -1);
4577
+ nextDate = this.findNextEnabledDate(focused, -1);
3734
4578
  break;
3735
4579
  case 'ArrowDown':
3736
- nextDate = this.addDays(focused, 7);
4580
+ nextDate = this.findNextEnabledDate(focused, 7);
3737
4581
  break;
3738
4582
  case 'ArrowUp':
3739
- nextDate = this.addDays(focused, -7);
4583
+ nextDate = this.findNextEnabledDate(focused, -7);
3740
4584
  break;
3741
4585
  case 'Home':
3742
4586
  nextDate = this.addDays(focused, -this.getWeekdayIndex(focused));
@@ -3745,15 +4589,17 @@ class AfDatepickerComponent {
3745
4589
  nextDate = this.addDays(focused, 6 - this.getWeekdayIndex(focused));
3746
4590
  break;
3747
4591
  case 'PageUp':
3748
- nextDate = this.addMonths(focused, -1);
4592
+ nextDate = event.shiftKey ? this.addYears(focused, -1) : this.addMonths(focused, -1);
3749
4593
  break;
3750
4594
  case 'PageDown':
3751
- nextDate = this.addMonths(focused, 1);
4595
+ nextDate = event.shiftKey ? this.addYears(focused, 1) : this.addMonths(focused, 1);
3752
4596
  break;
3753
4597
  case 'Enter':
3754
4598
  case ' ':
3755
4599
  event.preventDefault();
3756
- this.selectDate(focused);
4600
+ if (!this.isDateDisabled(focused)) {
4601
+ this.onDayClick({ date: focused });
4602
+ }
3757
4603
  return;
3758
4604
  case 'Escape':
3759
4605
  event.preventDefault();
@@ -3767,10 +4613,100 @@ class AfDatepickerComponent {
3767
4613
  this.setFocusedDate(nextDate);
3768
4614
  }
3769
4615
  }
3770
- onEscape() {
3771
- if (this.isOpen()) {
3772
- this.close(true);
4616
+ /** Keyboard navigation within the month grid */
4617
+ onMonthGridKeydown(event) {
4618
+ let nextMonth = this.focusedMonth();
4619
+ switch (event.key) {
4620
+ case 'ArrowRight':
4621
+ nextMonth = Math.min(11, nextMonth + 1);
4622
+ break;
4623
+ case 'ArrowLeft':
4624
+ nextMonth = Math.max(0, nextMonth - 1);
4625
+ break;
4626
+ case 'ArrowDown':
4627
+ nextMonth = Math.min(11, nextMonth + 3);
4628
+ break;
4629
+ case 'ArrowUp':
4630
+ nextMonth = Math.max(0, nextMonth - 3);
4631
+ break;
4632
+ case 'Home':
4633
+ nextMonth = 0;
4634
+ break;
4635
+ case 'End':
4636
+ nextMonth = 11;
4637
+ break;
4638
+ case 'Enter':
4639
+ case ' ':
4640
+ event.preventDefault();
4641
+ if (!this.isMonthDisabled(this.currentYear(), nextMonth, this.parsedMin(), this.parsedMax())) {
4642
+ this.selectMonth(nextMonth);
4643
+ }
4644
+ return;
4645
+ case 'Escape':
4646
+ event.preventDefault();
4647
+ this.currentView.set('days');
4648
+ queueMicrotask(() => this.focusDayButton(this.focusedDate() ?? new Date()));
4649
+ return;
4650
+ default:
4651
+ return;
4652
+ }
4653
+ event.preventDefault();
4654
+ this.focusedMonth.set(nextMonth);
4655
+ queueMicrotask(() => this.focusMonthButton(nextMonth));
4656
+ }
4657
+ /** Keyboard navigation within the year grid */
4658
+ onYearGridKeydown(event) {
4659
+ let nextYear = this.focusedYear();
4660
+ const pageStart = this.yearPageStart();
4661
+ switch (event.key) {
4662
+ case 'ArrowRight':
4663
+ nextYear += 1;
4664
+ break;
4665
+ case 'ArrowLeft':
4666
+ nextYear -= 1;
4667
+ break;
4668
+ case 'ArrowDown':
4669
+ nextYear += 3;
4670
+ break;
4671
+ case 'ArrowUp':
4672
+ nextYear -= 3;
4673
+ break;
4674
+ case 'Home':
4675
+ nextYear = pageStart;
4676
+ break;
4677
+ case 'End':
4678
+ nextYear = pageStart + 11;
4679
+ break;
4680
+ case 'PageUp':
4681
+ nextYear -= 12;
4682
+ break;
4683
+ case 'PageDown':
4684
+ nextYear += 12;
4685
+ break;
4686
+ case 'Enter':
4687
+ case ' ':
4688
+ event.preventDefault();
4689
+ if (!this.isYearDisabled(nextYear, this.parsedMin(), this.parsedMax())) {
4690
+ this.selectYear(nextYear);
4691
+ }
4692
+ return;
4693
+ case 'Escape':
4694
+ event.preventDefault();
4695
+ this.currentView.set('months');
4696
+ queueMicrotask(() => this.focusMonthButton(this.focusedMonth()));
4697
+ return;
4698
+ default:
4699
+ return;
4700
+ }
4701
+ event.preventDefault();
4702
+ if (nextYear < pageStart) {
4703
+ this.yearPageStart.update((s) => s - 12);
3773
4704
  }
4705
+ else if (nextYear >= pageStart + 12) {
4706
+ this.yearPageStart.update((s) => s + 12);
4707
+ }
4708
+ this.focusedYear.set(nextYear);
4709
+ queueMicrotask(() => this.focusYearButton(nextYear));
3774
4710
  }
3775
4711
  onDocumentClick(event) {
3776
4712
  const target = event.target;
@@ -3778,24 +4714,204 @@ class AfDatepickerComponent {
3778
4714
  this.close(false);
3779
4715
  }
3780
4716
  }
3781
- open() {
3782
- if (this.disabled() || this.isOpen())
4717
+ // ── ControlValueAccessor ─────────────────────────────────────
4718
+ writeValue(value) {
4719
+ if (this.mode() === 'range') {
4720
+ this.writeRangeValue(value);
4721
+ }
4722
+ else {
4723
+ this.writeSingleValue(value);
4724
+ }
4725
+ }
4726
+ registerOnChange(fn) {
4727
+ this.onChange = fn;
4728
+ }
4729
+ registerOnTouched(fn) {
4730
+ this.onTouched = fn;
4731
+ }
4732
+ setDisabledState(isDisabled) {
4733
+ this.disabled.set(isDisabled);
4734
+ }
4735
+ // ── Validator ────────────────────────────────────────────────
4736
+ validate(control) {
4737
+ const value = control.value;
4738
+ if (this.mode() === 'range') {
4739
+ return this.validateRange(value);
4740
+ }
4741
+ return this.validateSingle(value);
4742
+ }
4743
+ registerOnValidatorChange(fn) {
4744
+ this.onValidatorChange = fn;
4745
+ }
4746
+ // ── Private: Value Handling ──────────────────────────────────
4747
+ writeSingleValue(value) {
4748
+ const date = this.coerceToDate(value);
4749
+ this.selectedDate.set(date);
4750
+ if (date) {
4751
+ this.currentMonth.set(date.getMonth());
4752
+ this.currentYear.set(date.getFullYear());
4753
+ this.focusedDate.set(date);
4754
+ }
4755
+ else {
4756
+ this.focusedDate.set(null);
4757
+ }
4758
+ }
4759
+ writeRangeValue(value) {
4760
+ if (!value || typeof value !== 'object') {
4761
+ this.rangeStart.set(null);
4762
+ this.rangeEnd.set(null);
4763
+ this.rangeSelecting.set(false);
3783
4764
  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));
4765
+ }
4766
+ const range = value;
4767
+ this.rangeStart.set(this.coerceToDate(range['start']));
4768
+ this.rangeEnd.set(this.coerceToDate(range['end']));
4769
+ this.rangeSelecting.set(false);
4770
+ const focus = this.rangeStart() ?? this.rangeEnd();
4771
+ if (focus) {
4772
+ this.currentMonth.set(focus.getMonth());
4773
+ this.currentYear.set(focus.getFullYear());
4774
+ }
3791
4775
  }
3792
- close(returnFocus = false) {
3793
- this.isOpen.set(false);
3794
- this.onTouched();
3795
- if (returnFocus) {
3796
- this.inputRef()?.nativeElement.focus();
4776
+ emitSingleValue(date) {
4777
+ if (this.valueFormat() === 'iso') {
4778
+ this.onChange(date ? this.toIsoString(date) : null);
4779
+ }
4780
+ else {
4781
+ this.onChange(date);
3797
4782
  }
4783
+ this.onValidatorChange();
3798
4784
  }
4785
+ emitRangeValue(start, end) {
4786
+ if (this.valueFormat() === 'iso') {
4787
+ this.onChange({
4788
+ start: start ? this.toIsoString(start) : null,
4789
+ end: end ? this.toIsoString(end) : null,
4790
+ });
4791
+ }
4792
+ else {
4793
+ this.onChange({ start, end });
4794
+ }
4795
+ this.onValidatorChange();
4796
+ }
4797
+ // ── Private: Validation ──────────────────────────────────────
4798
+ validateSingle(value) {
4799
+ if (!value)
4800
+ return null;
4801
+ const date = this.coerceToDate(value);
4802
+ if (!date)
4803
+ return null;
4804
+ const min = this.parsedMin();
4805
+ const max = this.parsedMax();
4806
+ if (min && this.compareDays(date, min) < 0) {
4807
+ return { afDatepickerMin: { min, actual: date } };
4808
+ }
4809
+ if (max && this.compareDays(date, max) > 0) {
4810
+ return { afDatepickerMax: { max, actual: date } };
4811
+ }
4812
+ if (this.isDateUnavailable(date)) {
4813
+ return { afDatepickerFilter: true };
4814
+ }
4815
+ return null;
4816
+ }
4817
+ validateRange(value) {
4818
+ if (!value || typeof value !== 'object')
4819
+ return null;
4820
+ const range = value;
4821
+ const start = this.coerceToDate(range['start']);
4822
+ const end = this.coerceToDate(range['end']);
4823
+ const errors = {};
4824
+ const min = this.parsedMin();
4825
+ const max = this.parsedMax();
4826
+ if (start && min && this.compareDays(start, min) < 0) {
4827
+ errors['afDatepickerMin'] = { min, actual: start };
4828
+ }
4829
+ if (end && max && this.compareDays(end, max) > 0) {
4830
+ errors['afDatepickerMax'] = { max, actual: end };
4831
+ }
4832
+ if (start && end && this.compareDays(start, end) > 0) {
4833
+ errors['afDatepickerRange'] = { start, end };
4834
+ }
4835
+ return Object.keys(errors).length > 0 ? errors : null;
4836
+ }
4837
+ // ── Private: Calendar Building ───────────────────────────────
4838
+ buildDay(date, isCurrentMonth, today, selected, rangeStart, rangeEnd) {
4839
+ const isToday = this.isSameDay(date, today);
4840
+ const isSelected = this.mode() === 'single' && selected !== null ? this.isSameDay(date, selected) : false;
4841
+ const isDisabled = this.isDateDisabled(date);
4842
+ const isUnavailable = this.isDateUnavailable(date);
4843
+ let isInRange = false;
4844
+ let isRangeStart = false;
4845
+ let isRangeEnd = false;
4846
+ if (this.mode() === 'range' && rangeStart) {
4847
+ isRangeStart = this.isSameDay(date, rangeStart);
4848
+ if (rangeEnd) {
4849
+ const [lo, hi] = this.compareDays(rangeStart, rangeEnd) <= 0
4850
+ ? [rangeStart, rangeEnd]
4851
+ : [rangeEnd, rangeStart];
4852
+ isRangeStart = this.isSameDay(date, lo);
4853
+ isRangeEnd = this.isSameDay(date, hi);
4854
+ isInRange =
4855
+ !isRangeStart &&
4856
+ !isRangeEnd &&
4857
+ this.compareDays(date, lo) > 0 &&
4858
+ this.compareDays(date, hi) < 0;
4859
+ }
4860
+ }
4861
+ return {
4862
+ date,
4863
+ isCurrentMonth,
4864
+ isToday,
4865
+ isSelected,
4866
+ isDisabled,
4867
+ isUnavailable,
4868
+ isInRange,
4869
+ isRangeStart,
4870
+ isRangeEnd,
4871
+ };
4872
+ }
4873
+ // ── Private: Disability Checks ───────────────────────────────
4874
+ /** Checks whether a date falls outside min/max bounds */
4875
+ isDateDisabled(date) {
4876
+ const min = this.parsedMin();
4877
+ const max = this.parsedMax();
4878
+ if (min && this.compareDays(date, min) < 0)
4879
+ return true;
4880
+ if (max && this.compareDays(date, max) > 0)
4881
+ return true;
4882
+ return false;
4883
+ }
4884
+ /** Checks whether a date is explicitly unavailable (disabledDates or dateFilter) */
4885
+ isDateUnavailable(date) {
4886
+ const disabled = this.disabledDates();
4887
+ if (disabled.some((d) => this.isSameDay(d, date)))
4888
+ return true;
4889
+ const filter = this.dateFilter();
4890
+ if (filter && !filter(date))
4891
+ return true;
4892
+ return false;
4893
+ }
4894
+ isMonthDisabled(year, month, min, max) {
4895
+ if (min) {
4896
+ const minMonth = new Date(min.getFullYear(), min.getMonth(), 1);
4897
+ if (new Date(year, month + 1, 0) < minMonth)
4898
+ return true;
4899
+ }
4900
+ if (max) {
4901
+ const maxMonth = new Date(max.getFullYear(), max.getMonth() + 1, 0);
4902
+ if (new Date(year, month, 1) > maxMonth)
4903
+ return true;
4904
+ }
4905
+ return false;
4906
+ }
4907
+ isYearDisabled(year, min, max) {
4908
+ if (min && year < min.getFullYear())
4909
+ return true;
4910
+ if (max && year > max.getFullYear())
4911
+ return true;
4912
+ return false;
4913
+ }
4914
+ // ── Private: Focus Management ────────────────────────────────
3799
4915
  setFocusedDate(date) {
3800
4916
  this.currentMonth.set(date.getMonth());
3801
4917
  this.currentYear.set(date.getFullYear());
@@ -3810,6 +4926,41 @@ class AfDatepickerComponent {
3810
4926
  const button = popover.querySelector(`.ct-datepicker__day[data-date="${key}"]`);
3811
4927
  button?.focus();
3812
4928
  }
4929
+ focusMonthButton(monthIndex) {
4930
+ const popover = this.popoverRef()?.nativeElement;
4931
+ if (!popover)
4932
+ return;
4933
+ const buttons = popover.querySelectorAll('.ct-datepicker__month');
4934
+ buttons[monthIndex]?.focus();
4935
+ }
4936
+ focusYearButton(year) {
4937
+ const popover = this.popoverRef()?.nativeElement;
4938
+ if (!popover)
4939
+ return;
4940
+ const buttons = popover.querySelectorAll('.ct-datepicker__year');
4941
+ const pageStart = this.yearPageStart();
4942
+ const index = year - pageStart;
4943
+ if (index >= 0 && index < buttons.length) {
4944
+ buttons[index]?.focus();
4945
+ }
4946
+ }
4947
+ /** Finds the next non-disabled date in the given direction */
4948
+ findNextEnabledDate(from, step) {
4949
+ let next = this.addDays(from, step);
4950
+ let attempts = 0;
4951
+ const singleStep = step > 0 ? 1 : -1;
4952
+ while (this.isDateDisabled(next) && attempts < 365) {
4953
+ next = this.addDays(next, singleStep);
4954
+ attempts++;
4955
+ }
4956
+ return next;
4957
+ }
4958
+ // ── Private: Date Arithmetic ─────────────────────────────────
4959
+ shiftMonth(delta) {
4960
+ const base = this.focusedDate() ?? new Date(this.currentYear(), this.currentMonth(), 1);
4961
+ const next = this.addMonths(base, delta);
4962
+ this.setFocusedDate(next);
4963
+ }
3813
4964
  addDays(date, delta) {
3814
4965
  const next = new Date(date);
3815
4966
  next.setDate(date.getDate() + delta);
@@ -3820,224 +4971,662 @@ class AfDatepickerComponent {
3820
4971
  next.setMonth(date.getMonth() + delta);
3821
4972
  return next;
3822
4973
  }
4974
+ addYears(date, delta) {
4975
+ const next = new Date(date);
4976
+ next.setFullYear(date.getFullYear() + delta);
4977
+ return next;
4978
+ }
3823
4979
  getWeekdayIndex(date) {
3824
4980
  const day = date.getDay();
3825
4981
  return day === 0 ? 6 : day - 1;
3826
4982
  }
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);
4983
+ // ── Private: Date Comparison & Parsing ───────────────────────
4984
+ isSameDay(a, b) {
4985
+ return (a.getFullYear() === b.getFullYear() &&
4986
+ a.getMonth() === b.getMonth() &&
4987
+ a.getDate() === b.getDate());
4988
+ }
4989
+ compareDays(a, b) {
4990
+ const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
4991
+ const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
4992
+ return da - db;
4993
+ }
4994
+ parseDate(value) {
4995
+ if (!value)
4996
+ return null;
4997
+ if (value instanceof Date)
4998
+ return value;
4999
+ if (typeof value === 'string') {
5000
+ const parsed = new Date(value);
5001
+ return isNaN(parsed.getTime()) ? null : parsed;
5002
+ }
5003
+ return null;
3831
5004
  }
3832
- isSameDay(date1, date2) {
3833
- return (date1.getFullYear() === date2.getFullYear() &&
3834
- date1.getMonth() === date2.getMonth() &&
3835
- date1.getDate() === date2.getDate());
5005
+ coerceToDate(value) {
5006
+ if (!value)
5007
+ return null;
5008
+ if (value instanceof Date)
5009
+ return value;
5010
+ if (typeof value === 'string') {
5011
+ const parsed = new Date(value);
5012
+ return isNaN(parsed.getTime()) ? null : parsed;
5013
+ }
5014
+ return null;
5015
+ }
5016
+ toIsoString(date) {
5017
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
3836
5018
  }
3837
5019
  formatDate(date) {
3838
5020
  const tokens = {
3839
5021
  yyyy: String(date.getFullYear()),
3840
- MMM: this.monthNames[date.getMonth()].substring(0, 3),
5022
+ MMM: MONTH_SHORT[date.getMonth()],
3841
5023
  MM: String(date.getMonth() + 1).padStart(2, '0'),
3842
5024
  dd: String(date.getDate()).padStart(2, '0'),
3843
- d: String(date.getDate())
5025
+ d: String(date.getDate()),
3844
5026
  };
3845
- return this.dateFormat().replace(/yyyy|MMM|MM|dd|d/g, token => tokens[token] ?? token);
5027
+ return this.dateFormat().replace(/yyyy|MMM|MM|dd|d/g, (token) => tokens[token] ?? token);
3846
5028
  }
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);
5029
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDatepickerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5030
+ 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: [
5031
+ {
5032
+ provide: NG_VALUE_ACCESSOR,
5033
+ useExisting: forwardRef(() => AfDatepickerComponent),
5034
+ multi: true,
5035
+ },
5036
+ {
5037
+ provide: NG_VALIDATORS,
5038
+ useExisting: forwardRef(() => AfDatepickerComponent),
5039
+ multi: true,
5040
+ },
5041
+ ], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "popoverRef", first: true, predicate: ["popoverEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
5042
+ <div class="ct-field" [class.ct-field--error]="error()">
5043
+ @if (label()) {
5044
+ <label class="ct-field__label" [attr.for]="inputId()">
5045
+ {{ label() }}
5046
+ @if (required()) {
5047
+ <span aria-label="required"> *</span>
5048
+ }
5049
+ </label>
5050
+ }
5051
+
5052
+ <div class="ct-datepicker" [attr.data-state]="isOpen() ? 'open' : 'closed'">
5053
+ <div class="ct-datepicker__input-wrap">
5054
+ <input
5055
+ #inputEl
5056
+ class="ct-input"
5057
+ type="text"
5058
+ [id]="inputId()"
5059
+ [placeholder]="placeholder()"
5060
+ [value]="formattedValue()"
5061
+ [disabled]="disabled()"
5062
+ [required]="required()"
5063
+ [attr.aria-haspopup]="'dialog'"
5064
+ [attr.aria-expanded]="isOpen()"
5065
+ [attr.aria-controls]="popoverId()"
5066
+ [attr.aria-invalid]="error() ? true : null"
5067
+ [attr.aria-describedby]="ariaDescribedBy()"
5068
+ [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
5069
+ (click)="toggle()"
5070
+ (keydown)="onInputKeydown($event)"
5071
+ (blur)="onTouched()"
5072
+ readonly
5073
+ />
5074
+ @if (hasClearableValue() && !disabled()) {
5075
+ <button
5076
+ class="ct-datepicker__clear"
5077
+ type="button"
5078
+ aria-label="Clear date"
5079
+ (click)="clearValue($event)">
5080
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
5081
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
5082
+ stroke-linejoin="round" aria-hidden="true">
5083
+ <line x1="18" y1="6" x2="6" y2="18"></line>
5084
+ <line x1="6" y1="6" x2="18" y2="18"></line>
5085
+ </svg>
5086
+ </button>
5087
+ }
5088
+ </div>
5089
+
5090
+ <div
5091
+ #popoverEl
5092
+ class="ct-datepicker__popover"
5093
+ role="dialog"
5094
+ [id]="popoverId()"
5095
+ [attr.aria-label]="dialogAriaLabel()"
5096
+ [attr.aria-hidden]="!isOpen() || null">
5097
+
5098
+ <div class="ct-datepicker__header">
5099
+ <button
5100
+ class="ct-button ct-button--ghost ct-button--icon"
5101
+ type="button"
5102
+ [attr.aria-label]="prevButtonAriaLabel()"
5103
+ (click)="navigatePrevious()">
5104
+ &#8249;
5105
+ </button>
5106
+ <button
5107
+ class="ct-datepicker__title"
5108
+ type="button"
5109
+ [attr.aria-label]="titleAriaLabel()"
5110
+ (click)="drillUp()">
5111
+ {{ headerTitle() }}
5112
+ </button>
5113
+ <button
5114
+ class="ct-button ct-button--ghost ct-button--icon"
5115
+ type="button"
5116
+ [attr.aria-label]="nextButtonAriaLabel()"
5117
+ (click)="navigateNext()">
5118
+ &#8250;
5119
+ </button>
5120
+ </div>
5121
+
5122
+ @switch (currentView()) {
5123
+ @case ('days') {
5124
+ <div
5125
+ class="ct-datepicker__grid"
5126
+ role="grid"
5127
+ [attr.aria-label]="gridAriaLabel()"
5128
+ (keydown)="onDayGridKeydown($event)">
5129
+ <div class="ct-datepicker__row" role="row">
5130
+ @for (day of weekdayLabels; track $index) {
5131
+ <div class="ct-datepicker__weekday" role="columnheader" [attr.aria-label]="weekdayFullLabels[$index]">
5132
+ {{ day }}
5133
+ </div>
5134
+ }
5135
+ </div>
5136
+ @for (day of calendarDays(); track day.date.getTime()) {
5137
+ <button
5138
+ class="ct-datepicker__day"
5139
+ [attr.data-date]="getDateKey(day.date)"
5140
+ [attr.data-outside]="!day.isCurrentMonth || null"
5141
+ [attr.data-today]="day.isToday || null"
5142
+ [attr.data-unavailable]="day.isUnavailable || null"
5143
+ [attr.data-highlighted]="isDayHighlighted(day) || null"
5144
+ [attr.data-in-range]="day.isInRange || null"
5145
+ [attr.data-range-start]="day.isRangeStart || null"
5146
+ [attr.data-range-end]="day.isRangeEnd || null"
5147
+ [attr.aria-selected]="day.isSelected ? 'true' : null"
5148
+ [attr.aria-current]="day.isToday ? 'date' : null"
5149
+ [attr.aria-disabled]="day.isDisabled || day.isUnavailable ? 'true' : null"
5150
+ [disabled]="day.isDisabled || day.isUnavailable"
5151
+ [attr.tabindex]="getDayTabIndex(day)"
5152
+ role="gridcell"
5153
+ type="button"
5154
+ (click)="onDayClick(day)">
5155
+ {{ day.date.getDate() }}
5156
+ </button>
5157
+ }
5158
+ </div>
5159
+ <div class="ct-datepicker__footer">
5160
+ <button
5161
+ class="ct-datepicker__today"
5162
+ type="button"
5163
+ [disabled]="isTodayDisabled()"
5164
+ (click)="goToToday()">
5165
+ Today
5166
+ </button>
5167
+ </div>
5168
+ }
5169
+ @case ('months') {
5170
+ <div
5171
+ class="ct-datepicker__month-grid"
5172
+ role="grid"
5173
+ aria-label="Select month"
5174
+ (keydown)="onMonthGridKeydown($event)">
5175
+ @for (m of monthItems(); track m.index) {
5176
+ <button
5177
+ class="ct-datepicker__month"
5178
+ [attr.aria-selected]="m.isSelected ? 'true' : null"
5179
+ [attr.data-highlighted]="isMonthHighlighted(m.index) || null"
5180
+ [disabled]="m.isDisabled"
5181
+ [attr.tabindex]="getMonthTabIndex(m.index)"
5182
+ role="gridcell"
5183
+ type="button"
5184
+ (click)="selectMonth(m.index)">
5185
+ {{ m.shortLabel }}
5186
+ </button>
5187
+ }
5188
+ </div>
5189
+ }
5190
+ @case ('years') {
5191
+ <div
5192
+ class="ct-datepicker__year-grid"
5193
+ role="grid"
5194
+ aria-label="Select year"
5195
+ (keydown)="onYearGridKeydown($event)">
5196
+ @for (y of yearItems(); track y.value) {
5197
+ <button
5198
+ class="ct-datepicker__year"
5199
+ [attr.aria-selected]="y.isSelected ? 'true' : null"
5200
+ [attr.data-highlighted]="isYearHighlighted(y.value) || null"
5201
+ [disabled]="y.isDisabled"
5202
+ [attr.tabindex]="getYearTabIndex(y.value)"
5203
+ role="gridcell"
5204
+ type="button"
5205
+ (click)="selectYear(y.value)">
5206
+ {{ y.value }}
5207
+ </button>
5208
+ }
5209
+ </div>
5210
+ }
5211
+ }
5212
+ </div>
5213
+ </div>
5214
+
5215
+ @if (hint() && !error()) {
5216
+ <div class="ct-field__hint" [id]="hintId()">{{ hint() }}</div>
5217
+ }
5218
+ @if (error()) {
5219
+ <div class="ct-field__error" [id]="errorId()">{{ error() }}</div>
5220
+ }
5221
+ </div>
5222
+ `, 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 });
5223
+ }
5224
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDatepickerComponent, decorators: [{
5225
+ type: Component,
5226
+ args: [{ selector: 'af-datepicker', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
5227
+ {
5228
+ provide: NG_VALUE_ACCESSOR,
5229
+ useExisting: forwardRef(() => AfDatepickerComponent),
5230
+ multi: true,
5231
+ },
5232
+ {
5233
+ provide: NG_VALIDATORS,
5234
+ useExisting: forwardRef(() => AfDatepickerComponent),
5235
+ multi: true,
5236
+ },
5237
+ ], host: {
5238
+ '(document:click)': 'onDocumentClick($event)',
5239
+ }, template: `
5240
+ <div class="ct-field" [class.ct-field--error]="error()">
5241
+ @if (label()) {
5242
+ <label class="ct-field__label" [attr.for]="inputId()">
5243
+ {{ label() }}
5244
+ @if (required()) {
5245
+ <span aria-label="required"> *</span>
5246
+ }
5247
+ </label>
5248
+ }
5249
+
5250
+ <div class="ct-datepicker" [attr.data-state]="isOpen() ? 'open' : 'closed'">
5251
+ <div class="ct-datepicker__input-wrap">
5252
+ <input
5253
+ #inputEl
5254
+ class="ct-input"
5255
+ type="text"
5256
+ [id]="inputId()"
5257
+ [placeholder]="placeholder()"
5258
+ [value]="formattedValue()"
5259
+ [disabled]="disabled()"
5260
+ [required]="required()"
5261
+ [attr.aria-haspopup]="'dialog'"
5262
+ [attr.aria-expanded]="isOpen()"
5263
+ [attr.aria-controls]="popoverId()"
5264
+ [attr.aria-invalid]="error() ? true : null"
5265
+ [attr.aria-describedby]="ariaDescribedBy()"
5266
+ [attr.aria-label]="label() ? null : (placeholder() || 'Select date')"
5267
+ (click)="toggle()"
5268
+ (keydown)="onInputKeydown($event)"
5269
+ (blur)="onTouched()"
5270
+ readonly
5271
+ />
5272
+ @if (hasClearableValue() && !disabled()) {
5273
+ <button
5274
+ class="ct-datepicker__clear"
5275
+ type="button"
5276
+ aria-label="Clear date"
5277
+ (click)="clearValue($event)">
5278
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
5279
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
5280
+ stroke-linejoin="round" aria-hidden="true">
5281
+ <line x1="18" y1="6" x2="6" y2="18"></line>
5282
+ <line x1="6" y1="6" x2="18" y2="18"></line>
5283
+ </svg>
5284
+ </button>
5285
+ }
5286
+ </div>
5287
+
5288
+ <div
5289
+ #popoverEl
5290
+ class="ct-datepicker__popover"
5291
+ role="dialog"
5292
+ [id]="popoverId()"
5293
+ [attr.aria-label]="dialogAriaLabel()"
5294
+ [attr.aria-hidden]="!isOpen() || null">
5295
+
5296
+ <div class="ct-datepicker__header">
5297
+ <button
5298
+ class="ct-button ct-button--ghost ct-button--icon"
5299
+ type="button"
5300
+ [attr.aria-label]="prevButtonAriaLabel()"
5301
+ (click)="navigatePrevious()">
5302
+ &#8249;
5303
+ </button>
5304
+ <button
5305
+ class="ct-datepicker__title"
5306
+ type="button"
5307
+ [attr.aria-label]="titleAriaLabel()"
5308
+ (click)="drillUp()">
5309
+ {{ headerTitle() }}
5310
+ </button>
5311
+ <button
5312
+ class="ct-button ct-button--ghost ct-button--icon"
5313
+ type="button"
5314
+ [attr.aria-label]="nextButtonAriaLabel()"
5315
+ (click)="navigateNext()">
5316
+ &#8250;
5317
+ </button>
5318
+ </div>
5319
+
5320
+ @switch (currentView()) {
5321
+ @case ('days') {
5322
+ <div
5323
+ class="ct-datepicker__grid"
5324
+ role="grid"
5325
+ [attr.aria-label]="gridAriaLabel()"
5326
+ (keydown)="onDayGridKeydown($event)">
5327
+ <div class="ct-datepicker__row" role="row">
5328
+ @for (day of weekdayLabels; track $index) {
5329
+ <div class="ct-datepicker__weekday" role="columnheader" [attr.aria-label]="weekdayFullLabels[$index]">
5330
+ {{ day }}
5331
+ </div>
5332
+ }
5333
+ </div>
5334
+ @for (day of calendarDays(); track day.date.getTime()) {
5335
+ <button
5336
+ class="ct-datepicker__day"
5337
+ [attr.data-date]="getDateKey(day.date)"
5338
+ [attr.data-outside]="!day.isCurrentMonth || null"
5339
+ [attr.data-today]="day.isToday || null"
5340
+ [attr.data-unavailable]="day.isUnavailable || null"
5341
+ [attr.data-highlighted]="isDayHighlighted(day) || null"
5342
+ [attr.data-in-range]="day.isInRange || null"
5343
+ [attr.data-range-start]="day.isRangeStart || null"
5344
+ [attr.data-range-end]="day.isRangeEnd || null"
5345
+ [attr.aria-selected]="day.isSelected ? 'true' : null"
5346
+ [attr.aria-current]="day.isToday ? 'date' : null"
5347
+ [attr.aria-disabled]="day.isDisabled || day.isUnavailable ? 'true' : null"
5348
+ [disabled]="day.isDisabled || day.isUnavailable"
5349
+ [attr.tabindex]="getDayTabIndex(day)"
5350
+ role="gridcell"
5351
+ type="button"
5352
+ (click)="onDayClick(day)">
5353
+ {{ day.date.getDate() }}
5354
+ </button>
5355
+ }
5356
+ </div>
5357
+ <div class="ct-datepicker__footer">
5358
+ <button
5359
+ class="ct-datepicker__today"
5360
+ type="button"
5361
+ [disabled]="isTodayDisabled()"
5362
+ (click)="goToToday()">
5363
+ Today
5364
+ </button>
5365
+ </div>
5366
+ }
5367
+ @case ('months') {
5368
+ <div
5369
+ class="ct-datepicker__month-grid"
5370
+ role="grid"
5371
+ aria-label="Select month"
5372
+ (keydown)="onMonthGridKeydown($event)">
5373
+ @for (m of monthItems(); track m.index) {
5374
+ <button
5375
+ class="ct-datepicker__month"
5376
+ [attr.aria-selected]="m.isSelected ? 'true' : null"
5377
+ [attr.data-highlighted]="isMonthHighlighted(m.index) || null"
5378
+ [disabled]="m.isDisabled"
5379
+ [attr.tabindex]="getMonthTabIndex(m.index)"
5380
+ role="gridcell"
5381
+ type="button"
5382
+ (click)="selectMonth(m.index)">
5383
+ {{ m.shortLabel }}
5384
+ </button>
5385
+ }
5386
+ </div>
5387
+ }
5388
+ @case ('years') {
5389
+ <div
5390
+ class="ct-datepicker__year-grid"
5391
+ role="grid"
5392
+ aria-label="Select year"
5393
+ (keydown)="onYearGridKeydown($event)">
5394
+ @for (y of yearItems(); track y.value) {
5395
+ <button
5396
+ class="ct-datepicker__year"
5397
+ [attr.aria-selected]="y.isSelected ? 'true' : null"
5398
+ [attr.data-highlighted]="isYearHighlighted(y.value) || null"
5399
+ [disabled]="y.isDisabled"
5400
+ [attr.tabindex]="getYearTabIndex(y.value)"
5401
+ role="gridcell"
5402
+ type="button"
5403
+ (click)="selectYear(y.value)">
5404
+ {{ y.value }}
5405
+ </button>
5406
+ }
5407
+ </div>
5408
+ }
5409
+ }
5410
+ </div>
5411
+ </div>
5412
+
5413
+ @if (hint() && !error()) {
5414
+ <div class="ct-field__hint" [id]="hintId()">{{ hint() }}</div>
5415
+ }
5416
+ @if (error()) {
5417
+ <div class="ct-field__error" [id]="errorId()">{{ error() }}</div>
5418
+ }
5419
+ </div>
5420
+ `, 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"] }]
5421
+ }], 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 }] }] } });
5422
+
5423
+ /**
5424
+ * Chip component for labels, tags, statuses, and interactive filters.
5425
+ *
5426
+ * @example Static chip
5427
+ * <af-chip variant="success" size="sm">Resolved</af-chip>
5428
+ *
5429
+ * @example Toggle chip with two-way binding
5430
+ * <af-chip selectable [(selected)]="isActive">Filter</af-chip>
5431
+ *
5432
+ * @example Removable chip
5433
+ * <af-chip removable (removed)="onRemove()">Status: Active</af-chip>
5434
+ */
5435
+ class AfChipComponent {
5436
+ /** Semantic color variant. */
5437
+ variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : []));
5438
+ /** Visual style: subtle (filled background), outline, or solid. */
5439
+ appearance = input('subtle', ...(ngDevMode ? [{ debugName: "appearance" }] : []));
5440
+ /** Size of the chip. */
5441
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : []));
5442
+ /** Opaque value for identifying this chip in lists or groups. */
5443
+ value = input(...(ngDevMode ? [undefined, { debugName: "value" }] : []));
5444
+ /** Enables toggle behavior with hover, active, focus styles, and keyboard support. */
5445
+ selectable = input(false, { ...(ngDevMode ? { debugName: "selectable" } : {}), transform: booleanAttribute });
5446
+ /** Selected state for toggle chips. Supports two-way binding via `[(selected)]`. */
5447
+ selected = model(false, ...(ngDevMode ? [{ debugName: "selected" }] : []));
5448
+ /** Disables the chip, preventing all interaction. */
5449
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : {}), transform: booleanAttribute });
5450
+ /** Shows a remove button inside the chip. */
5451
+ removable = input(false, { ...(ngDevMode ? { debugName: "removable" } : {}), transform: booleanAttribute });
5452
+ /** Shows a dot status indicator instead of an icon. */
5453
+ dot = input(false, { ...(ngDevMode ? { debugName: "dot" } : {}), transform: booleanAttribute });
5454
+ /** Accessible label for icon-only or truncated chips. */
5455
+ ariaLabel = input(...(ngDevMode ? [undefined, { debugName: "ariaLabel" }] : []));
5456
+ /** Accessible label for the remove button. Should be localized by the consumer. */
5457
+ removeAriaLabel = input('Remove', ...(ngDevMode ? [{ debugName: "removeAriaLabel" }] : []));
5458
+ /** Emits the chip's `value` when the remove button is clicked or Delete/Backspace is pressed. */
5459
+ removed = output();
5460
+ chipRole = computed(() => {
5461
+ if (!this.selectable())
5462
+ return null;
5463
+ return this.removable() ? 'group' : 'button';
5464
+ }, ...(ngDevMode ? [{ debugName: "chipRole" }] : []));
5465
+ iconClasses = computed(() => {
5466
+ const classes = ['ct-chip__icon'];
5467
+ if (this.variant() !== 'default') {
5468
+ classes.push(`ct-chip__icon--${this.variant()}`);
3854
5469
  }
3855
- else {
3856
- this.selectedDate.set(null);
3857
- this.focusedDate.set(null);
5470
+ return classes.join(' ');
5471
+ }, ...(ngDevMode ? [{ debugName: "iconClasses" }] : []));
5472
+ chipClasses = computed(() => {
5473
+ const classes = ['ct-chip'];
5474
+ if (this.size() !== 'md') {
5475
+ classes.push(`ct-chip--${this.size()}`);
5476
+ }
5477
+ if (this.appearance() !== 'subtle') {
5478
+ classes.push(`ct-chip--${this.appearance()}`);
5479
+ }
5480
+ if (this.variant() !== 'default') {
5481
+ classes.push(`ct-chip--${this.variant()}`);
5482
+ }
5483
+ if (this.selectable()) {
5484
+ classes.push('ct-chip--interactive');
5485
+ }
5486
+ if (this.selectable() && this.selected()) {
5487
+ classes.push('ct-chip--selected');
3858
5488
  }
5489
+ if (this.disabled()) {
5490
+ classes.push('ct-chip--disabled');
5491
+ }
5492
+ return classes.join(' ');
5493
+ }, ...(ngDevMode ? [{ debugName: "chipClasses" }] : []));
5494
+ handleClick() {
5495
+ if (!this.selectable() || this.disabled())
5496
+ return;
5497
+ this.selected.update(v => !v);
3859
5498
  }
3860
- registerOnChange(fn) {
3861
- this.onChange = fn;
5499
+ handleKeydown(event) {
5500
+ if (event.target.closest('.ct-chip__remove'))
5501
+ return;
5502
+ if (!this.selectable() || this.disabled())
5503
+ return;
5504
+ event.preventDefault();
5505
+ this.selected.update(v => !v);
3862
5506
  }
3863
- registerOnTouched(fn) {
3864
- this.onTouched = fn;
5507
+ handleRemoveKeydown(event) {
5508
+ if (!this.removable() || this.disabled())
5509
+ return;
5510
+ event.preventDefault();
5511
+ this.removed.emit(this.value());
3865
5512
  }
3866
- setDisabledState(isDisabled) {
3867
- this.disabled.set(isDisabled);
5513
+ handleRemove(event) {
5514
+ event.stopPropagation();
5515
+ this.removed.emit(this.value());
3868
5516
  }
3869
- 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: [
3871
- {
3872
- provide: NG_VALUE_ACCESSOR,
3873
- 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">
3878
- @if (label()) {
3879
- <label class="ct-field__label" [attr.for]="inputId()">{{ label() }}</label>
5517
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfChipComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5518
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AfChipComponent, isStandalone: true, selector: "af-chip", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, appearance: { classPropertyName: "appearance", publicName: "appearance", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, selected: { classPropertyName: "selected", publicName: "selected", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, removable: { classPropertyName: "removable", publicName: "removable", isSignal: true, isRequired: false, transformFunction: null }, dot: { classPropertyName: "dot", publicName: "dot", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, removeAriaLabel: { classPropertyName: "removeAriaLabel", publicName: "removeAriaLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selected: "selectedChange", removed: "removed" }, host: { listeners: { "click": "handleClick()", "keydown.enter": "handleKeydown($event)", "keydown.space": "handleKeydown($event)", "keydown.delete": "handleRemoveKeydown($event)", "keydown.backspace": "handleRemoveKeydown($event)" }, properties: { "class": "chipClasses()", "attr.role": "chipRole()", "attr.tabindex": "selectable() && !removable() && !disabled() ? \"0\" : null", "attr.aria-pressed": "selectable() && !removable() ? selected() : null", "attr.aria-disabled": "disabled() || null", "attr.aria-label": "ariaLabel() || null" } }, ngImport: i0, template: `
5519
+ <span
5520
+ class="ct-chip__action"
5521
+ [attr.role]="selectable() && removable() ? 'button' : null"
5522
+ [attr.tabindex]="selectable() && removable() && !disabled() ? '0' : null"
5523
+ [attr.aria-pressed]="selectable() && removable() ? selected() : null">
5524
+ @if (dot()) {
5525
+ <span class="ct-chip__dot" aria-hidden="true"></span>
5526
+ } @else {
5527
+ <span class="ct-chip__avatar">
5528
+ <ng-content select="[chipAvatar]" />
5529
+ </span>
5530
+
5531
+ <span [class]="iconClasses()" aria-hidden="true">
5532
+ <ng-content select="af-icon,[chipIcon]" />
5533
+ </span>
3880
5534
  }
3881
5535
 
3882
- <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
- />
5536
+ <span class="ct-chip__label">
5537
+ <ng-content />
5538
+ </span>
3900
5539
 
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>
5540
+ @if (selectable()) {
5541
+ <span class="ct-chip__check" aria-hidden="true"></span>
5542
+ }
5543
+ </span>
3927
5544
 
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()) {
3933
- <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)"
3941
- type="button"
3942
- (click)="selectDate(day.date)">
3943
- {{ day.date.getDate() }}
3944
- </button>
3945
- }
3946
- </div>
3947
- </div>
3948
- }
3949
- </div>
3950
- </div>
3951
- `, isInline: true, styles: [":host{display:block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5545
+ @if (removable() && !disabled()) {
5546
+ <button
5547
+ type="button"
5548
+ class="ct-chip__remove"
5549
+ [attr.aria-label]="removeAriaLabel()"
5550
+ (click)="handleRemove($event)">
5551
+ <svg
5552
+ viewBox="0 0 24 24"
5553
+ width="16"
5554
+ height="16"
5555
+ fill="none"
5556
+ stroke="currentColor"
5557
+ stroke-width="2"
5558
+ stroke-linecap="round"
5559
+ aria-hidden="true">
5560
+ <line x1="18" y1="6" x2="6" y2="18" />
5561
+ <line x1="6" y1="6" x2="18" y2="18" />
5562
+ </svg>
5563
+ </button>
5564
+ }
5565
+ `, isInline: true, styles: [":host{display:inline-flex;align-items:center}.ct-chip__action{display:contents}.ct-chip__action:focus-visible{outline:none}:host:has(.ct-chip__action:focus-visible){outline:2px solid var(--color-focus-ring, Highlight);outline-offset:2px}.ct-chip__icon:not(:has(*)),.ct-chip__avatar:not(:has(*)){display:none}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3952
5566
  }
3953
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfDatepickerComponent, decorators: [{
5567
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfChipComponent, decorators: [{
3954
5568
  type: Component,
3955
- args: [{ selector: 'af-datepicker', changeDetection: ChangeDetectionStrategy.OnPush, providers: [
3956
- {
3957
- provide: NG_VALUE_ACCESSOR,
3958
- useExisting: forwardRef(() => AfDatepickerComponent),
3959
- multi: true
3960
- }
3961
- ], host: {
3962
- '(document:keydown.escape)': 'onEscape()',
3963
- '(document:click)': 'onDocumentClick($event)',
5569
+ args: [{ selector: 'af-chip', changeDetection: ChangeDetectionStrategy.OnPush, host: {
5570
+ '[class]': 'chipClasses()',
5571
+ '[attr.role]': 'chipRole()',
5572
+ '[attr.tabindex]': 'selectable() && !removable() && !disabled() ? "0" : null',
5573
+ '[attr.aria-pressed]': 'selectable() && !removable() ? selected() : null',
5574
+ '[attr.aria-disabled]': 'disabled() || null',
5575
+ '[attr.aria-label]': 'ariaLabel() || null',
5576
+ '(click)': 'handleClick()',
5577
+ '(keydown.enter)': 'handleKeydown($event)',
5578
+ '(keydown.space)': 'handleKeydown($event)',
5579
+ '(keydown.delete)': 'handleRemoveKeydown($event)',
5580
+ '(keydown.backspace)': 'handleRemoveKeydown($event)',
3964
5581
  }, template: `
3965
- <div class="ct-field">
3966
- @if (label()) {
3967
- <label class="ct-field__label" [attr.for]="inputId()">{{ label() }}</label>
5582
+ <span
5583
+ class="ct-chip__action"
5584
+ [attr.role]="selectable() && removable() ? 'button' : null"
5585
+ [attr.tabindex]="selectable() && removable() && !disabled() ? '0' : null"
5586
+ [attr.aria-pressed]="selectable() && removable() ? selected() : null">
5587
+ @if (dot()) {
5588
+ <span class="ct-chip__dot" aria-hidden="true"></span>
5589
+ } @else {
5590
+ <span class="ct-chip__avatar">
5591
+ <ng-content select="[chipAvatar]" />
5592
+ </span>
5593
+
5594
+ <span [class]="iconClasses()" aria-hidden="true">
5595
+ <ng-content select="af-icon,[chipIcon]" />
5596
+ </span>
3968
5597
  }
3969
5598
 
3970
- <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
- />
5599
+ <span class="ct-chip__label">
5600
+ <ng-content />
5601
+ </span>
3988
5602
 
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>
5603
+ @if (selectable()) {
5604
+ <span class="ct-chip__check" aria-hidden="true"></span>
5605
+ }
5606
+ </span>
4015
5607
 
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()) {
4021
- <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)"
4029
- type="button"
4030
- (click)="selectDate(day.date)">
4031
- {{ day.date.getDate() }}
4032
- </button>
4033
- }
4034
- </div>
4035
- </div>
4036
- }
4037
- </div>
4038
- </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 }] }] } });
5608
+ @if (removable() && !disabled()) {
5609
+ <button
5610
+ type="button"
5611
+ class="ct-chip__remove"
5612
+ [attr.aria-label]="removeAriaLabel()"
5613
+ (click)="handleRemove($event)">
5614
+ <svg
5615
+ viewBox="0 0 24 24"
5616
+ width="16"
5617
+ height="16"
5618
+ fill="none"
5619
+ stroke="currentColor"
5620
+ stroke-width="2"
5621
+ stroke-linecap="round"
5622
+ aria-hidden="true">
5623
+ <line x1="18" y1="6" x2="6" y2="18" />
5624
+ <line x1="6" y1="6" x2="18" y2="18" />
5625
+ </svg>
5626
+ </button>
5627
+ }
5628
+ `, styles: [":host{display:inline-flex;align-items:center}.ct-chip__action{display:contents}.ct-chip__action:focus-visible{outline:none}:host:has(.ct-chip__action:focus-visible){outline:2px solid var(--color-focus-ring, Highlight);outline-offset:2px}.ct-chip__icon:not(:has(*)),.ct-chip__avatar:not(:has(*)){display:none}\n"] }]
5629
+ }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], appearance: [{ type: i0.Input, args: [{ isSignal: true, alias: "appearance", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }, { type: i0.Output, args: ["selectedChange"] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], removable: [{ type: i0.Input, args: [{ isSignal: true, alias: "removable", required: false }] }], dot: [{ type: i0.Input, args: [{ isSignal: true, alias: "dot", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], removeAriaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "removeAriaLabel", required: false }] }], removed: [{ type: i0.Output, args: ["removed"] }] } });
4041
5630
 
4042
5631
  /**
4043
5632
  * Chip input component for managing a list of string values.
@@ -4440,21 +6029,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
4440
6029
  }], 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 }] }] } });
4441
6030
 
4442
6031
  /**
4443
- * Badge component for status indicators
6032
+ * Badge component for status indicators, labels, and counts.
4444
6033
  *
4445
- * @example
4446
- * <af-badge variant="success" icon="+">Approved</af-badge>
4447
- * <af-badge variant="danger">Blocked</af-badge>
6034
+ * Wraps the Construct Design System `ct-badge` with semantic color
6035
+ * variants and optional icon or dot indicators.
6036
+ *
6037
+ * @example Basic usage
6038
+ * <af-badge variant="success">Approved</af-badge>
6039
+ *
6040
+ * @example With icon
6041
+ * <af-badge variant="danger" icon="+">Blocked</af-badge>
6042
+ *
6043
+ * @example With dot indicator
6044
+ * <af-badge variant="info" dot>Online</af-badge>
6045
+ *
6046
+ * @example Status badge for screen readers
6047
+ * <af-badge variant="warning" role="status" ariaLabel="Build status: failing">
6048
+ * Failing
6049
+ * </af-badge>
6050
+ *
6051
+ * @accessibility
6052
+ * - Non-interactive element — no keyboard navigation required.
6053
+ * - Decorative elements (icon, dot) are hidden from screen readers via `aria-hidden`.
6054
+ * - Use `ariaLabel` when the badge has no visible text or when the visual content
6055
+ * alone does not convey the full meaning.
6056
+ * - Set `role="status"` when the badge reflects a live value that screen readers
6057
+ * should announce on change.
4448
6058
  */
4449
6059
  class AfBadgeComponent {
6060
+ /** ARIA role, e.g. `"status"` for live status badges. */
6061
+ role = input('', ...(ngDevMode ? [{ debugName: "role" }] : []));
4450
6062
  /** Accessible label, useful when the badge has no visible text. */
4451
6063
  ariaLabel = input('', ...(ngDevMode ? [{ debugName: "ariaLabel" }] : []));
4452
- /** Color variant. */
6064
+ /** Semantic color variant. */
4453
6065
  variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : []));
4454
- /** Icon character to display */
6066
+ /** Icon character to display before the label. */
4455
6067
  icon = input('', ...(ngDevMode ? [{ debugName: "icon" }] : []));
4456
- /** Show a dot indicator instead of icon */
4457
- dot = input(false, ...(ngDevMode ? [{ debugName: "dot" }] : []));
6068
+ /** Show a dot indicator instead of an icon. */
6069
+ dot = input(false, { ...(ngDevMode ? { debugName: "dot" } : {}), transform: booleanAttribute });
6070
+ /** Computed CSS classes combining base class and variant/icon modifiers. */
4458
6071
  badgeClasses = computed(() => {
4459
6072
  const classes = ['ct-badge'];
4460
6073
  if (this.variant() !== 'default') {
@@ -4466,32 +6079,91 @@ class AfBadgeComponent {
4466
6079
  return classes.join(' ');
4467
6080
  }, ...(ngDevMode ? [{ debugName: "badgeClasses" }] : []));
4468
6081
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4469
- 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: `
4470
- <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
4471
- @if (icon()) {
4472
- <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
4473
- }
4474
- @if (dot()) {
4475
- <span class="ct-badge__dot" aria-hidden="true"></span>
4476
- }
4477
- <ng-content />
4478
- </span>
4479
- `, isInline: true, styles: [":host{display:inline-block}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6082
+ 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: `
6083
+ @if (icon()) {
6084
+ <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
6085
+ }
6086
+ @if (dot()) {
6087
+ <span class="ct-badge__dot" aria-hidden="true"></span>
6088
+ }
6089
+ <ng-content />
6090
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4480
6091
  }
4481
6092
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfBadgeComponent, decorators: [{
4482
6093
  type: Component,
4483
- args: [{ selector: 'af-badge', changeDetection: ChangeDetectionStrategy.OnPush, template: `
4484
- <span [class]="badgeClasses()" [attr.aria-label]="ariaLabel() || null">
4485
- @if (icon()) {
4486
- <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
4487
- }
4488
- @if (dot()) {
4489
- <span class="ct-badge__dot" aria-hidden="true"></span>
4490
- }
4491
- <ng-content />
4492
- </span>
4493
- `, styles: [":host{display:inline-block}\n"] }]
4494
- }], 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 }] }] } });
6094
+ args: [{
6095
+ selector: 'af-badge',
6096
+ changeDetection: ChangeDetectionStrategy.OnPush,
6097
+ host: {
6098
+ '[class]': 'badgeClasses()',
6099
+ '[attr.role]': 'role() || null',
6100
+ '[attr.aria-label]': 'ariaLabel() || null',
6101
+ },
6102
+ template: `
6103
+ @if (icon()) {
6104
+ <span class="ct-badge__icon" aria-hidden="true">{{ icon() }}</span>
6105
+ }
6106
+ @if (dot()) {
6107
+ <span class="ct-badge__dot" aria-hidden="true"></span>
6108
+ }
6109
+ <ng-content />
6110
+ `,
6111
+ }]
6112
+ }], 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 }] }] } });
6113
+
6114
+ /**
6115
+ * Test harness for AfBadgeComponent.
6116
+ *
6117
+ * Provides a semantic API for querying badge state in tests,
6118
+ * abstracting DOM details behind readable method names.
6119
+ *
6120
+ * @example
6121
+ * const harness = new AfBadgeHarness(fixture.nativeElement);
6122
+ * expect(harness.getText()).toBe('Approved');
6123
+ * expect(harness.hasClass('ct-badge--success')).toBe(true);
6124
+ */
6125
+ class AfBadgeHarness {
6126
+ hostEl;
6127
+ constructor(container) {
6128
+ const el = container.querySelector('af-badge');
6129
+ if (!el) {
6130
+ throw new Error('AfBadgeHarness: af-badge element not found in container.');
6131
+ }
6132
+ this.hostEl = el;
6133
+ }
6134
+ /** Returns the trimmed text content of the badge (projected content only). */
6135
+ getText() {
6136
+ return this.hostEl.textContent?.trim() ?? '';
6137
+ }
6138
+ /** Returns the full `class` attribute string of the host element. */
6139
+ getClasses() {
6140
+ return this.hostEl.className;
6141
+ }
6142
+ /** Returns whether the host element has the given CSS class. */
6143
+ hasClass(className) {
6144
+ return this.hostEl.classList.contains(className);
6145
+ }
6146
+ /** Returns the `aria-label` attribute value, or `null` if absent. */
6147
+ getAriaLabel() {
6148
+ return this.hostEl.getAttribute('aria-label');
6149
+ }
6150
+ /** Returns the `role` attribute value, or `null` if absent. */
6151
+ getRole() {
6152
+ return this.hostEl.getAttribute('role');
6153
+ }
6154
+ /** Returns whether the badge contains an icon element. */
6155
+ hasIcon() {
6156
+ return this.hostEl.querySelector('.ct-badge__icon') !== null;
6157
+ }
6158
+ /** Returns whether the badge contains a dot indicator. */
6159
+ hasDot() {
6160
+ return this.hostEl.querySelector('.ct-badge__dot') !== null;
6161
+ }
6162
+ /** Returns the text content of the icon element, or empty string if absent. */
6163
+ getIconText() {
6164
+ return this.hostEl.querySelector('.ct-badge__icon')?.textContent?.trim() ?? '';
6165
+ }
6166
+ }
4495
6167
 
4496
6168
  /**
4497
6169
  * Progress bar for showing completion state
@@ -6158,7 +7830,7 @@ class AfFileUploadComponent {
6158
7830
  {{ liveAnnouncement() }}
6159
7831
  </span>
6160
7832
  </div>
6161
- `, 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 });
7833
+ `, 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 });
6162
7834
  }
6163
7835
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AfFileUploadComponent, decorators: [{
6164
7836
  type: Component,
@@ -6257,6 +7929,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6257
7929
  `, 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"] }]
6258
7930
  }], 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 }] }] } });
6259
7931
 
7932
+ /**
7933
+ * Injection token to override select-menu screen-reader announcements
7934
+ * and the fallback `aria-label`.
7935
+ *
7936
+ * @example
7937
+ * providers: [{
7938
+ * provide: AF_SELECT_MENU_I18N,
7939
+ * useValue: {
7940
+ * selectOption: 'Option auswählen',
7941
+ * opened: '{count} Optionen verfügbar',
7942
+ * closed: 'Auswahl geschlossen',
7943
+ * selected: '{label} ausgewählt',
7944
+ * deselected: '{label} abgewählt',
7945
+ * countSelected: '{count} Optionen ausgewählt',
7946
+ * },
7947
+ * }]
7948
+ */
7949
+ const AF_SELECT_MENU_I18N = new InjectionToken('AfSelectMenuI18n', {
7950
+ factory: () => ({
7951
+ selectOption: 'Select option',
7952
+ opened: '{count} options available',
7953
+ closed: 'Selection closed',
7954
+ selected: '{label} selected',
7955
+ deselected: '{label} deselected',
7956
+ countSelected: '{count} options selected',
7957
+ }),
7958
+ });
7959
+
6260
7960
  /**
6261
7961
  * Custom dropdown select component with keyboard navigation
6262
7962
  * and full ARIA listbox pattern for single and multi-select.
@@ -6277,9 +7977,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6277
7977
  * [multiple]="true"
6278
7978
  * [formControl]="rolesControl">
6279
7979
  * </af-select-menu>
7980
+ *
7981
+ * @accessibility
7982
+ * - Implements the WAI-ARIA Listbox pattern with a combobox trigger.
7983
+ * - Keyboard: ArrowDown/Up to move highlight, Enter/Space to select,
7984
+ * Escape to close, Home/End to jump, Tab to select-and-close (single) or close (multi).
7985
+ * - Focus stays on the combobox trigger; `aria-activedescendant` tracks the highlighted option.
7986
+ * - Screen-reader announcements via {@link AriaLiveAnnouncer} for open/close/selection changes.
7987
+ * - All user-facing strings are configurable via {@link AF_SELECT_MENU_I18N} for i18n.
7988
+ * - Uses CSS logical properties for RTL layout support.
7989
+ * - `aria-describedby` links to hint or error text; `aria-invalid` is set on error state.
7990
+ * - Disabled options are marked with `aria-disabled` and skipped during keyboard navigation.
6280
7991
  */
6281
7992
  class AfSelectMenuComponent {
6282
7993
  static nextId = 0;
7994
+ i18n = inject(AF_SELECT_MENU_I18N);
7995
+ announcer = inject(AriaLiveAnnouncer);
6283
7996
  /** Label shown above the select */
6284
7997
  label = input('', ...(ngDevMode ? [{ debugName: "label" }] : []));
6285
7998
  /** Placeholder text when nothing is selected */
@@ -6397,6 +8110,8 @@ class AfSelectMenuComponent {
6397
8110
  this.isOpen.set(true);
6398
8111
  this.highlightedIndex.set(this.findSelectedOrFirstIndex());
6399
8112
  this.scrollHighlightedIntoView();
8113
+ const enabledCount = this.options().filter((o) => !o.disabled).length;
8114
+ this.announcer.announce(this.i18n.opened.replace('{count}', String(enabledCount)));
6400
8115
  }
6401
8116
  /** Closes the listbox */
6402
8117
  closeListbox() {
@@ -6404,6 +8119,7 @@ class AfSelectMenuComponent {
6404
8119
  return;
6405
8120
  this.isOpen.set(false);
6406
8121
  this.highlightedIndex.set(-1);
8122
+ this.announcer.announce(this.i18n.closed);
6407
8123
  }
6408
8124
  /** Selects or toggles an option */
6409
8125
  selectOption(option) {
@@ -6413,15 +8129,21 @@ class AfSelectMenuComponent {
6413
8129
  if (this.multiple()) {
6414
8130
  const current = this.value() ?? [];
6415
8131
  const idx = current.findIndex((v) => cmp(v, option.value));
6416
- const next = idx >= 0
8132
+ const wasSelected = idx >= 0;
8133
+ const next = wasSelected
6417
8134
  ? [...current.slice(0, idx), ...current.slice(idx + 1)]
6418
8135
  : [...current, option.value];
6419
8136
  this.value.set(next);
6420
8137
  this.onChange(next);
8138
+ const msg = wasSelected
8139
+ ? this.i18n.deselected.replace('{label}', option.label)
8140
+ : this.i18n.selected.replace('{label}', option.label);
8141
+ this.announcer.announce(`${msg}, ${this.i18n.countSelected.replace('{count}', String(next.length))}`);
6421
8142
  }
6422
8143
  else {
6423
8144
  this.value.set(option.value);
6424
8145
  this.onChange(option.value);
8146
+ this.announcer.announce(this.i18n.selected.replace('{label}', option.label));
6425
8147
  this.closeListbox();
6426
8148
  this.triggerRef()?.nativeElement.focus();
6427
8149
  }
@@ -6613,7 +8335,7 @@ class AfSelectMenuComponent {
6613
8335
  aria-haspopup="listbox"
6614
8336
  [attr.aria-controls]="listboxId"
6615
8337
  [attr.aria-labelledby]="label() ? labelId : null"
6616
- [attr.aria-label]="label() ? null : 'Select option'"
8338
+ [attr.aria-label]="label() ? null : i18n.selectOption"
6617
8339
  [attr.aria-activedescendant]="activeDescendantId()"
6618
8340
  [attr.aria-invalid]="error() ? true : null"
6619
8341
  [attr.aria-describedby]="getAriaDescribedBy()"
@@ -6720,7 +8442,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6720
8442
  aria-haspopup="listbox"
6721
8443
  [attr.aria-controls]="listboxId"
6722
8444
  [attr.aria-labelledby]="label() ? labelId : null"
6723
- [attr.aria-label]="label() ? null : 'Select option'"
8445
+ [attr.aria-label]="label() ? null : i18n.selectOption"
6724
8446
  [attr.aria-activedescendant]="activeDescendantId()"
6725
8447
  [attr.aria-invalid]="error() ? true : null"
6726
8448
  [attr.aria-describedby]="getAriaDescribedBy()"
@@ -6794,6 +8516,141 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
6794
8516
  `, styles: [":host{display:block}\n"] }]
6795
8517
  }], 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 }] }] } });
6796
8518
 
8519
+ /**
8520
+ * Test harness for AfSelectMenuComponent.
8521
+ *
8522
+ * Provides a semantic API for interacting with the select-menu in tests,
8523
+ * abstracting DOM details behind readable method names.
8524
+ *
8525
+ * @example
8526
+ * const harness = new AfSelectMenuHarness(fixture.nativeElement);
8527
+ * harness.click();
8528
+ * expect(harness.isOpen()).toBe(true);
8529
+ * expect(harness.getOptionCount()).toBe(4);
8530
+ * harness.clickOption(1);
8531
+ * expect(harness.getTriggerText()).toBe('Banana');
8532
+ */
8533
+ class AfSelectMenuHarness {
8534
+ hostEl;
8535
+ constructor(container) {
8536
+ const el = container.querySelector('af-select-menu');
8537
+ if (!el) {
8538
+ throw new Error('AfSelectMenuHarness: af-select-menu element not found in container.');
8539
+ }
8540
+ this.hostEl = el;
8541
+ }
8542
+ /** Returns the trigger `<button>` element. */
8543
+ getTriggerElement() {
8544
+ const btn = this.hostEl.querySelector('.ct-select-menu__trigger');
8545
+ if (!btn) {
8546
+ throw new Error('AfSelectMenuHarness: trigger button not found.');
8547
+ }
8548
+ return btn;
8549
+ }
8550
+ /** Returns the trimmed display text of the trigger. */
8551
+ getTriggerText() {
8552
+ const value = this.hostEl.querySelector('.ct-select-menu__value');
8553
+ return value?.textContent?.trim() ?? '';
8554
+ }
8555
+ /** Clicks the trigger button. */
8556
+ click() {
8557
+ this.getTriggerElement().click();
8558
+ }
8559
+ /** Returns whether the trigger is disabled. */
8560
+ isDisabled() {
8561
+ return this.getTriggerElement().disabled;
8562
+ }
8563
+ /** Returns whether the listbox is currently open. */
8564
+ isOpen() {
8565
+ const menu = this.hostEl.querySelector('.ct-select-menu');
8566
+ return menu?.getAttribute('data-state') === 'open';
8567
+ }
8568
+ /** Returns the `aria-expanded` attribute value. */
8569
+ getAriaExpanded() {
8570
+ return this.getTriggerElement().getAttribute('aria-expanded');
8571
+ }
8572
+ /** Returns the `aria-label` attribute value. */
8573
+ getAriaLabel() {
8574
+ return this.getTriggerElement().getAttribute('aria-label');
8575
+ }
8576
+ /** Returns the `aria-labelledby` attribute value. */
8577
+ getAriaLabelledBy() {
8578
+ return this.getTriggerElement().getAttribute('aria-labelledby');
8579
+ }
8580
+ /** Returns the `aria-activedescendant` attribute value. */
8581
+ getAriaActiveDescendant() {
8582
+ return this.getTriggerElement().getAttribute('aria-activedescendant');
8583
+ }
8584
+ /** Returns the `aria-invalid` attribute value. */
8585
+ getAriaInvalid() {
8586
+ return this.getTriggerElement().getAttribute('aria-invalid');
8587
+ }
8588
+ /** Returns the `aria-required` attribute value. */
8589
+ getAriaRequired() {
8590
+ return this.getTriggerElement().getAttribute('aria-required');
8591
+ }
8592
+ /** Returns the `aria-describedby` attribute value. */
8593
+ getAriaDescribedBy() {
8594
+ return this.getTriggerElement().getAttribute('aria-describedby');
8595
+ }
8596
+ /** Returns all option elements inside the listbox. */
8597
+ getOptions() {
8598
+ return Array.from(this.hostEl.querySelectorAll('[role="option"]'));
8599
+ }
8600
+ /** Returns the trimmed text of the option at the given index. */
8601
+ getOptionText(index) {
8602
+ const options = this.getOptions();
8603
+ if (index < 0 || index >= options.length) {
8604
+ throw new Error(`AfSelectMenuHarness: option index ${index} out of bounds (${options.length} options).`);
8605
+ }
8606
+ return options[index].textContent?.trim() ?? '';
8607
+ }
8608
+ /** Returns the number of options. */
8609
+ getOptionCount() {
8610
+ return this.getOptions().length;
8611
+ }
8612
+ /** Returns whether the option at the given index is selected. */
8613
+ isOptionSelected(index) {
8614
+ return this.getOptions()[index]?.getAttribute('aria-selected') === 'true';
8615
+ }
8616
+ /** Returns whether the option at the given index is disabled. */
8617
+ isOptionDisabled(index) {
8618
+ return this.getOptions()[index]?.getAttribute('aria-disabled') === 'true';
8619
+ }
8620
+ /** Returns whether the option at the given index is highlighted. */
8621
+ isOptionHighlighted(index) {
8622
+ return this.getOptions()[index]?.hasAttribute('data-highlighted') ?? false;
8623
+ }
8624
+ /** Clicks the option at the given index. */
8625
+ clickOption(index) {
8626
+ const options = this.getOptions();
8627
+ if (index < 0 || index >= options.length) {
8628
+ throw new Error(`AfSelectMenuHarness: option index ${index} out of bounds (${options.length} options).`);
8629
+ }
8630
+ options[index].click();
8631
+ }
8632
+ /** Returns the trimmed label text, or empty string if no label. */
8633
+ getLabelText() {
8634
+ const label = this.hostEl.querySelector('.ct-field__label');
8635
+ return label?.textContent?.trim() ?? '';
8636
+ }
8637
+ /** Returns the trimmed hint text, or empty string if no hint. */
8638
+ getHintText() {
8639
+ const hint = this.hostEl.querySelector('.ct-field__hint');
8640
+ return hint?.textContent?.trim() ?? '';
8641
+ }
8642
+ /** Returns the trimmed error text, or empty string if no error. */
8643
+ getErrorText() {
8644
+ const error = this.hostEl.querySelector('.ct-field__error');
8645
+ return error?.textContent?.trim() ?? '';
8646
+ }
8647
+ /** Returns whether the select-menu wrapper has the given CSS class. */
8648
+ hasClass(className) {
8649
+ const menu = this.hostEl.querySelector('.ct-select-menu');
8650
+ return menu?.classList.contains(className) ?? false;
8651
+ }
8652
+ }
8653
+
6797
8654
  let nextId = 0;
6798
8655
  /**
6799
8656
  * Individual navigation item used within af-navbar or af-toolbar.
@@ -8275,5 +10132,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImpor
8275
10132
  * Generated bundle index. Do not edit.
8276
10133
  */
8277
10134
 
8278
- export { AfAccordionComponent, AfAccordionItemComponent, AfAlertComponent, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, 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 };
10135
+ export { AF_ACCORDION_I18N, AF_ALERT_I18N, AF_INPUT_I18N, AF_SELECT_MENU_I18N, AfAccordionComponent, AfAccordionHarness, AfAccordionItemComponent, AfAccordionItemHarness, AfAlertComponent, AfAlertHarness, AfAppShellComponent, AfAppShellPageHeaderComponent, AfAppShellV2Component, AfAppShellV2ToolbarComponent, AfAvatarComponent, AfBadgeComponent, AfBadgeHarness, AfBannerComponent, AfBreadcrumbsComponent, AfButtonComponent, AfButtonHarness, AfCardComponent, AfCellDefDirective, AfCheckboxComponent, AfChipComponent, AfChipInputComponent, AfComboboxComponent, AfDataTableComponent, AfDatepickerComponent, AfDividerComponent, AfDrawerComponent, AfDropdownComponent, AfEmptyStateComponent, AfFieldComponent, AfFileUploadComponent, AfFormatLabelPipe, AfIconComponent, AfInputComponent, AfInputHarness, AfModalComponent, AfNavItemComponent, AfNavTabsComponent, AfNavbarComponent, AfPaginationComponent, AfPopoverComponent, AfPopoverTriggerDirective, AfProgressBarComponent, AfRadioComponent, AfRadioGroupComponent, AfSelectComponent, AfSelectMenuComponent, AfSelectMenuHarness, AfSidebarComponent, AfSkeletonComponent, AfSkipLinkComponent, AfSliderComponent, AfSpinnerComponent, AfSwitchComponent, AfTabPanelComponent, AfTableBodyComponent, AfTableCellComponent, AfTableComponent, AfTableHeaderCellComponent, AfTableHeaderComponent, AfTableRowComponent, AfTabsComponent, AfTextareaComponent, AfToastContainerComponent, AfToastService, AfToggleGroupComponent, AfToolbarComponent, AfTooltipDirective };
8279
10136
  //# sourceMappingURL=neuravision-ng-construct.mjs.map