@synthaxai/ui 1.0.0 → 1.2.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.
- package/LICENSE +9 -0
- package/README.md +40 -220
- package/dist/data-display/DataTable/DataTable.svelte +14 -8
- package/dist/data-display/DataTable/DataTable.svelte.d.ts +64 -61
- package/dist/data-display/DataTable/DataTable.svelte.d.ts.map +1 -1
- package/dist/forms/DatePicker/DatePicker.svelte +1564 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts +129 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.d.ts +8 -0
- package/dist/forms/DatePicker/index.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.js +7 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte +1651 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +127 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.d.ts +9 -0
- package/dist/forms/MultiSelect/index.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.js +7 -0
- package/dist/forms/SearchInput/SearchInput.svelte +1319 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts +148 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.d.ts +9 -0
- package/dist/forms/SearchInput/index.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.js +7 -0
- package/dist/forms/TextInput/TextInput.svelte +763 -122
- package/dist/forms/TextInput/TextInput.svelte.d.ts +98 -13
- package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
- package/dist/forms/TextInput/index.d.ts +7 -1
- package/dist/forms/TextInput/index.d.ts.map +1 -1
- package/dist/forms/TextInput/index.js +7 -1
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +3 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/package.json +7 -7
|
@@ -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>
|