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