@synthaxai/ui 1.0.0 → 1.1.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.
Files changed (36) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +40 -220
  3. package/dist/data-display/DataTable/DataTable.svelte +14 -8
  4. package/dist/data-display/DataTable/DataTable.svelte.d.ts +64 -61
  5. package/dist/data-display/DataTable/DataTable.svelte.d.ts.map +1 -1
  6. package/dist/forms/DatePicker/DatePicker.svelte +1564 -0
  7. package/dist/forms/DatePicker/DatePicker.svelte.d.ts +129 -0
  8. package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
  9. package/dist/forms/DatePicker/index.d.ts +8 -0
  10. package/dist/forms/DatePicker/index.d.ts.map +1 -0
  11. package/dist/forms/DatePicker/index.js +7 -0
  12. package/dist/forms/MultiSelect/MultiSelect.svelte +1651 -0
  13. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +127 -0
  14. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
  15. package/dist/forms/MultiSelect/index.d.ts +9 -0
  16. package/dist/forms/MultiSelect/index.d.ts.map +1 -0
  17. package/dist/forms/MultiSelect/index.js +7 -0
  18. package/dist/forms/SearchInput/SearchInput.svelte +1319 -0
  19. package/dist/forms/SearchInput/SearchInput.svelte.d.ts +148 -0
  20. package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
  21. package/dist/forms/SearchInput/index.d.ts +9 -0
  22. package/dist/forms/SearchInput/index.d.ts.map +1 -0
  23. package/dist/forms/SearchInput/index.js +7 -0
  24. package/dist/forms/TextInput/TextInput.svelte +763 -122
  25. package/dist/forms/TextInput/TextInput.svelte.d.ts +98 -13
  26. package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
  27. package/dist/forms/TextInput/index.d.ts +7 -1
  28. package/dist/forms/TextInput/index.d.ts.map +1 -1
  29. package/dist/forms/TextInput/index.js +7 -1
  30. package/dist/forms/index.d.ts +3 -0
  31. package/dist/forms/index.d.ts.map +1 -1
  32. package/dist/forms/index.js +3 -0
  33. package/dist/index.d.ts +5 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/package.json +3 -3
@@ -0,0 +1,1564 @@
1
+ <!--
2
+ @component DatePicker
3
+
4
+ A fully accessible, healthcare-grade date picker component with:
5
+ - Calendar popup with full keyboard navigation (WAI-ARIA dialog pattern)
6
+ - Direct text input with automatic formatting
7
+ - Age calculation display (critical for healthcare DOB fields)
8
+ - Min/max date constraints
9
+ - Past/future date restrictions
10
+ - Disabled dates support
11
+ - Focus trap and restoration
12
+ - Screen reader announcements for month/year navigation
13
+
14
+ Follows W3C APG Date Picker Dialog pattern:
15
+ https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/
16
+
17
+ @example Basic usage
18
+ <DatePicker
19
+ label="Date of Birth"
20
+ bind:value={dateOfBirth}
21
+ showAge
22
+ />
23
+
24
+ @example Appointment scheduling (no past dates)
25
+ <DatePicker
26
+ label="Appointment Date"
27
+ bind:value={appointmentDate}
28
+ disablePast
29
+ minDate={new Date()}
30
+ />
31
+
32
+ @example Coverage period (date range)
33
+ <DatePicker
34
+ label="Coverage Start"
35
+ bind:value={coverageStart}
36
+ minDate={new Date('2020-01-01')}
37
+ maxDate={new Date('2030-12-31')}
38
+ />
39
+ -->
40
+ <script lang="ts" module>
41
+ /**
42
+ * Date format options for the DatePicker.
43
+ */
44
+ export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD';
45
+
46
+ /**
47
+ * Parses a date string into a Date object based on format.
48
+ */
49
+ export function parseDate(value: string, format: DateFormat): Date | null {
50
+ if (!value) return null;
51
+
52
+ const digits = value.replace(/\D/g, '');
53
+ if (digits.length < 8) return null;
54
+
55
+ let year: number, month: number, day: number;
56
+
57
+ if (format === 'YYYY-MM-DD') {
58
+ year = parseInt(digits.slice(0, 4));
59
+ month = parseInt(digits.slice(4, 6)) - 1;
60
+ day = parseInt(digits.slice(6, 8));
61
+ } else if (format === 'DD/MM/YYYY') {
62
+ day = parseInt(digits.slice(0, 2));
63
+ month = parseInt(digits.slice(2, 4)) - 1;
64
+ year = parseInt(digits.slice(4, 8));
65
+ } else {
66
+ // MM/DD/YYYY (default)
67
+ month = parseInt(digits.slice(0, 2)) - 1;
68
+ day = parseInt(digits.slice(2, 4));
69
+ year = parseInt(digits.slice(4, 8));
70
+ }
71
+
72
+ // Validate the date
73
+ const date = new Date(year, month, day);
74
+ if (
75
+ date.getFullYear() !== year ||
76
+ date.getMonth() !== month ||
77
+ date.getDate() !== day
78
+ ) {
79
+ return null;
80
+ }
81
+
82
+ return date;
83
+ }
84
+
85
+ /**
86
+ * Formats a Date object into a string based on format.
87
+ */
88
+ export function formatDate(date: Date | null, format: DateFormat): string {
89
+ if (!date) return '';
90
+
91
+ const year = date.getFullYear();
92
+ const month = String(date.getMonth() + 1).padStart(2, '0');
93
+ const day = String(date.getDate()).padStart(2, '0');
94
+
95
+ switch (format) {
96
+ case 'YYYY-MM-DD':
97
+ return `${year}-${month}-${day}`;
98
+ case 'DD/MM/YYYY':
99
+ return `${day}/${month}/${year}`;
100
+ default:
101
+ return `${month}/${day}/${year}`;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Calculates age from a date of birth.
107
+ */
108
+ export function calculateAge(dob: Date): { years: number; months: number; days: number } {
109
+ const today = new Date();
110
+ let years = today.getFullYear() - dob.getFullYear();
111
+ let months = today.getMonth() - dob.getMonth();
112
+ let days = today.getDate() - dob.getDate();
113
+
114
+ if (days < 0) {
115
+ months--;
116
+ const prevMonth = new Date(today.getFullYear(), today.getMonth(), 0);
117
+ days += prevMonth.getDate();
118
+ }
119
+
120
+ if (months < 0) {
121
+ years--;
122
+ months += 12;
123
+ }
124
+
125
+ return { years, months, days };
126
+ }
127
+
128
+ /**
129
+ * Formats age for display.
130
+ */
131
+ export function formatAge(dob: Date): string {
132
+ const age = calculateAge(dob);
133
+ if (age.years < 0) return '';
134
+ if (age.years === 0 && age.months === 0) {
135
+ return `${age.days} day${age.days !== 1 ? 's' : ''} old`;
136
+ }
137
+ if (age.years === 0) {
138
+ return `${age.months} month${age.months !== 1 ? 's' : ''} old`;
139
+ }
140
+ return `${age.years} year${age.years !== 1 ? 's' : ''} old`;
141
+ }
142
+ </script>
143
+
144
+ <script lang="ts">
145
+ import type { Snippet } from 'svelte';
146
+ import { Calendar, ChevronLeft, ChevronRight, AlertCircle, Check, X } from 'lucide-svelte';
147
+ import { cn } from '../../utils/cn.js';
148
+ import { generateId, Keys, createFocusTrap, type FocusTrap } from '../../utils/keyboard.js';
149
+ import { announce, prefersReducedMotion } from '../../utils/accessibility.js';
150
+
151
+ type InputSize = 'sm' | 'md' | 'lg';
152
+
153
+ interface Props {
154
+ /** Unique identifier for the input */
155
+ id?: string;
156
+ /** Form field name */
157
+ name?: string;
158
+ /** Label text for the input */
159
+ label: string;
160
+ /** Current value as ISO date string (YYYY-MM-DD) or empty string */
161
+ value?: string;
162
+ /** Display format for the date */
163
+ format?: DateFormat;
164
+ /** Placeholder text */
165
+ placeholder?: string;
166
+ /** Whether the field is required */
167
+ required?: boolean;
168
+ /** Whether the field is disabled */
169
+ disabled?: boolean;
170
+ /** Whether the field is read-only */
171
+ readonly?: boolean;
172
+ /** Error message to display */
173
+ error?: string;
174
+ /** Hint text to display below the input */
175
+ hint?: string;
176
+ /** Success message when valid */
177
+ successMessage?: string;
178
+ /** Size of the input */
179
+ size?: InputSize;
180
+ /** Icon to display on the left */
181
+ iconLeft?: Snippet;
182
+ /** Whether to hide the label visually (still accessible) */
183
+ hideLabel?: boolean;
184
+ /** Additional CSS classes for the wrapper */
185
+ class?: string;
186
+ /** Show age calculation (for DOB fields) */
187
+ showAge?: boolean;
188
+ /** Minimum selectable date */
189
+ minDate?: Date;
190
+ /** Maximum selectable date */
191
+ maxDate?: Date;
192
+ /** Disable dates in the past */
193
+ disablePast?: boolean;
194
+ /** Disable dates in the future */
195
+ disableFuture?: boolean;
196
+ /** Function to check if a specific date should be disabled */
197
+ isDateDisabled?: (date: Date) => boolean;
198
+ /** Show clear button */
199
+ clearable?: boolean;
200
+ /** Show validation state immediately without waiting for interaction */
201
+ validateOnMount?: boolean;
202
+ /** Called when value changes */
203
+ onchange?: (value: string, date: Date | null) => void;
204
+ /** Called when calendar is opened */
205
+ onopen?: () => void;
206
+ /** Called when calendar is closed */
207
+ onclose?: () => void;
208
+ /** Called when clear button is clicked */
209
+ onclear?: () => void;
210
+ /** Test ID for e2e testing */
211
+ testId?: string;
212
+ }
213
+
214
+ let {
215
+ id = generateId('datepicker'),
216
+ name,
217
+ label,
218
+ value = $bindable(''),
219
+ format = 'MM/DD/YYYY',
220
+ placeholder,
221
+ required = false,
222
+ disabled = false,
223
+ readonly = false,
224
+ error = '',
225
+ hint = '',
226
+ successMessage = '',
227
+ size = 'md',
228
+ iconLeft,
229
+ hideLabel = false,
230
+ class: className = '',
231
+ showAge = false,
232
+ minDate,
233
+ maxDate,
234
+ disablePast = false,
235
+ disableFuture = false,
236
+ isDateDisabled,
237
+ clearable = false,
238
+ validateOnMount = false,
239
+ onchange,
240
+ onopen,
241
+ onclose,
242
+ onclear,
243
+ testId
244
+ }: Props = $props();
245
+
246
+ // ============================================================================
247
+ // State
248
+ // ============================================================================
249
+
250
+ let inputRef: HTMLInputElement | undefined = $state();
251
+ let calendarRef: HTMLDivElement | undefined = $state();
252
+ let triggerButtonRef: HTMLButtonElement | undefined = $state();
253
+ let isOpen = $state(false);
254
+ let isFocused = $state(false);
255
+ let hasInteracted = $state(false);
256
+ let inputValue = $state('');
257
+ let focusTrap: FocusTrap | null = null;
258
+
259
+ // Calendar state
260
+ let viewingMonth = $state(new Date().getMonth());
261
+ let viewingYear = $state(new Date().getFullYear());
262
+ let focusedDay = $state<number | null>(null);
263
+
264
+ // ============================================================================
265
+ // Derived State
266
+ // ============================================================================
267
+
268
+ const shouldValidate = $derived(hasInteracted || validateOnMount);
269
+ const showError = $derived(shouldValidate && !!error);
270
+ const showSuccess = $derived(shouldValidate && !error && !!value && !!successMessage);
271
+ const isValid = $derived(shouldValidate && !error && !!value && !!successMessage);
272
+
273
+ // Parse the current value to a Date
274
+ const selectedDate = $derived.by(() => {
275
+ if (!value) return null;
276
+ // Value is always stored as ISO format internally
277
+ const [year, month, day] = value.split('-').map(Number);
278
+ if (!year || !month || !day) return null;
279
+ const date = new Date(year, month - 1, day);
280
+ if (isNaN(date.getTime())) return null;
281
+ return date;
282
+ });
283
+
284
+ // Age display
285
+ const ageDisplay = $derived.by(() => {
286
+ if (!showAge || !selectedDate) return '';
287
+ return formatAge(selectedDate);
288
+ });
289
+
290
+ // Calendar grid
291
+ const calendarDays = $derived.by(() => {
292
+ const firstDay = new Date(viewingYear, viewingMonth, 1);
293
+ const lastDay = new Date(viewingYear, viewingMonth + 1, 0);
294
+ const daysInMonth = lastDay.getDate();
295
+ const startingDayOfWeek = firstDay.getDay();
296
+
297
+ const days: Array<{ day: number; date: Date; isCurrentMonth: boolean; isDisabled: boolean; isSelected: boolean; isToday: boolean }> = [];
298
+
299
+ // Previous month days
300
+ const prevMonthLastDay = new Date(viewingYear, viewingMonth, 0).getDate();
301
+ for (let i = startingDayOfWeek - 1; i >= 0; i--) {
302
+ const day = prevMonthLastDay - i;
303
+ const date = new Date(viewingYear, viewingMonth - 1, day);
304
+ days.push({
305
+ day,
306
+ date,
307
+ isCurrentMonth: false,
308
+ isDisabled: true,
309
+ isSelected: false,
310
+ isToday: false
311
+ });
312
+ }
313
+
314
+ // Current month days
315
+ const today = new Date();
316
+ today.setHours(0, 0, 0, 0);
317
+ for (let day = 1; day <= daysInMonth; day++) {
318
+ const date = new Date(viewingYear, viewingMonth, day);
319
+ const isDisabledDate = checkDateDisabled(date);
320
+ const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
321
+ const isToday = isSameDay(date, today);
322
+
323
+ days.push({
324
+ day,
325
+ date,
326
+ isCurrentMonth: true,
327
+ isDisabled: isDisabledDate,
328
+ isSelected,
329
+ isToday
330
+ });
331
+ }
332
+
333
+ // Next month days (fill to 42 cells for 6 rows)
334
+ const remaining = 42 - days.length;
335
+ for (let day = 1; day <= remaining; day++) {
336
+ const date = new Date(viewingYear, viewingMonth + 1, day);
337
+ days.push({
338
+ day,
339
+ date,
340
+ isCurrentMonth: false,
341
+ isDisabled: true,
342
+ isSelected: false,
343
+ isToday: false
344
+ });
345
+ }
346
+
347
+ return days;
348
+ });
349
+
350
+ // Month/year heading for aria-labelledby
351
+ const monthYearLabel = $derived(
352
+ new Date(viewingYear, viewingMonth).toLocaleDateString('en-US', {
353
+ month: 'long',
354
+ year: 'numeric'
355
+ })
356
+ );
357
+
358
+ // IDs for accessibility
359
+ const errorId = $derived(`${id}-error`);
360
+ const hintId = $derived(`${id}-hint`);
361
+ const calendarId = $derived(`${id}-calendar`);
362
+ const monthYearId = $derived(`${id}-month-year`);
363
+
364
+ // Build aria-describedby
365
+ const ariaDescribedBy = $derived(
366
+ [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
367
+ );
368
+
369
+ // Placeholder based on format
370
+ const effectivePlaceholder = $derived(placeholder ?? format);
371
+
372
+ // Mask pattern based on format
373
+ const maskPattern = $derived.by(() => {
374
+ switch (format) {
375
+ case 'YYYY-MM-DD':
376
+ return '####-##-##';
377
+ case 'DD/MM/YYYY':
378
+ return '##/##/####';
379
+ default:
380
+ return '##/##/####';
381
+ }
382
+ });
383
+
384
+ // ============================================================================
385
+ // Helper Functions
386
+ // ============================================================================
387
+
388
+ function isSameDay(a: Date, b: Date): boolean {
389
+ return (
390
+ a.getFullYear() === b.getFullYear() &&
391
+ a.getMonth() === b.getMonth() &&
392
+ a.getDate() === b.getDate()
393
+ );
394
+ }
395
+
396
+ function checkDateDisabled(date: Date): boolean {
397
+ const today = new Date();
398
+ today.setHours(0, 0, 0, 0);
399
+ date.setHours(0, 0, 0, 0);
400
+
401
+ if (disablePast && date < today) return true;
402
+ if (disableFuture && date > today) return true;
403
+ if (minDate) {
404
+ const min = new Date(minDate);
405
+ min.setHours(0, 0, 0, 0);
406
+ if (date < min) return true;
407
+ }
408
+ if (maxDate) {
409
+ const max = new Date(maxDate);
410
+ max.setHours(0, 0, 0, 0);
411
+ if (date > max) return true;
412
+ }
413
+ if (isDateDisabled && isDateDisabled(date)) return true;
414
+
415
+ return false;
416
+ }
417
+
418
+ function toISOValue(date: Date): string {
419
+ const year = date.getFullYear();
420
+ const month = String(date.getMonth() + 1).padStart(2, '0');
421
+ const day = String(date.getDate()).padStart(2, '0');
422
+ return `${year}-${month}-${day}`;
423
+ }
424
+
425
+ function applyMask(input: string): string {
426
+ const digits = input.replace(/\D/g, '');
427
+ let result = '';
428
+ let digitIndex = 0;
429
+
430
+ for (let i = 0; i < maskPattern.length && digitIndex < digits.length; i++) {
431
+ if (maskPattern[i] === '#') {
432
+ result += digits[digitIndex];
433
+ digitIndex++;
434
+ } else {
435
+ result += maskPattern[i];
436
+ }
437
+ }
438
+
439
+ return result;
440
+ }
441
+
442
+ // ============================================================================
443
+ // Event Handlers
444
+ // ============================================================================
445
+
446
+ function handleInputChange(e: Event) {
447
+ const target = e.target as HTMLInputElement;
448
+ const rawValue = target.value;
449
+ const masked = applyMask(rawValue);
450
+ inputValue = masked;
451
+ target.value = masked;
452
+
453
+ // Try to parse the date
454
+ const parsed = parseDate(masked, format);
455
+ if (parsed && !checkDateDisabled(parsed)) {
456
+ const isoValue = toISOValue(parsed);
457
+ value = isoValue;
458
+ viewingMonth = parsed.getMonth();
459
+ viewingYear = parsed.getFullYear();
460
+ onchange?.(isoValue, parsed);
461
+ } else if (masked.replace(/\D/g, '').length === 8) {
462
+ // Full date entered but invalid
463
+ value = '';
464
+ }
465
+ }
466
+
467
+ function handleInputFocus() {
468
+ isFocused = true;
469
+ }
470
+
471
+ function handleInputBlur() {
472
+ isFocused = false;
473
+ hasInteracted = true;
474
+
475
+ // Sync display value with actual value
476
+ if (selectedDate) {
477
+ inputValue = formatDate(selectedDate, format);
478
+ } else {
479
+ inputValue = '';
480
+ }
481
+ }
482
+
483
+ function handleInputKeyDown(e: KeyboardEvent) {
484
+ if (e.key === Keys.ArrowDown && e.altKey) {
485
+ e.preventDefault();
486
+ openCalendar();
487
+ } else if (e.key === Keys.Escape && inputValue) {
488
+ e.preventDefault();
489
+ handleClear();
490
+ }
491
+ }
492
+
493
+ function openCalendar() {
494
+ if (disabled || readonly) return;
495
+
496
+ isOpen = true;
497
+
498
+ // Set viewing month/year to selected date or today
499
+ if (selectedDate) {
500
+ viewingMonth = selectedDate.getMonth();
501
+ viewingYear = selectedDate.getFullYear();
502
+ focusedDay = selectedDate.getDate();
503
+ } else {
504
+ const today = new Date();
505
+ viewingMonth = today.getMonth();
506
+ viewingYear = today.getFullYear();
507
+ focusedDay = today.getDate();
508
+ }
509
+
510
+ onopen?.();
511
+
512
+ // Focus trap and initial focus
513
+ requestAnimationFrame(() => {
514
+ if (calendarRef) {
515
+ focusTrap = createFocusTrap(calendarRef);
516
+ focusTrap.activate();
517
+
518
+ // Focus the selected day or today
519
+ const focusTarget = calendarRef.querySelector('[data-focused="true"]') as HTMLButtonElement;
520
+ if (focusTarget) {
521
+ focusTarget.focus();
522
+ }
523
+ }
524
+ });
525
+
526
+ announce(`Calendar opened. ${monthYearLabel}. Use arrow keys to navigate.`, 'polite');
527
+ }
528
+
529
+ function closeCalendar(restoreFocus = true) {
530
+ isOpen = false;
531
+ focusTrap?.deactivate();
532
+ focusTrap = null;
533
+ onclose?.();
534
+
535
+ if (restoreFocus) {
536
+ requestAnimationFrame(() => {
537
+ triggerButtonRef?.focus();
538
+ });
539
+ }
540
+ }
541
+
542
+ function selectDate(date: Date) {
543
+ if (checkDateDisabled(date)) return;
544
+
545
+ const isoValue = toISOValue(date);
546
+ value = isoValue;
547
+ inputValue = formatDate(date, format);
548
+ hasInteracted = true;
549
+ onchange?.(isoValue, date);
550
+
551
+ closeCalendar();
552
+
553
+ announce(`Selected ${formatDate(date, format)}`, 'polite');
554
+ }
555
+
556
+ function handleClear() {
557
+ value = '';
558
+ inputValue = '';
559
+ hasInteracted = true;
560
+ onclear?.();
561
+ onchange?.('', null);
562
+ inputRef?.focus();
563
+ announce('Date cleared', 'polite');
564
+ }
565
+
566
+ function navigateMonth(delta: number) {
567
+ let newMonth = viewingMonth + delta;
568
+ let newYear = viewingYear;
569
+
570
+ if (newMonth < 0) {
571
+ newMonth = 11;
572
+ newYear--;
573
+ } else if (newMonth > 11) {
574
+ newMonth = 0;
575
+ newYear++;
576
+ }
577
+
578
+ viewingMonth = newMonth;
579
+ viewingYear = newYear;
580
+
581
+ // Announce the new month
582
+ const newLabel = new Date(newYear, newMonth).toLocaleDateString('en-US', {
583
+ month: 'long',
584
+ year: 'numeric'
585
+ });
586
+ announce(newLabel, 'polite');
587
+ }
588
+
589
+ function navigateYear(delta: number) {
590
+ viewingYear += delta;
591
+
592
+ const newLabel = new Date(viewingYear, viewingMonth).toLocaleDateString('en-US', {
593
+ month: 'long',
594
+ year: 'numeric'
595
+ });
596
+ announce(newLabel, 'polite');
597
+ }
598
+
599
+ function handleCalendarKeyDown(e: KeyboardEvent) {
600
+ const currentDays = calendarDays.filter(d => d.isCurrentMonth && !d.isDisabled);
601
+ const currentIndex = currentDays.findIndex(d => d.day === focusedDay);
602
+
603
+ switch (e.key) {
604
+ case Keys.Escape:
605
+ e.preventDefault();
606
+ closeCalendar();
607
+ break;
608
+
609
+ case Keys.Enter:
610
+ case Keys.Space:
611
+ e.preventDefault();
612
+ if (focusedDay) {
613
+ const dayInfo = calendarDays.find(d => d.isCurrentMonth && d.day === focusedDay);
614
+ if (dayInfo && !dayInfo.isDisabled) {
615
+ selectDate(dayInfo.date);
616
+ }
617
+ }
618
+ break;
619
+
620
+ case Keys.ArrowLeft:
621
+ e.preventDefault();
622
+ moveFocus(-1);
623
+ break;
624
+
625
+ case Keys.ArrowRight:
626
+ e.preventDefault();
627
+ moveFocus(1);
628
+ break;
629
+
630
+ case Keys.ArrowUp:
631
+ e.preventDefault();
632
+ moveFocus(-7);
633
+ break;
634
+
635
+ case Keys.ArrowDown:
636
+ e.preventDefault();
637
+ moveFocus(7);
638
+ break;
639
+
640
+ case Keys.Home:
641
+ e.preventDefault();
642
+ focusedDay = 1;
643
+ focusDayButton();
644
+ break;
645
+
646
+ case Keys.End:
647
+ e.preventDefault();
648
+ const lastDay = new Date(viewingYear, viewingMonth + 1, 0).getDate();
649
+ focusedDay = lastDay;
650
+ focusDayButton();
651
+ break;
652
+
653
+ case Keys.PageUp:
654
+ e.preventDefault();
655
+ if (e.shiftKey) {
656
+ navigateYear(-1);
657
+ } else {
658
+ navigateMonth(-1);
659
+ }
660
+ // Keep focus on same day or last day of month
661
+ requestAnimationFrame(() => {
662
+ const lastDay = new Date(viewingYear, viewingMonth + 1, 0).getDate();
663
+ if (focusedDay && focusedDay > lastDay) {
664
+ focusedDay = lastDay;
665
+ }
666
+ focusDayButton();
667
+ });
668
+ break;
669
+
670
+ case Keys.PageDown:
671
+ e.preventDefault();
672
+ if (e.shiftKey) {
673
+ navigateYear(1);
674
+ } else {
675
+ navigateMonth(1);
676
+ }
677
+ // Keep focus on same day or last day of month
678
+ requestAnimationFrame(() => {
679
+ const lastDay = new Date(viewingYear, viewingMonth + 1, 0).getDate();
680
+ if (focusedDay && focusedDay > lastDay) {
681
+ focusedDay = lastDay;
682
+ }
683
+ focusDayButton();
684
+ });
685
+ break;
686
+
687
+ case 'Tab':
688
+ // Allow Tab to cycle within the dialog
689
+ break;
690
+ }
691
+ }
692
+
693
+ function moveFocus(delta: number) {
694
+ if (!focusedDay) {
695
+ focusedDay = 1;
696
+ focusDayButton();
697
+ return;
698
+ }
699
+
700
+ const daysInMonth = new Date(viewingYear, viewingMonth + 1, 0).getDate();
701
+ let newDay = focusedDay + delta;
702
+
703
+ if (newDay < 1) {
704
+ // Go to previous month
705
+ navigateMonth(-1);
706
+ requestAnimationFrame(() => {
707
+ const prevMonthDays = new Date(viewingYear, viewingMonth + 1, 0).getDate();
708
+ focusedDay = prevMonthDays + newDay;
709
+ focusDayButton();
710
+ });
711
+ } else if (newDay > daysInMonth) {
712
+ // Go to next month
713
+ navigateMonth(1);
714
+ requestAnimationFrame(() => {
715
+ focusedDay = newDay - daysInMonth;
716
+ focusDayButton();
717
+ });
718
+ } else {
719
+ focusedDay = newDay;
720
+ focusDayButton();
721
+ }
722
+ }
723
+
724
+ function focusDayButton() {
725
+ requestAnimationFrame(() => {
726
+ if (calendarRef) {
727
+ const button = calendarRef.querySelector(`[data-day="${focusedDay}"]`) as HTMLButtonElement;
728
+ if (button) {
729
+ button.focus();
730
+ }
731
+ }
732
+ });
733
+ }
734
+
735
+ function handleBackdropClick() {
736
+ closeCalendar();
737
+ }
738
+
739
+ // ============================================================================
740
+ // Effects
741
+ // ============================================================================
742
+
743
+ // Sync inputValue with value
744
+ $effect(() => {
745
+ if (selectedDate) {
746
+ inputValue = formatDate(selectedDate, format);
747
+ } else {
748
+ inputValue = '';
749
+ }
750
+ });
751
+
752
+ // Handle click outside
753
+ $effect(() => {
754
+ if (!isOpen) return;
755
+
756
+ function handleClickOutside(e: MouseEvent) {
757
+ const target = e.target as Node;
758
+ if (
759
+ calendarRef &&
760
+ !calendarRef.contains(target) &&
761
+ triggerButtonRef &&
762
+ !triggerButtonRef.contains(target) &&
763
+ inputRef &&
764
+ !inputRef.contains(target)
765
+ ) {
766
+ closeCalendar(false);
767
+ }
768
+ }
769
+
770
+ document.addEventListener('mousedown', handleClickOutside);
771
+ return () => document.removeEventListener('mousedown', handleClickOutside);
772
+ });
773
+
774
+ // Cleanup focus trap
775
+ $effect(() => {
776
+ return () => {
777
+ focusTrap?.deactivate();
778
+ };
779
+ });
780
+ </script>
781
+
782
+ <div class={cn('datepicker-wrapper', `datepicker-${size}`, disabled && 'datepicker-disabled', isOpen && 'datepicker-open', className)}>
783
+ <!-- Label -->
784
+ <label for={id} class={cn('datepicker-label', hideLabel && 'sr-only')}>
785
+ {label}
786
+ {#if required}
787
+ <span class="datepicker-required" aria-hidden="true">*</span>
788
+ {/if}
789
+ </label>
790
+
791
+ <!-- Input wrapper -->
792
+ <div
793
+ class={cn(
794
+ 'datepicker-field-wrapper',
795
+ isFocused && 'datepicker-field-wrapper-focused',
796
+ isOpen && 'datepicker-field-wrapper-open',
797
+ showError && 'datepicker-field-wrapper-error',
798
+ isValid && 'datepicker-field-wrapper-valid',
799
+ disabled && 'datepicker-field-wrapper-disabled'
800
+ )}
801
+ >
802
+ <!-- Left icon -->
803
+ {#if iconLeft}
804
+ <span class={cn('datepicker-icon-left', isFocused && 'datepicker-icon-focused')} aria-hidden="true">
805
+ {@render iconLeft()}
806
+ </span>
807
+ {/if}
808
+
809
+ <input
810
+ bind:this={inputRef}
811
+ {id}
812
+ {name}
813
+ type="text"
814
+ inputmode="numeric"
815
+ value={inputValue}
816
+ placeholder={effectivePlaceholder}
817
+ {disabled}
818
+ readonly={readonly}
819
+ {required}
820
+ aria-invalid={showError}
821
+ aria-describedby={ariaDescribedBy}
822
+ aria-required={required}
823
+ aria-haspopup="dialog"
824
+ data-testid={testId}
825
+ class={cn(
826
+ 'datepicker-field',
827
+ iconLeft && 'datepicker-has-icon-left'
828
+ )}
829
+ oninput={handleInputChange}
830
+ onfocus={handleInputFocus}
831
+ onblur={handleInputBlur}
832
+ onkeydown={handleInputKeyDown}
833
+ />
834
+
835
+ <!-- Right side content -->
836
+ <div class="datepicker-right-content">
837
+ <!-- Status icons -->
838
+ {#if showError}
839
+ <span class="datepicker-icon-right datepicker-icon-error" aria-hidden="true">
840
+ <AlertCircle size={size === 'sm' ? 14 : 16} />
841
+ </span>
842
+ {:else if isValid}
843
+ <span class="datepicker-icon-right datepicker-icon-success" aria-hidden="true">
844
+ <Check size={size === 'sm' ? 14 : 16} />
845
+ </span>
846
+ {/if}
847
+
848
+ <!-- Clear button -->
849
+ {#if clearable && value && !disabled && !readonly}
850
+ <button
851
+ type="button"
852
+ class="datepicker-clear-button"
853
+ onclick={handleClear}
854
+ aria-label="Clear date"
855
+ tabindex={-1}
856
+ >
857
+ <X size={size === 'sm' ? 14 : 16} />
858
+ </button>
859
+ {/if}
860
+
861
+ <!-- Calendar trigger -->
862
+ <button
863
+ bind:this={triggerButtonRef}
864
+ type="button"
865
+ class={cn('datepicker-trigger', isOpen && 'datepicker-trigger-active')}
866
+ onclick={() => isOpen ? closeCalendar() : openCalendar()}
867
+ aria-label={value ? `Change date, ${inputValue}` : 'Choose date'}
868
+ aria-haspopup="dialog"
869
+ aria-expanded={isOpen}
870
+ {disabled}
871
+ >
872
+ <Calendar size={size === 'sm' ? 16 : 18} />
873
+ </button>
874
+ </div>
875
+ </div>
876
+
877
+ <!-- Footer messages -->
878
+ <div class="datepicker-footer">
879
+ <div class="datepicker-messages">
880
+ {#if showError}
881
+ <p id={errorId} class="datepicker-message datepicker-message-error" role="alert" aria-live="assertive">
882
+ {error}
883
+ </p>
884
+ {:else if showSuccess}
885
+ <p class="datepicker-message datepicker-message-success">
886
+ {successMessage}
887
+ </p>
888
+ {:else if showAge && ageDisplay}
889
+ <p class="datepicker-message datepicker-message-age">
890
+ {ageDisplay}
891
+ </p>
892
+ {:else if hint && isFocused}
893
+ <p id={hintId} class="datepicker-message datepicker-message-hint">
894
+ {hint}
895
+ </p>
896
+ {/if}
897
+ </div>
898
+ </div>
899
+
900
+ <!-- Calendar popup -->
901
+ {#if isOpen}
902
+ <!-- Backdrop for closing on outside click -->
903
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
904
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
905
+ <div class="datepicker-backdrop" onclick={handleBackdropClick}></div>
906
+
907
+ <!-- Calendar dialog -->
908
+ <div
909
+ bind:this={calendarRef}
910
+ id={calendarId}
911
+ class="datepicker-calendar"
912
+ role="dialog"
913
+ aria-modal="true"
914
+ aria-label={`Choose date, ${monthYearLabel}`}
915
+ tabindex="-1"
916
+ onkeydown={handleCalendarKeyDown}
917
+ >
918
+ <!-- Calendar header -->
919
+ <div class="datepicker-calendar-header">
920
+ <button
921
+ type="button"
922
+ class="datepicker-nav-button"
923
+ onclick={() => navigateMonth(-1)}
924
+ aria-label="Previous month"
925
+ >
926
+ <ChevronLeft size={20} />
927
+ </button>
928
+
929
+ <h2 id={monthYearId} class="datepicker-month-year" aria-live="polite">
930
+ {monthYearLabel}
931
+ </h2>
932
+
933
+ <button
934
+ type="button"
935
+ class="datepicker-nav-button"
936
+ onclick={() => navigateMonth(1)}
937
+ aria-label="Next month"
938
+ >
939
+ <ChevronRight size={20} />
940
+ </button>
941
+ </div>
942
+
943
+ <!-- Weekday headers -->
944
+ <div class="datepicker-weekdays" role="row">
945
+ {#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day, i}
946
+ <div class="datepicker-weekday" role="columnheader" aria-label={['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][i]}>
947
+ <abbr title={['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][i]}>{day}</abbr>
948
+ </div>
949
+ {/each}
950
+ </div>
951
+
952
+ <!-- Calendar grid -->
953
+ <div class="datepicker-grid" role="grid" aria-labelledby={monthYearId}>
954
+ {#each Array(6) as _, weekIndex}
955
+ <div class="datepicker-row" role="row">
956
+ {#each calendarDays.slice(weekIndex * 7, (weekIndex + 1) * 7) as dayInfo}
957
+ <div role="gridcell">
958
+ <button
959
+ type="button"
960
+ class={cn(
961
+ 'datepicker-day',
962
+ !dayInfo.isCurrentMonth && 'datepicker-day-other',
963
+ dayInfo.isDisabled && 'datepicker-day-disabled',
964
+ dayInfo.isSelected && 'datepicker-day-selected',
965
+ dayInfo.isToday && 'datepicker-day-today',
966
+ focusedDay === dayInfo.day && dayInfo.isCurrentMonth && 'datepicker-day-focused'
967
+ )}
968
+ disabled={dayInfo.isDisabled || !dayInfo.isCurrentMonth}
969
+ tabindex={dayInfo.isCurrentMonth && focusedDay === dayInfo.day ? 0 : -1}
970
+ data-day={dayInfo.isCurrentMonth ? dayInfo.day : undefined}
971
+ data-focused={dayInfo.isCurrentMonth && focusedDay === dayInfo.day}
972
+ data-selected={dayInfo.isSelected}
973
+ aria-label={dayInfo.date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) + (dayInfo.isSelected ? ', selected' : '')}
974
+ aria-selected={dayInfo.isSelected}
975
+ aria-disabled={dayInfo.isDisabled || !dayInfo.isCurrentMonth}
976
+ onclick={() => dayInfo.isCurrentMonth && !dayInfo.isDisabled && selectDate(dayInfo.date)}
977
+ >
978
+ {dayInfo.day}
979
+ </button>
980
+ </div>
981
+ {/each}
982
+ </div>
983
+ {/each}
984
+ </div>
985
+
986
+ <!-- Keyboard shortcuts hint -->
987
+ <div class="datepicker-shortcuts" aria-hidden="true">
988
+ <kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd><kbd>↓</kbd> navigate
989
+ <span class="datepicker-shortcut-divider">|</span>
990
+ <kbd>Enter</kbd> select
991
+ <span class="datepicker-shortcut-divider">|</span>
992
+ <kbd>Esc</kbd> close
993
+ </div>
994
+ </div>
995
+ {/if}
996
+ </div>
997
+
998
+ <style>
999
+ /* ========================================
1000
+ BASE WRAPPER
1001
+ ======================================== */
1002
+ .datepicker-wrapper {
1003
+ display: flex;
1004
+ flex-direction: column;
1005
+ gap: 0.375rem;
1006
+ position: relative;
1007
+ }
1008
+
1009
+ .datepicker-wrapper.datepicker-open {
1010
+ z-index: 50;
1011
+ }
1012
+
1013
+ .datepicker-disabled {
1014
+ opacity: 0.6;
1015
+ }
1016
+
1017
+ /* ========================================
1018
+ LABEL
1019
+ ======================================== */
1020
+ .datepicker-label {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ gap: 0.375rem;
1024
+ font-weight: 500;
1025
+ color: var(--ui-text-primary);
1026
+ }
1027
+
1028
+ .datepicker-sm .datepicker-label {
1029
+ font-size: 0.75rem;
1030
+ }
1031
+
1032
+ .datepicker-md .datepicker-label,
1033
+ .datepicker-lg .datepicker-label {
1034
+ font-size: 0.875rem;
1035
+ }
1036
+
1037
+ .datepicker-required {
1038
+ color: rgb(var(--ui-color-error));
1039
+ }
1040
+
1041
+ :global([data-theme='dark']) .datepicker-required {
1042
+ color: rgb(var(--ui-color-error-light));
1043
+ }
1044
+
1045
+ @media (prefers-color-scheme: dark) {
1046
+ :global(:root:not([data-theme='light'])) .datepicker-required {
1047
+ color: rgb(var(--ui-color-error-light));
1048
+ }
1049
+ }
1050
+
1051
+ /* ========================================
1052
+ FIELD WRAPPER
1053
+ ======================================== */
1054
+ .datepicker-field-wrapper {
1055
+ position: relative;
1056
+ display: flex;
1057
+ align-items: center;
1058
+ border: 2px solid var(--ui-border-default);
1059
+ border-radius: 0.5rem;
1060
+ background: var(--ui-bg-secondary);
1061
+ transition: all 0.2s ease;
1062
+ }
1063
+
1064
+ .datepicker-field-wrapper-focused,
1065
+ .datepicker-field-wrapper-open {
1066
+ background: var(--ui-bg-primary);
1067
+ border-color: rgb(var(--ui-color-primary));
1068
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary) / 0.4);
1069
+ }
1070
+
1071
+ :global([data-theme='dark']) .datepicker-field-wrapper-focused,
1072
+ :global([data-theme='dark']) .datepicker-field-wrapper-open {
1073
+ border-color: rgb(var(--ui-color-primary-light));
1074
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
1075
+ }
1076
+
1077
+ @media (prefers-color-scheme: dark) {
1078
+ :global(:root:not([data-theme='light'])) .datepicker-field-wrapper-focused,
1079
+ :global(:root:not([data-theme='light'])) .datepicker-field-wrapper-open {
1080
+ border-color: rgb(var(--ui-color-primary-light));
1081
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
1082
+ }
1083
+ }
1084
+
1085
+ .datepicker-field-wrapper-error {
1086
+ border-color: rgb(var(--ui-color-error));
1087
+ }
1088
+
1089
+ .datepicker-field-wrapper-focused.datepicker-field-wrapper-error {
1090
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-error) / 0.4);
1091
+ }
1092
+
1093
+ :global([data-theme='dark']) .datepicker-field-wrapper-error {
1094
+ border-color: rgb(var(--ui-color-error-light));
1095
+ }
1096
+
1097
+ @media (prefers-color-scheme: dark) {
1098
+ :global(:root:not([data-theme='light'])) .datepicker-field-wrapper-error {
1099
+ border-color: rgb(var(--ui-color-error-light));
1100
+ }
1101
+ }
1102
+
1103
+ .datepicker-field-wrapper-valid {
1104
+ border-color: rgb(var(--ui-color-success));
1105
+ }
1106
+
1107
+ .datepicker-field-wrapper-focused.datepicker-field-wrapper-valid {
1108
+ box-shadow: 0 0 0 3px rgb(var(--ui-color-success) / 0.4);
1109
+ }
1110
+
1111
+ .datepicker-field-wrapper-disabled {
1112
+ opacity: 0.6;
1113
+ cursor: not-allowed;
1114
+ background: var(--ui-bg-tertiary);
1115
+ }
1116
+
1117
+ /* ========================================
1118
+ INPUT FIELD
1119
+ ======================================== */
1120
+ .datepicker-field {
1121
+ flex: 1;
1122
+ min-width: 0;
1123
+ border: none;
1124
+ border-radius: 0;
1125
+ background: transparent;
1126
+ color: var(--ui-text-primary);
1127
+ outline: none;
1128
+ font-family: var(--ui-font-mono, ui-monospace, monospace);
1129
+ letter-spacing: 0.025em;
1130
+ }
1131
+
1132
+ .datepicker-field::placeholder {
1133
+ color: var(--ui-text-tertiary);
1134
+ font-family: var(--ui-font-sans, system-ui, sans-serif);
1135
+ letter-spacing: normal;
1136
+ }
1137
+
1138
+ .datepicker-field:focus-visible {
1139
+ outline: none;
1140
+ }
1141
+
1142
+ .datepicker-field:disabled {
1143
+ cursor: not-allowed;
1144
+ }
1145
+
1146
+ .datepicker-sm .datepicker-field-wrapper {
1147
+ min-height: 2rem;
1148
+ }
1149
+
1150
+ .datepicker-md .datepicker-field-wrapper {
1151
+ min-height: 2.5rem;
1152
+ }
1153
+
1154
+ .datepicker-lg .datepicker-field-wrapper {
1155
+ min-height: 3rem;
1156
+ }
1157
+
1158
+ .datepicker-sm .datepicker-field {
1159
+ padding: 0 0.5rem;
1160
+ font-size: 0.875rem;
1161
+ }
1162
+
1163
+ .datepicker-md .datepicker-field {
1164
+ padding: 0 0.625rem;
1165
+ font-size: 0.875rem;
1166
+ }
1167
+
1168
+ .datepicker-lg .datepicker-field {
1169
+ padding: 0 0.75rem;
1170
+ font-size: 1rem;
1171
+ }
1172
+
1173
+ .datepicker-field.datepicker-has-icon-left {
1174
+ padding-left: 0.25rem !important;
1175
+ }
1176
+
1177
+ /* ========================================
1178
+ ICONS
1179
+ ======================================== */
1180
+ .datepicker-icon-left {
1181
+ display: flex;
1182
+ align-items: center;
1183
+ justify-content: center;
1184
+ flex-shrink: 0;
1185
+ padding-left: 0.75rem;
1186
+ pointer-events: none;
1187
+ color: var(--ui-text-tertiary);
1188
+ transition: color 0.2s ease;
1189
+ }
1190
+
1191
+ .datepicker-icon-focused {
1192
+ color: rgb(var(--ui-color-primary));
1193
+ }
1194
+
1195
+ :global([data-theme='dark']) .datepicker-icon-focused {
1196
+ color: rgb(var(--ui-color-primary-light));
1197
+ }
1198
+
1199
+ @media (prefers-color-scheme: dark) {
1200
+ :global(:root:not([data-theme='light'])) .datepicker-icon-focused {
1201
+ color: rgb(var(--ui-color-primary-light));
1202
+ }
1203
+ }
1204
+
1205
+ .datepicker-right-content {
1206
+ display: flex;
1207
+ align-items: center;
1208
+ gap: 0.25rem;
1209
+ flex-shrink: 0;
1210
+ padding-right: 0.375rem;
1211
+ }
1212
+
1213
+ .datepicker-icon-right {
1214
+ display: flex;
1215
+ align-items: center;
1216
+ justify-content: center;
1217
+ color: var(--ui-text-tertiary);
1218
+ pointer-events: none;
1219
+ }
1220
+
1221
+ .datepicker-icon-error {
1222
+ color: rgb(var(--ui-color-error));
1223
+ }
1224
+
1225
+ .datepicker-icon-success {
1226
+ color: rgb(var(--ui-color-success));
1227
+ }
1228
+
1229
+ /* ========================================
1230
+ BUTTONS
1231
+ ======================================== */
1232
+ .datepicker-clear-button,
1233
+ .datepicker-trigger {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ justify-content: center;
1237
+ width: 1.75rem;
1238
+ height: 1.75rem;
1239
+ padding: 0;
1240
+ border: none;
1241
+ border-radius: 0.375rem;
1242
+ background: transparent;
1243
+ color: var(--ui-text-tertiary);
1244
+ cursor: pointer;
1245
+ transition: all 0.15s ease;
1246
+ }
1247
+
1248
+ .datepicker-clear-button:hover,
1249
+ .datepicker-trigger:hover {
1250
+ background: var(--ui-bg-tertiary);
1251
+ color: var(--ui-text-primary);
1252
+ }
1253
+
1254
+ .datepicker-trigger-active {
1255
+ background: rgb(var(--ui-color-primary) / 0.1);
1256
+ color: rgb(var(--ui-color-primary));
1257
+ }
1258
+
1259
+ .datepicker-clear-button:focus-visible,
1260
+ .datepicker-trigger:focus-visible {
1261
+ outline: none;
1262
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary) / 0.5);
1263
+ }
1264
+
1265
+ .datepicker-clear-button:disabled,
1266
+ .datepicker-trigger:disabled {
1267
+ opacity: 0.5;
1268
+ cursor: not-allowed;
1269
+ }
1270
+
1271
+ /* ========================================
1272
+ FOOTER & MESSAGES
1273
+ ======================================== */
1274
+ .datepicker-footer {
1275
+ display: flex;
1276
+ justify-content: space-between;
1277
+ align-items: center;
1278
+ min-height: 1.25rem;
1279
+ }
1280
+
1281
+ .datepicker-messages {
1282
+ flex: 1;
1283
+ }
1284
+
1285
+ .datepicker-message {
1286
+ font-size: 0.75rem;
1287
+ animation: datepicker-slide-in 0.2s ease-out;
1288
+ }
1289
+
1290
+ @media (prefers-reduced-motion: reduce) {
1291
+ .datepicker-message {
1292
+ animation: none;
1293
+ }
1294
+ }
1295
+
1296
+ .datepicker-message-error {
1297
+ color: rgb(var(--ui-color-error));
1298
+ }
1299
+
1300
+ :global([data-theme='dark']) .datepicker-message-error {
1301
+ color: rgb(var(--ui-color-error-light));
1302
+ }
1303
+
1304
+ @media (prefers-color-scheme: dark) {
1305
+ :global(:root:not([data-theme='light'])) .datepicker-message-error {
1306
+ color: rgb(var(--ui-color-error-light));
1307
+ }
1308
+ }
1309
+
1310
+ .datepicker-message-success {
1311
+ color: rgb(var(--ui-color-success));
1312
+ }
1313
+
1314
+ .datepicker-message-age {
1315
+ color: rgb(var(--ui-color-info));
1316
+ font-weight: 500;
1317
+ }
1318
+
1319
+ .datepicker-message-hint {
1320
+ color: var(--ui-text-tertiary);
1321
+ }
1322
+
1323
+ /* ========================================
1324
+ CALENDAR POPUP
1325
+ ======================================== */
1326
+ .datepicker-backdrop {
1327
+ position: fixed;
1328
+ inset: 0;
1329
+ z-index: 40;
1330
+ background: transparent;
1331
+ }
1332
+
1333
+ .datepicker-calendar {
1334
+ position: absolute;
1335
+ top: 100%;
1336
+ left: 0;
1337
+ z-index: 50;
1338
+ margin-top: 0.5rem;
1339
+ min-width: 18rem;
1340
+ padding: 1rem;
1341
+ background: var(--ui-bg-primary);
1342
+ border: 1px solid var(--ui-border-default);
1343
+ border-radius: 0.75rem;
1344
+ box-shadow:
1345
+ 0 10px 15px -3px rgb(0 0 0 / 0.1),
1346
+ 0 4px 6px -4px rgb(0 0 0 / 0.1),
1347
+ 0 20px 25px -5px rgb(0 0 0 / 0.05);
1348
+ animation: datepicker-calendar-in 0.15s ease-out;
1349
+ }
1350
+
1351
+ @media (prefers-reduced-motion: reduce) {
1352
+ .datepicker-calendar {
1353
+ animation: none;
1354
+ }
1355
+ }
1356
+
1357
+ @keyframes datepicker-calendar-in {
1358
+ from {
1359
+ opacity: 0;
1360
+ transform: translateY(-0.5rem);
1361
+ }
1362
+ to {
1363
+ opacity: 1;
1364
+ transform: translateY(0);
1365
+ }
1366
+ }
1367
+
1368
+ /* Calendar header */
1369
+ .datepicker-calendar-header {
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: space-between;
1373
+ gap: 0.5rem;
1374
+ margin-bottom: 1rem;
1375
+ }
1376
+
1377
+ .datepicker-nav-button {
1378
+ display: flex;
1379
+ align-items: center;
1380
+ justify-content: center;
1381
+ width: 2rem;
1382
+ height: 2rem;
1383
+ padding: 0;
1384
+ border: none;
1385
+ border-radius: 0.375rem;
1386
+ background: transparent;
1387
+ color: var(--ui-text-secondary);
1388
+ cursor: pointer;
1389
+ transition: all 0.15s ease;
1390
+ }
1391
+
1392
+ .datepicker-nav-button:hover {
1393
+ background: var(--ui-bg-secondary);
1394
+ color: var(--ui-text-primary);
1395
+ }
1396
+
1397
+ .datepicker-nav-button:focus-visible {
1398
+ outline: none;
1399
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary) / 0.5);
1400
+ }
1401
+
1402
+ .datepicker-month-year {
1403
+ flex: 1;
1404
+ text-align: center;
1405
+ font-size: 0.9375rem;
1406
+ font-weight: 600;
1407
+ color: var(--ui-text-primary);
1408
+ margin: 0;
1409
+ }
1410
+
1411
+ /* Weekday headers */
1412
+ .datepicker-weekdays {
1413
+ display: grid;
1414
+ grid-template-columns: repeat(7, 1fr);
1415
+ gap: 0.125rem;
1416
+ margin-bottom: 0.5rem;
1417
+ }
1418
+
1419
+ .datepicker-weekday {
1420
+ display: flex;
1421
+ align-items: center;
1422
+ justify-content: center;
1423
+ height: 2rem;
1424
+ font-size: 0.75rem;
1425
+ font-weight: 600;
1426
+ color: var(--ui-text-tertiary);
1427
+ text-transform: uppercase;
1428
+ }
1429
+
1430
+ .datepicker-weekday abbr {
1431
+ text-decoration: none;
1432
+ }
1433
+
1434
+ /* Calendar grid */
1435
+ .datepicker-grid {
1436
+ display: flex;
1437
+ flex-direction: column;
1438
+ gap: 0.125rem;
1439
+ }
1440
+
1441
+ .datepicker-row {
1442
+ display: grid;
1443
+ grid-template-columns: repeat(7, 1fr);
1444
+ gap: 0.125rem;
1445
+ }
1446
+
1447
+ .datepicker-day {
1448
+ display: flex;
1449
+ align-items: center;
1450
+ justify-content: center;
1451
+ width: 2.25rem;
1452
+ height: 2.25rem;
1453
+ padding: 0;
1454
+ border: 2px solid transparent;
1455
+ border-radius: 0.5rem;
1456
+ background: transparent;
1457
+ font-size: 0.875rem;
1458
+ font-weight: 500;
1459
+ color: var(--ui-text-primary);
1460
+ cursor: pointer;
1461
+ transition: all 0.15s ease;
1462
+ }
1463
+
1464
+ .datepicker-day:hover:not(:disabled) {
1465
+ background: var(--ui-bg-secondary);
1466
+ }
1467
+
1468
+ .datepicker-day:focus-visible {
1469
+ outline: none;
1470
+ border-color: rgb(var(--ui-color-primary));
1471
+ box-shadow: 0 0 0 2px rgb(var(--ui-color-primary) / 0.3);
1472
+ }
1473
+
1474
+ .datepicker-day-other {
1475
+ color: var(--ui-text-tertiary);
1476
+ opacity: 0.5;
1477
+ }
1478
+
1479
+ .datepicker-day-disabled {
1480
+ color: var(--ui-text-tertiary);
1481
+ cursor: not-allowed;
1482
+ opacity: 0.4;
1483
+ }
1484
+
1485
+ .datepicker-day-today {
1486
+ border-color: rgb(var(--ui-color-primary) / 0.5);
1487
+ }
1488
+
1489
+ .datepicker-day-selected {
1490
+ background: linear-gradient(135deg, rgb(var(--ui-color-primary)), rgb(var(--ui-color-primary-light)));
1491
+ color: white;
1492
+ border-color: transparent;
1493
+ box-shadow:
1494
+ 0 1px 2px rgb(var(--ui-color-primary) / 0.2),
1495
+ 0 4px 12px rgb(var(--ui-color-primary) / 0.15);
1496
+ }
1497
+
1498
+ .datepicker-day-selected:hover {
1499
+ background: linear-gradient(135deg, rgb(var(--ui-color-primary)), rgb(var(--ui-color-primary-light)));
1500
+ }
1501
+
1502
+ .datepicker-day-focused:not(.datepicker-day-selected) {
1503
+ border-color: rgb(var(--ui-color-primary));
1504
+ }
1505
+
1506
+ /* Keyboard shortcuts hint */
1507
+ .datepicker-shortcuts {
1508
+ display: flex;
1509
+ align-items: center;
1510
+ justify-content: center;
1511
+ gap: 0.375rem;
1512
+ padding-top: 0.75rem;
1513
+ margin-top: 0.75rem;
1514
+ border-top: 1px solid var(--ui-border-default);
1515
+ font-size: 0.6875rem;
1516
+ color: var(--ui-text-tertiary);
1517
+ }
1518
+
1519
+ .datepicker-shortcuts kbd {
1520
+ display: inline-flex;
1521
+ align-items: center;
1522
+ justify-content: center;
1523
+ min-width: 1.25rem;
1524
+ height: 1.25rem;
1525
+ padding: 0 0.25rem;
1526
+ background: var(--ui-bg-secondary);
1527
+ border: 1px solid var(--ui-border-default);
1528
+ border-radius: 0.25rem;
1529
+ font-family: inherit;
1530
+ font-size: 0.625rem;
1531
+ font-weight: 500;
1532
+ }
1533
+
1534
+ .datepicker-shortcut-divider {
1535
+ margin: 0 0.25rem;
1536
+ color: var(--ui-border-default);
1537
+ }
1538
+
1539
+ /* ========================================
1540
+ UTILITIES
1541
+ ======================================== */
1542
+ .sr-only {
1543
+ position: absolute;
1544
+ width: 1px;
1545
+ height: 1px;
1546
+ padding: 0;
1547
+ margin: -1px;
1548
+ overflow: hidden;
1549
+ clip: rect(0, 0, 0, 0);
1550
+ white-space: nowrap;
1551
+ border: 0;
1552
+ }
1553
+
1554
+ @keyframes datepicker-slide-in {
1555
+ from {
1556
+ opacity: 0;
1557
+ transform: translateY(-4px);
1558
+ }
1559
+ to {
1560
+ opacity: 1;
1561
+ transform: translateY(0);
1562
+ }
1563
+ }
1564
+ </style>