@classic-homes/theme-svelte 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { Popover, Calendar } from 'bits-ui';
3
+ import { tick } from 'svelte';
3
4
  import { cn } from '../utils.js';
5
+ import { parseDate, toISOString } from '../utils/date.js';
6
+ import Label from './Label.svelte';
4
7
 
5
8
  interface Props {
6
9
  /** Current date/time value */
7
10
  value?: Date | null;
11
+ /** Current value as ISO string (alternative to value prop for form binding) */
12
+ stringValue?: string | null;
8
13
  /** Callback when value changes */
9
14
  onValueChange?: (date: Date | null) => void;
15
+ /** Callback when stringValue changes */
16
+ onStringValueChange?: (value: string | null) => void;
10
17
  /** Placeholder text */
11
18
  placeholder?: string;
12
19
  /** Whether the picker is disabled */
@@ -31,11 +38,17 @@
31
38
  class?: string;
32
39
  /** Show only date picker without time selection */
33
40
  dateOnly?: boolean;
41
+ /** Label text for the picker */
42
+ label?: string;
43
+ /** Hint text displayed below the picker */
44
+ hint?: string;
34
45
  }
35
46
 
36
47
  let {
37
48
  value = $bindable(null),
49
+ stringValue = $bindable(null),
38
50
  onValueChange,
51
+ onStringValueChange,
39
52
  placeholder,
40
53
  disabled = false,
41
54
  required = false,
@@ -48,8 +61,50 @@
48
61
  error,
49
62
  class: className,
50
63
  dateOnly = false,
64
+ label,
65
+ hint,
51
66
  }: Props = $props();
52
67
 
68
+ // Generate unique IDs for accessibility
69
+ // Using a random suffix ensures uniqueness if id is not provided
70
+ const randomSuffix = Math.random().toString(36).substring(2, 9);
71
+ const componentId = $derived(id || `datetimepicker-${randomSuffix}`);
72
+ const hintId = $derived(`${componentId}-hint`);
73
+ const errorId = $derived(`${componentId}-error`);
74
+
75
+ // Compute aria-describedby based on hint and error
76
+ const describedBy = $derived.by(() => {
77
+ const ids: string[] = [];
78
+ if (hint) ids.push(hintId);
79
+ if (error) ids.push(errorId);
80
+ return ids.length > 0 ? ids.join(' ') : undefined;
81
+ });
82
+
83
+ // Sync stringValue -> value when stringValue changes externally
84
+ let lastStringValue = $state<string | null>(stringValue);
85
+ $effect(() => {
86
+ if (stringValue !== lastStringValue) {
87
+ lastStringValue = stringValue;
88
+ const parsed = parseDate(stringValue);
89
+ if (
90
+ parsed !== value &&
91
+ (parsed === null || value === null || parsed.getTime() !== value?.getTime())
92
+ ) {
93
+ value = parsed;
94
+ }
95
+ }
96
+ });
97
+
98
+ // Sync value -> stringValue when value changes
99
+ $effect(() => {
100
+ const newStringValue = toISOString(value);
101
+ if (newStringValue !== stringValue) {
102
+ stringValue = newStringValue;
103
+ lastStringValue = newStringValue;
104
+ onStringValueChange?.(newStringValue);
105
+ }
106
+ });
107
+
53
108
  // Set default placeholder based on dateOnly mode
54
109
  const defaultPlaceholder = $derived(dateOnly ? 'Select date...' : 'Select date and time...');
55
110
  const effectivePlaceholder = $derived(placeholder ?? defaultPlaceholder);
@@ -153,7 +208,7 @@
153
208
  );
154
209
  }
155
210
 
156
- function selectDate(day: number) {
211
+ async function selectDate(day: number) {
157
212
  if (isDateDisabled(day)) return;
158
213
 
159
214
  // Capture current view state before any reactive updates
@@ -178,11 +233,12 @@
178
233
  isSelecting = true;
179
234
  value = newDate;
180
235
  onValueChange?.(newDate);
181
- // Allow effect to run again for external changes
236
+ // Wait for effects to process before allowing external sync
237
+ await tick();
182
238
  isSelecting = false;
183
239
  }
184
240
 
185
- function updateTime() {
241
+ async function updateTime() {
186
242
  isSelecting = true;
187
243
  if (!value) {
188
244
  // If no date selected, use today
@@ -204,6 +260,8 @@
204
260
  value = newDate;
205
261
  onValueChange?.(newDate);
206
262
  }
263
+ // Wait for effects to process before allowing external sync
264
+ await tick();
207
265
  isSelecting = false;
208
266
  }
209
267
 
@@ -246,208 +304,239 @@
246
304
  ];
247
305
 
248
306
  const dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
307
+
308
+ // Determine if we need a container wrapper
309
+ const hasWrapper = $derived(!!label || !!hint || !!error);
249
310
  </script>
250
311
 
251
- <Popover.Root bind:open>
252
- <Popover.Trigger
253
- {id}
254
- {disabled}
255
- class={cn(
256
- 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
257
- !value && 'text-muted-foreground',
258
- error && 'border-destructive focus:ring-destructive',
259
- className
260
- )}
261
- aria-invalid={error ? 'true' : undefined}
262
- >
263
- <span class="truncate">
264
- {displayValue || effectivePlaceholder}
265
- </span>
266
- <svg
267
- xmlns="http://www.w3.org/2000/svg"
268
- width="16"
269
- height="16"
270
- viewBox="0 0 24 24"
271
- fill="none"
272
- stroke="currentColor"
273
- stroke-width="2"
274
- stroke-linecap="round"
275
- stroke-linejoin="round"
276
- class="h-4 w-4 opacity-50 shrink-0 ml-2"
277
- >
278
- <rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
279
- <line x1="16" x2="16" y1="2" y2="6" />
280
- <line x1="8" x2="8" y1="2" y2="6" />
281
- <line x1="3" x2="21" y1="10" y2="10" />
282
- <path d="M8 14h.01" />
283
- <path d="M12 14h.01" />
284
- <path d="M16 14h.01" />
285
- <path d="M8 18h.01" />
286
- <path d="M12 18h.01" />
287
- <path d="M16 18h.01" />
288
- </svg>
289
- </Popover.Trigger>
290
-
291
- <Popover.Portal>
292
- <Popover.Content
293
- class="z-50 w-auto p-0 rounded-md border bg-popover text-popover-foreground shadow-md"
294
- sideOffset={4}
295
- align="start"
312
+ {#snippet pickerContent()}
313
+ <Popover.Root bind:open>
314
+ <Popover.Trigger
315
+ id={componentId}
316
+ {disabled}
317
+ class={cn(
318
+ 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
319
+ !value && 'text-muted-foreground',
320
+ error && 'border-destructive focus:ring-destructive',
321
+ className
322
+ )}
323
+ aria-invalid={error ? 'true' : undefined}
324
+ aria-describedby={describedBy}
325
+ aria-label={!label ? effectivePlaceholder : undefined}
296
326
  >
297
- <div class="p-3">
298
- <!-- Calendar header -->
299
- <div class="flex items-center justify-between mb-2">
300
- <button
301
- type="button"
302
- onclick={prevMonth}
303
- class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
304
- aria-label="Previous month"
305
- >
306
- <svg
307
- xmlns="http://www.w3.org/2000/svg"
308
- width="16"
309
- height="16"
310
- viewBox="0 0 24 24"
311
- fill="none"
312
- stroke="currentColor"
313
- stroke-width="2"
314
- stroke-linecap="round"
315
- stroke-linejoin="round"
316
- >
317
- <path d="m15 18-6-6 6-6" />
318
- </svg>
319
- </button>
320
- <span class="text-sm font-medium">
321
- {monthNames[viewMonth]}
322
- {viewYear}
323
- </span>
324
- <button
325
- type="button"
326
- onclick={nextMonth}
327
- class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
328
- aria-label="Next month"
329
- >
330
- <svg
331
- xmlns="http://www.w3.org/2000/svg"
332
- width="16"
333
- height="16"
334
- viewBox="0 0 24 24"
335
- fill="none"
336
- stroke="currentColor"
337
- stroke-width="2"
338
- stroke-linecap="round"
339
- stroke-linejoin="round"
327
+ <span class="truncate">
328
+ {displayValue || effectivePlaceholder}
329
+ </span>
330
+ <svg
331
+ xmlns="http://www.w3.org/2000/svg"
332
+ width="16"
333
+ height="16"
334
+ viewBox="0 0 24 24"
335
+ fill="none"
336
+ stroke="currentColor"
337
+ stroke-width="2"
338
+ stroke-linecap="round"
339
+ stroke-linejoin="round"
340
+ class="h-4 w-4 opacity-50 shrink-0 ml-2"
341
+ >
342
+ <rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
343
+ <line x1="16" x2="16" y1="2" y2="6" />
344
+ <line x1="8" x2="8" y1="2" y2="6" />
345
+ <line x1="3" x2="21" y1="10" y2="10" />
346
+ <path d="M8 14h.01" />
347
+ <path d="M12 14h.01" />
348
+ <path d="M16 14h.01" />
349
+ <path d="M8 18h.01" />
350
+ <path d="M12 18h.01" />
351
+ <path d="M16 18h.01" />
352
+ </svg>
353
+ </Popover.Trigger>
354
+
355
+ <Popover.Portal>
356
+ <Popover.Content
357
+ class="z-50 w-auto p-0 rounded-md border bg-popover text-popover-foreground shadow-md"
358
+ sideOffset={4}
359
+ align="start"
360
+ >
361
+ <div class="p-3">
362
+ <!-- Calendar header -->
363
+ <div class="flex items-center justify-between mb-2">
364
+ <button
365
+ type="button"
366
+ onclick={prevMonth}
367
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
368
+ aria-label="Previous month"
340
369
  >
341
- <path d="m9 18 6-6-6-6" />
342
- </svg>
343
- </button>
344
- </div>
345
-
346
- <!-- Day names -->
347
- <div class="grid grid-cols-7 gap-1 mb-1">
348
- {#each dayNames as dayName}
349
- <div
350
- class="h-8 w-8 flex items-center justify-center text-xs text-muted-foreground font-medium"
370
+ <svg
371
+ xmlns="http://www.w3.org/2000/svg"
372
+ width="16"
373
+ height="16"
374
+ viewBox="0 0 24 24"
375
+ fill="none"
376
+ stroke="currentColor"
377
+ stroke-width="2"
378
+ stroke-linecap="round"
379
+ stroke-linejoin="round"
380
+ >
381
+ <path d="m15 18-6-6 6-6" />
382
+ </svg>
383
+ </button>
384
+ <span class="text-sm font-medium">
385
+ {monthNames[viewMonth]}
386
+ {viewYear}
387
+ </span>
388
+ <button
389
+ type="button"
390
+ onclick={nextMonth}
391
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground"
392
+ aria-label="Next month"
351
393
  >
352
- {dayName}
353
- </div>
354
- {/each}
355
- </div>
356
-
357
- <!-- Calendar days -->
358
- <div class="grid grid-cols-7 gap-1">
359
- {#each calendarDays as day}
360
- {#if day === null}
361
- <div class="h-8 w-8"></div>
362
- {:else}
363
- <button
364
- type="button"
365
- onclick={() => selectDate(day)}
366
- disabled={isDateDisabled(day)}
367
- class={cn(
368
- 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent transition-colors duration-150',
369
- 'hover:bg-accent hover:text-accent-foreground',
370
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
371
- 'disabled:pointer-events-none disabled:opacity-50',
372
- isDateSelected(day) &&
373
- 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
374
- )}
394
+ <svg
395
+ xmlns="http://www.w3.org/2000/svg"
396
+ width="16"
397
+ height="16"
398
+ viewBox="0 0 24 24"
399
+ fill="none"
400
+ stroke="currentColor"
401
+ stroke-width="2"
402
+ stroke-linecap="round"
403
+ stroke-linejoin="round"
375
404
  >
376
- {day}
377
- </button>
378
- {/if}
379
- {/each}
380
- </div>
405
+ <path d="m9 18 6-6-6-6" />
406
+ </svg>
407
+ </button>
408
+ </div>
381
409
 
382
- <!-- Time selector (hidden in dateOnly mode) -->
383
- {#if !dateOnly}
384
- <div class="border-t mt-3 pt-3">
385
- <div class="flex items-center gap-2">
386
- <span class="text-sm text-muted-foreground">Time:</span>
387
- <select
388
- bind:value={hour}
389
- onchange={updateTime}
390
- class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
391
- >
392
- {#each hourOptions as h}
393
- <option value={timeFormat === '12h' ? h : h}>
394
- {String(h).padStart(2, '0')}
395
- </option>
396
- {/each}
397
- </select>
398
- <span class="text-muted-foreground">:</span>
399
- <select
400
- bind:value={minute}
401
- onchange={updateTime}
402
- class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
410
+ <!-- Day names -->
411
+ <div class="grid grid-cols-7 gap-1 mb-1">
412
+ {#each dayNames as dayName}
413
+ <div
414
+ class="h-8 w-8 flex items-center justify-center text-xs text-muted-foreground font-medium"
403
415
  >
404
- {#each minuteOptions as m}
405
- <option value={m}>{String(m).padStart(2, '0')}</option>
406
- {/each}
407
- </select>
408
- {#if timeFormat === '12h'}
416
+ {dayName}
417
+ </div>
418
+ {/each}
419
+ </div>
420
+
421
+ <!-- Calendar days -->
422
+ <div class="grid grid-cols-7 gap-1">
423
+ {#each calendarDays as day}
424
+ {#if day === null}
425
+ <div class="h-8 w-8"></div>
426
+ {:else}
427
+ <button
428
+ type="button"
429
+ onclick={() => selectDate(day)}
430
+ disabled={isDateDisabled(day)}
431
+ class={cn(
432
+ 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent transition-colors duration-150',
433
+ 'hover:bg-accent hover:text-accent-foreground',
434
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
435
+ 'disabled:pointer-events-none disabled:opacity-50',
436
+ isDateSelected(day) &&
437
+ 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
438
+ )}
439
+ >
440
+ {day}
441
+ </button>
442
+ {/if}
443
+ {/each}
444
+ </div>
445
+
446
+ <!-- Time selector (hidden in dateOnly mode) -->
447
+ {#if !dateOnly}
448
+ <div class="border-t mt-3 pt-3">
449
+ <div class="flex items-center gap-2">
450
+ <span class="text-sm text-muted-foreground">Time:</span>
409
451
  <select
410
- bind:value={period}
452
+ bind:value={hour}
411
453
  onchange={updateTime}
412
454
  class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
413
455
  >
414
- <option value="AM">AM</option>
415
- <option value="PM">PM</option>
456
+ {#each hourOptions as h}
457
+ <option value={timeFormat === '12h' ? h : h}>
458
+ {String(h).padStart(2, '0')}
459
+ </option>
460
+ {/each}
416
461
  </select>
417
- {/if}
462
+ <span class="text-muted-foreground">:</span>
463
+ <select
464
+ bind:value={minute}
465
+ onchange={updateTime}
466
+ class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
467
+ >
468
+ {#each minuteOptions as m}
469
+ <option value={m}>{String(m).padStart(2, '0')}</option>
470
+ {/each}
471
+ </select>
472
+ {#if timeFormat === '12h'}
473
+ <select
474
+ bind:value={period}
475
+ onchange={updateTime}
476
+ class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
477
+ >
478
+ <option value="AM">AM</option>
479
+ <option value="PM">PM</option>
480
+ </select>
481
+ {/if}
482
+ </div>
418
483
  </div>
484
+ {/if}
485
+
486
+ <!-- Actions -->
487
+ <div class="border-t mt-3 pt-3 flex justify-between">
488
+ <button
489
+ type="button"
490
+ onclick={clearValue}
491
+ class="text-sm text-muted-foreground hover:text-foreground"
492
+ >
493
+ Clear
494
+ </button>
495
+ <button
496
+ type="button"
497
+ onclick={() => (open = false)}
498
+ class="text-sm font-medium text-primary hover:underline"
499
+ >
500
+ Done
501
+ </button>
419
502
  </div>
420
- {/if}
421
-
422
- <!-- Actions -->
423
- <div class="border-t mt-3 pt-3 flex justify-between">
424
- <button
425
- type="button"
426
- onclick={clearValue}
427
- class="text-sm text-muted-foreground hover:text-foreground"
428
- >
429
- Clear
430
- </button>
431
- <button
432
- type="button"
433
- onclick={() => (open = false)}
434
- class="text-sm font-medium text-primary hover:underline"
435
- >
436
- Done
437
- </button>
438
503
  </div>
439
- </div>
440
- </Popover.Content>
441
- </Popover.Portal>
442
- </Popover.Root>
443
-
444
- <!-- Hidden input for form submission -->
445
- {#if name && value}
446
- <input type="hidden" {name} value={value.toISOString()} />
447
- {/if}
448
-
449
- {#if error}
450
- <p class="text-sm text-destructive mt-1.5" role="alert">
451
- {error}
452
- </p>
504
+ </Popover.Content>
505
+ </Popover.Portal>
506
+ </Popover.Root>
507
+
508
+ <!-- Hidden input for form submission -->
509
+ {#if name && value}
510
+ <input type="hidden" {name} value={value.toISOString()} />
511
+ {/if}
512
+ {/snippet}
513
+
514
+ {#if hasWrapper}
515
+ <div class="space-y-2">
516
+ {#if label}
517
+ <Label for={componentId} {disabled}>
518
+ {label}
519
+ {#if required}
520
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
521
+ <span class="sr-only">(required)</span>
522
+ {/if}
523
+ </Label>
524
+ {/if}
525
+
526
+ {@render pickerContent()}
527
+
528
+ {#if hint && !error}
529
+ <p id={hintId} class="text-sm text-muted-foreground">
530
+ {hint}
531
+ </p>
532
+ {/if}
533
+
534
+ {#if error}
535
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
536
+ {error}
537
+ </p>
538
+ {/if}
539
+ </div>
540
+ {:else}
541
+ {@render pickerContent()}
453
542
  {/if}
@@ -1,8 +1,12 @@
1
1
  interface Props {
2
2
  /** Current date/time value */
3
3
  value?: Date | null;
4
+ /** Current value as ISO string (alternative to value prop for form binding) */
5
+ stringValue?: string | null;
4
6
  /** Callback when value changes */
5
7
  onValueChange?: (date: Date | null) => void;
8
+ /** Callback when stringValue changes */
9
+ onStringValueChange?: (value: string | null) => void;
6
10
  /** Placeholder text */
7
11
  placeholder?: string;
8
12
  /** Whether the picker is disabled */
@@ -27,7 +31,11 @@ interface Props {
27
31
  class?: string;
28
32
  /** Show only date picker without time selection */
29
33
  dateOnly?: boolean;
34
+ /** Label text for the picker */
35
+ label?: string;
36
+ /** Hint text displayed below the picker */
37
+ hint?: string;
30
38
  }
31
- declare const DateTimePicker: import("svelte").Component<Props, {}, "value">;
39
+ declare const DateTimePicker: import("svelte").Component<Props, {}, "value" | "stringValue">;
32
40
  type DateTimePicker = ReturnType<typeof DateTimePicker>;
33
41
  export default DateTimePicker;
@@ -53,7 +53,7 @@
53
53
  {autocomplete}
54
54
  {inputmode}
55
55
  class={cn(
56
- 'flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
56
+ 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
57
57
  className
58
58
  )}
59
59
  oninput={handleInput}