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