@classic-homes/theme-svelte 0.1.52 → 0.1.54

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 (102) hide show
  1. package/dist/__tests__/a11y/Alert.a11y.test.js +84 -0
  2. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +126 -0
  3. package/dist/__tests__/a11y/Button.a11y.test.js +66 -0
  4. package/dist/__tests__/a11y/Card.a11y.test.js +77 -0
  5. package/dist/__tests__/a11y/Combobox.a11y.test.js +209 -0
  6. package/dist/__tests__/a11y/Dialog.a11y.test.js +103 -0
  7. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +104 -0
  8. package/dist/__tests__/a11y/FileUpload.a11y.test.js +158 -0
  9. package/dist/__tests__/a11y/Icon.a11y.test.js +119 -0
  10. package/dist/__tests__/a11y/Input.a11y.test.js +96 -0
  11. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +102 -0
  12. package/dist/__tests__/a11y/Switch.a11y.test.js +95 -0
  13. package/dist/__tests__/a11y/Tabs.a11y.test.js +97 -0
  14. package/dist/__tests__/a11y/Textarea.a11y.test.js +111 -0
  15. package/dist/__tests__/setup.js +6 -0
  16. package/dist/lib/components/Alert.svelte +1 -1
  17. package/dist/lib/components/AlertDialog.svelte +1 -1
  18. package/dist/lib/components/AlertDialog.svelte.d.ts +2 -2
  19. package/dist/lib/components/Badge.svelte +49 -12
  20. package/dist/lib/components/Badge.svelte.d.ts +9 -0
  21. package/dist/lib/components/Button.svelte +14 -2
  22. package/dist/lib/components/CardTitle.svelte +9 -3
  23. package/dist/lib/components/CardTitle.svelte.d.ts +2 -0
  24. package/dist/lib/components/Combobox.svelte +19 -2
  25. package/dist/lib/components/DataPanelHeader.svelte +4 -4
  26. package/dist/lib/components/DataPanelTab.svelte +1 -1
  27. package/dist/lib/components/DataTable.svelte +7 -7
  28. package/dist/lib/components/DataTable.svelte.d.ts +5 -5
  29. package/dist/lib/components/DateTimePicker.svelte +7 -4
  30. package/dist/lib/components/Dialog.svelte +6 -6
  31. package/dist/lib/components/DropdownMenu.svelte +14 -10
  32. package/dist/lib/components/FileUpload.svelte +5 -10
  33. package/dist/lib/components/FormField.svelte +7 -1
  34. package/dist/lib/components/HeaderSearch.svelte +9 -9
  35. package/dist/lib/components/Icon.svelte +5 -5
  36. package/dist/lib/components/Input.svelte +32 -0
  37. package/dist/lib/components/Input.svelte.d.ts +8 -0
  38. package/dist/lib/components/LiveRegion.svelte +61 -0
  39. package/dist/lib/components/LiveRegion.svelte.d.ts +18 -0
  40. package/dist/lib/components/LoadingOverlay.svelte +148 -0
  41. package/dist/lib/components/LoadingOverlay.svelte.d.ts +29 -0
  42. package/dist/lib/components/LoadingPage.svelte +86 -0
  43. package/dist/lib/components/LoadingPage.svelte.d.ts +20 -0
  44. package/dist/lib/components/LoadingSection.svelte +54 -0
  45. package/dist/lib/components/LoadingSection.svelte.d.ts +25 -0
  46. package/dist/lib/components/MultiSelect.svelte +23 -2
  47. package/dist/lib/components/NumberInput.svelte +4 -4
  48. package/dist/lib/components/OTPInput.svelte +14 -0
  49. package/dist/lib/components/PasswordInput.svelte +14 -1
  50. package/dist/lib/components/PasswordInput.svelte.d.ts +4 -0
  51. package/dist/lib/components/ProgressBar.svelte +91 -0
  52. package/dist/lib/components/ProgressBar.svelte.d.ts +18 -0
  53. package/dist/lib/components/RadioGroup.svelte +5 -5
  54. package/dist/lib/components/Select.svelte +13 -1
  55. package/dist/lib/components/Signature.svelte +5 -5
  56. package/dist/lib/components/Skeleton.svelte +13 -3
  57. package/dist/lib/components/Skeleton.svelte.d.ts +2 -0
  58. package/dist/lib/components/SkipLink.svelte +67 -0
  59. package/dist/lib/components/SkipLink.svelte.d.ts +25 -0
  60. package/dist/lib/components/Slider.svelte +6 -2
  61. package/dist/lib/components/Switch.svelte +8 -0
  62. package/dist/lib/components/Switch.svelte.d.ts +4 -0
  63. package/dist/lib/components/Tabs.svelte +15 -5
  64. package/dist/lib/components/Tabs.svelte.d.ts +6 -0
  65. package/dist/lib/components/Textarea.svelte +19 -0
  66. package/dist/lib/components/Textarea.svelte.d.ts +8 -0
  67. package/dist/lib/components/Toast.svelte +1 -1
  68. package/dist/lib/components/layout/AuthLayout.svelte +1 -1
  69. package/dist/lib/components/layout/DashboardLayout.svelte +1 -1
  70. package/dist/lib/components/layout/Footer.svelte +1 -1
  71. package/dist/lib/components/layout/PublicLayout.svelte +2 -2
  72. package/dist/lib/components/layout/QuickLinks.svelte +3 -3
  73. package/dist/lib/components/layout/Sidebar.svelte +1 -1
  74. package/dist/lib/components/layout/sidebar/SidebarFlyout.svelte +1 -2
  75. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +20 -21
  76. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +2 -2
  77. package/dist/lib/components/notifications/NotificationFilters.svelte +1 -1
  78. package/dist/lib/components/notifications/NotificationItem.svelte +2 -1
  79. package/dist/lib/composables/index.d.ts +8 -0
  80. package/dist/lib/composables/index.js +9 -0
  81. package/dist/lib/composables/useAnnounce.svelte.d.ts +65 -0
  82. package/dist/lib/composables/useAnnounce.svelte.js +76 -0
  83. package/dist/lib/composables/useArrowNavigation.svelte.d.ts +42 -0
  84. package/dist/lib/composables/useArrowNavigation.svelte.js +138 -0
  85. package/dist/lib/composables/useClickOutside.svelte.d.ts +88 -0
  86. package/dist/lib/composables/useClickOutside.svelte.js +100 -0
  87. package/dist/lib/composables/useEscapeKey.svelte.d.ts +48 -0
  88. package/dist/lib/composables/useEscapeKey.svelte.js +50 -0
  89. package/dist/lib/composables/useFocusRef.svelte.d.ts +46 -0
  90. package/dist/lib/composables/useFocusRef.svelte.js +92 -0
  91. package/dist/lib/composables/useFocusTrap.svelte.d.ts +42 -0
  92. package/dist/lib/composables/useFocusTrap.svelte.js +136 -0
  93. package/dist/lib/composables/useReducedMotion.svelte.d.ts +47 -0
  94. package/dist/lib/composables/useReducedMotion.svelte.js +67 -0
  95. package/dist/lib/composables/useRovingTabindex.svelte.d.ts +54 -0
  96. package/dist/lib/composables/useRovingTabindex.svelte.js +128 -0
  97. package/dist/lib/index.d.ts +7 -1
  98. package/dist/lib/index.js +11 -1
  99. package/dist/lib/stores/theme.svelte.d.ts +1 -1
  100. package/dist/lib/utils.d.ts +4 -2
  101. package/dist/lib/utils.js +5 -11
  102. package/package.json +11 -5
@@ -482,7 +482,7 @@
482
482
  <button
483
483
  type="button"
484
484
  onclick={prevMonth}
485
- 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"
485
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
486
486
  aria-label="Previous month"
487
487
  >
488
488
  <svg
@@ -506,7 +506,7 @@
506
506
  <button
507
507
  type="button"
508
508
  onclick={nextMonth}
509
- 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"
509
+ class="h-7 w-7 inline-flex items-center justify-center rounded-md bg-transparent hover:bg-accent hover:text-accent-foreground"
510
510
  aria-label="Next month"
511
511
  >
512
512
  <svg
@@ -547,7 +547,7 @@
547
547
  onclick={() => selectDate(day)}
548
548
  disabled={isDateDisabled(day)}
549
549
  class={cn(
550
- 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent transition-colors duration-150',
550
+ 'h-8 w-8 inline-flex items-center justify-center rounded-md text-sm bg-transparent',
551
551
  'hover:bg-accent hover:text-accent-foreground',
552
552
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
553
553
  'disabled:pointer-events-none disabled:opacity-50',
@@ -569,6 +569,7 @@
569
569
  <select
570
570
  bind:value={hour}
571
571
  onchange={updateTime}
572
+ aria-label="Hour"
572
573
  class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
573
574
  >
574
575
  {#each hourOptions as h}
@@ -577,10 +578,11 @@
577
578
  </option>
578
579
  {/each}
579
580
  </select>
580
- <span class="text-muted-foreground">:</span>
581
+ <span class="text-muted-foreground" aria-hidden="true">:</span>
581
582
  <select
582
583
  bind:value={minute}
583
584
  onchange={updateTime}
585
+ aria-label="Minute"
584
586
  class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
585
587
  >
586
588
  {#each minuteOptions as m}
@@ -591,6 +593,7 @@
591
593
  <select
592
594
  bind:value={period}
593
595
  onchange={updateTime}
596
+ aria-label="AM or PM"
594
597
  class="h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
595
598
  >
596
599
  <option value="AM">AM</option>
@@ -78,11 +78,11 @@
78
78
 
79
79
  <DialogPrimitive.Portal>
80
80
  <DialogPrimitive.Overlay
81
- class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
81
+ class="fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
82
82
  />
83
83
  <DialogPrimitive.Content
84
84
  class={cn(
85
- 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-black bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
85
+ 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
86
86
  sizeClasses[size],
87
87
  className
88
88
  )}
@@ -90,7 +90,7 @@
90
90
  <!-- Header section with title and actions -->
91
91
  {#if header || title || description || headerActions || showClose}
92
92
  <div
93
- class="flex items-start justify-between gap-4 border-b border-black bg-muted/30 px-6 py-4"
93
+ class="flex items-start justify-between gap-4 border-b border-border bg-muted/30 px-6 py-4"
94
94
  >
95
95
  <!-- Title section -->
96
96
  <div class="flex flex-col space-y-1.5 min-w-0 flex-1 overflow-hidden">
@@ -117,7 +117,7 @@
117
117
  {/if}
118
118
  {#if showClose}
119
119
  <DialogPrimitive.Close
120
- class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none"
120
+ class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none"
121
121
  aria-label="Close dialog"
122
122
  >
123
123
  <svg
@@ -131,11 +131,11 @@
131
131
  stroke-linecap="round"
132
132
  stroke-linejoin="round"
133
133
  class="h-5 w-5"
134
+ aria-hidden="true"
134
135
  >
135
136
  <path d="M18 6 6 18" />
136
137
  <path d="m6 6 12 12" />
137
138
  </svg>
138
- <span class="sr-only">Close</span>
139
139
  </DialogPrimitive.Close>
140
140
  {/if}
141
141
  </div>
@@ -150,7 +150,7 @@
150
150
  <!-- Footer section -->
151
151
  {#if footer}
152
152
  <div
153
- class="flex flex-col-reverse border-t border-black bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
153
+ class="flex flex-col-reverse border-t border-border bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
154
154
  >
155
155
  {@render footer()}
156
156
  </div>
@@ -58,9 +58,9 @@
58
58
  <DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
59
59
  <DropdownMenuPrimitive.Trigger>
60
60
  {#snippet child({ props })}
61
- <span {...props}>
61
+ <button type="button" {...props}>
62
62
  {@render trigger()}
63
- </span>
63
+ </button>
64
64
  {/snippet}
65
65
  </DropdownMenuPrimitive.Trigger>
66
66
 
@@ -80,15 +80,15 @@
80
80
  {#if isGroup(item)}
81
81
  <DropdownMenuPrimitive.Group>
82
82
  {#if item.label}
83
- <div class="px-2 py-1.5 text-sm font-semibold">
83
+ <DropdownMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
84
84
  {item.label}
85
- </div>
85
+ </DropdownMenuPrimitive.GroupHeading>
86
86
  {/if}
87
87
  {#each item.items as subItem}
88
88
  {#if subItem.type === 'separator'}
89
89
  <DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
90
90
  {:else if subItem.type === 'label'}
91
- <div class="px-2 py-1.5 text-sm font-semibold">
91
+ <div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
92
92
  {subItem.label}
93
93
  </div>
94
94
  {:else}
@@ -96,15 +96,17 @@
96
96
  disabled={subItem.disabled}
97
97
  onSelect={() => subItem.onSelect?.()}
98
98
  class={cn(
99
- 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
99
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
100
100
  subItem.destructive && 'text-destructive data-[highlighted]:text-destructive'
101
101
  )}
102
+ aria-keyshortcuts={subItem.shortcut || undefined}
102
103
  >
103
104
  {subItem.label}
104
105
  {#if subItem.shortcut}
105
- <span class="ml-auto text-xs tracking-widest opacity-60">
106
+ <span class="ml-auto text-xs tracking-widest opacity-60" aria-hidden="true">
106
107
  {subItem.shortcut}
107
108
  </span>
109
+ <span class="sr-only">, keyboard shortcut {subItem.shortcut}</span>
108
110
  {/if}
109
111
  </DropdownMenuPrimitive.Item>
110
112
  {/if}
@@ -113,7 +115,7 @@
113
115
  {:else if item.type === 'separator'}
114
116
  <DropdownMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
115
117
  {:else if item.type === 'label'}
116
- <div class="px-2 py-1.5 text-sm font-semibold">
118
+ <div role="presentation" class="px-2 py-1.5 text-sm font-semibold">
117
119
  {item.label}
118
120
  </div>
119
121
  {:else}
@@ -121,15 +123,17 @@
121
123
  disabled={item.disabled}
122
124
  onSelect={() => item.onSelect?.()}
123
125
  class={cn(
124
- 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
126
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default',
125
127
  item.destructive && 'text-destructive data-[highlighted]:text-destructive'
126
128
  )}
129
+ aria-keyshortcuts={item.shortcut || undefined}
127
130
  >
128
131
  {item.label}
129
132
  {#if item.shortcut}
130
- <span class="ml-auto text-xs tracking-widest opacity-60">
133
+ <span class="ml-auto text-xs tracking-widest opacity-60" aria-hidden="true">
131
134
  {item.shortcut}
132
135
  </span>
136
+ <span class="sr-only">, keyboard shortcut {item.shortcut}</span>
133
137
  {/if}
134
138
  </DropdownMenuPrimitive.Item>
135
139
  {/if}
@@ -297,15 +297,13 @@
297
297
  </label>
298
298
  {/if}
299
299
 
300
- <!-- Drop Zone -->
301
- <div
302
- role="button"
303
- tabindex={disabled ? -1 : 0}
304
- aria-disabled={disabled}
300
+ <!-- Drop Zone - using label to wrap the hidden input for better accessibility -->
301
+ <label
302
+ for={id}
305
303
  aria-describedby={displayError ? `${id}-error` : undefined}
306
304
  class={cn(
307
305
  'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors',
308
- 'cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
306
+ 'cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
309
307
  isDragging && !disabled && 'border-primary bg-primary/5',
310
308
  disabled && 'cursor-not-allowed opacity-50',
311
309
  displayError && 'border-destructive',
@@ -318,8 +316,6 @@
318
316
  ondragleave={handleDragLeave}
319
317
  ondragover={handleDragOver}
320
318
  ondrop={handleDrop}
321
- onclick={handleClick}
322
- onkeydown={handleKeyDown}
323
319
  >
324
320
  <!-- Cloud Upload Icon -->
325
321
  <svg
@@ -354,9 +350,8 @@
354
350
  {disabled}
355
351
  class="sr-only"
356
352
  onchange={handleInputChange}
357
- aria-label="File upload"
358
353
  />
359
- </div>
354
+ </label>
360
355
 
361
356
  <!-- Hint text -->
362
357
  {#if hint && !displayError}
@@ -122,7 +122,13 @@
122
122
 
123
123
  <!-- Error message (takes precedence over hint) -->
124
124
  {#if error}
125
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
125
+ <p
126
+ id={errorId}
127
+ class="text-sm text-destructive"
128
+ role="alert"
129
+ aria-live="polite"
130
+ aria-atomic="true"
131
+ >
126
132
  {error}
127
133
  </p>
128
134
  {:else if hint}
@@ -15,12 +15,12 @@
15
15
  * @example Basic usage:
16
16
  * ```svelte
17
17
  * <HeaderSearch
18
- * enabled={true}
19
- * placeholder="Search..."
20
- * onSearch={(query) => handleSearch(query)}
21
- * onSelect={(item) => goto(item.href)}
22
- * results={searchResults}
23
- * loading={isSearching}
18
+ * enabled={true}
19
+ * placeholder="Search..."
20
+ * onSearch={(query) => handleSearch(query)}
21
+ * onSelect={(item) => goto(item.href)}
22
+ * results={searchResults}
23
+ * loading={isSearching}
24
24
  * />
25
25
  * ```
26
26
  */
@@ -175,7 +175,7 @@
175
175
  type="button"
176
176
  onclick={() => (open = true)}
177
177
  class={cn(
178
- 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
178
+ 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
179
179
  size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
180
180
  className
181
181
  )}
@@ -197,7 +197,7 @@
197
197
  type="button"
198
198
  onclick={() => (open = true)}
199
199
  class={cn(
200
- 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
200
+ 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
201
201
  size === 'sm' ? 'h-8' : 'h-9',
202
202
  className
203
203
  )}
@@ -287,7 +287,7 @@
287
287
  type="button"
288
288
  onclick={() => handleSelect(item)}
289
289
  class={cn(
290
- 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
290
+ 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm',
291
291
  index === selectedIndex
292
292
  ? 'bg-accent text-accent-foreground'
293
293
  : 'hover:bg-accent-hover'
@@ -895,7 +895,7 @@
895
895
  {#if name === 'loading'}
896
896
  <!-- Loading spinner with animation -->
897
897
  <svg
898
- aria-hidden={!label}
898
+ aria-hidden="true"
899
899
  width={size}
900
900
  height={size}
901
901
  viewBox="0 0 24 24"
@@ -911,7 +911,7 @@
911
911
  {:else if name === 'more'}
912
912
  <!-- Vertical dots -->
913
913
  <svg
914
- aria-hidden={!label}
914
+ aria-hidden="true"
915
915
  width={size}
916
916
  height={size}
917
917
  viewBox="0 0 24 24"
@@ -926,7 +926,7 @@
926
926
  {:else if name === 'more-horizontal'}
927
927
  <!-- Horizontal dots -->
928
928
  <svg
929
- aria-hidden={!label}
929
+ aria-hidden="true"
930
930
  width={size}
931
931
  height={size}
932
932
  viewBox="0 0 24 24"
@@ -941,7 +941,7 @@
941
941
  {:else if name === 'grip'}
942
942
  <!-- Grip handle (6 dots for drag) -->
943
943
  <svg
944
- aria-hidden={!label}
944
+ aria-hidden="true"
945
945
  width={size}
946
946
  height={size}
947
947
  viewBox="0 0 24 24"
@@ -959,7 +959,7 @@
959
959
  {:else if iconDef}
960
960
  <!-- Standard path-based icons -->
961
961
  <svg
962
- aria-hidden={!label}
962
+ aria-hidden="true"
963
963
  width={size}
964
964
  height={size}
965
965
  viewBox="0 0 24 24"
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
+ import { DEV } from 'esm-env';
3
4
 
4
5
  interface Props {
5
6
  type?: string;
@@ -14,6 +15,14 @@
14
15
  /** Input mode hint for mobile keyboards */
15
16
  inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
16
17
  class?: string;
18
+ /** Accessible label for screen readers (alternative to visible label) */
19
+ 'aria-label'?: string;
20
+ /** ID of element that labels this input */
21
+ 'aria-labelledby'?: string;
22
+ /** ID of element that describes this input (e.g., hint or error message) */
23
+ 'aria-describedby'?: string;
24
+ /** Whether the input has an error */
25
+ 'aria-invalid'?: boolean | 'true' | 'false';
17
26
  onValueChange?: (value: string) => void;
18
27
  [key: string]: unknown;
19
28
  }
@@ -30,10 +39,25 @@
30
39
  autocomplete,
31
40
  inputmode,
32
41
  class: className,
42
+ 'aria-label': ariaLabel,
43
+ 'aria-labelledby': ariaLabelledby,
44
+ 'aria-describedby': ariaDescribedby,
45
+ 'aria-invalid': ariaInvalid,
33
46
  onValueChange,
34
47
  ...restProps
35
48
  }: Props = $props();
36
49
 
50
+ // Accessibility: warn if no label is associated in dev mode
51
+ if (DEV) {
52
+ $effect(() => {
53
+ if (!ariaLabel && !ariaLabelledby && !id) {
54
+ console.warn(
55
+ '[Input] Missing accessible label. Provide aria-label, aria-labelledby, or an id with a matching <label for="...">.'
56
+ );
57
+ }
58
+ });
59
+ }
60
+
37
61
  function handleInput(e: Event) {
38
62
  const target = e.target as HTMLInputElement;
39
63
  value = target.value;
@@ -52,8 +76,16 @@
52
76
  {id}
53
77
  {autocomplete}
54
78
  {inputmode}
79
+ aria-label={ariaLabel}
80
+ aria-labelledby={ariaLabelledby}
81
+ aria-describedby={ariaDescribedby}
82
+ aria-invalid={ariaInvalid}
83
+ aria-required={required}
55
84
  class={cn(
56
85
  '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',
86
+ ariaInvalid === true || ariaInvalid === 'true'
87
+ ? 'border-destructive focus-visible:ring-destructive'
88
+ : '',
57
89
  className
58
90
  )}
59
91
  oninput={handleInput}
@@ -11,6 +11,14 @@ interface Props {
11
11
  /** Input mode hint for mobile keyboards */
12
12
  inputmode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
13
13
  class?: string;
14
+ /** Accessible label for screen readers (alternative to visible label) */
15
+ 'aria-label'?: string;
16
+ /** ID of element that labels this input */
17
+ 'aria-labelledby'?: string;
18
+ /** ID of element that describes this input (e.g., hint or error message) */
19
+ 'aria-describedby'?: string;
20
+ /** Whether the input has an error */
21
+ 'aria-invalid'?: boolean | 'true' | 'false';
14
22
  onValueChange?: (value: string) => void;
15
23
  [key: string]: unknown;
16
24
  }
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ interface Props {
6
+ /** Text to announce to screen readers */
7
+ message?: string;
8
+ /** Interruption level - 'polite' waits for idle, 'assertive' interrupts immediately */
9
+ politeness?: 'polite' | 'assertive';
10
+ /** Whether to announce the entire region on updates (default: true) */
11
+ atomic?: boolean;
12
+ /** Whether to hide the region visually (default: true) */
13
+ visuallyHidden?: boolean;
14
+ /** Additional class names */
15
+ class?: string;
16
+ /** Child content to render in the live region */
17
+ children?: Snippet;
18
+ }
19
+
20
+ let {
21
+ message,
22
+ politeness = 'polite',
23
+ atomic = true,
24
+ visuallyHidden = true,
25
+ class: className,
26
+ children,
27
+ }: Props = $props();
28
+ </script>
29
+
30
+ <!--
31
+ LiveRegion announces dynamic content changes to screen readers.
32
+
33
+ Use this component when content updates need to be communicated to assistive technology users,
34
+ such as form validation messages, status updates, or notifications.
35
+
36
+ @example
37
+ <LiveRegion message={errorMessage} politeness="assertive" />
38
+
39
+ @example with children
40
+ <LiveRegion politeness="polite">
41
+ <span>Status: {status}</span>
42
+ </LiveRegion>
43
+
44
+ @example visible
45
+ <LiveRegion visuallyHidden={false} message="Loading..." />
46
+ -->
47
+ <div
48
+ role="status"
49
+ aria-live={politeness}
50
+ aria-atomic={atomic}
51
+ class={cn(
52
+ visuallyHidden && 'sr-only absolute h-px w-px overflow-hidden whitespace-nowrap border-0 p-0',
53
+ className
54
+ )}
55
+ >
56
+ {#if message}
57
+ {message}
58
+ {:else if children}
59
+ {@render children()}
60
+ {/if}
61
+ </div>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Text to announce to screen readers */
4
+ message?: string;
5
+ /** Interruption level - 'polite' waits for idle, 'assertive' interrupts immediately */
6
+ politeness?: 'polite' | 'assertive';
7
+ /** Whether to announce the entire region on updates (default: true) */
8
+ atomic?: boolean;
9
+ /** Whether to hide the region visually (default: true) */
10
+ visuallyHidden?: boolean;
11
+ /** Additional class names */
12
+ class?: string;
13
+ /** Child content to render in the live region */
14
+ children?: Snippet;
15
+ }
16
+ declare const LiveRegion: import("svelte").Component<Props, {}, "">;
17
+ type LiveRegion = ReturnType<typeof LiveRegion>;
18
+ export default LiveRegion;
@@ -0,0 +1,148 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+ import Spinner from './Spinner.svelte';
4
+ import LoadingLogo from './LoadingLogo.svelte';
5
+ import ProgressBar from './ProgressBar.svelte';
6
+ import Skeleton from './Skeleton.svelte';
7
+ import type { Snippet } from 'svelte';
8
+
9
+ interface Props {
10
+ /** Whether loading is active */
11
+ loading?: boolean;
12
+ /** Which indicator to show */
13
+ variant?: 'spinner' | 'logo' | 'progress' | 'skeleton';
14
+ /** Positioning mode */
15
+ mode?: 'fullscreen' | 'overlay' | 'inline';
16
+ /** Indicator size */
17
+ size?: 'sm' | 'md' | 'lg' | 'xl';
18
+ /** Loading message text */
19
+ message?: string;
20
+ /** Progress value for progress variant */
21
+ progress?: number;
22
+ /** Backdrop blur (overlay/fullscreen) */
23
+ blur?: boolean;
24
+ /** Fully opaque background */
25
+ opaque?: boolean;
26
+ /** Delay in ms before showing */
27
+ delay?: number;
28
+ /** Additional CSS classes */
29
+ class?: string;
30
+ /** Content behind overlay */
31
+ children?: Snippet;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ const logoSizeMap = { sm: 32, md: 40, lg: 56, xl: 80 } as const;
36
+
37
+ let {
38
+ loading = true,
39
+ variant = 'spinner',
40
+ mode = 'overlay',
41
+ size = 'md',
42
+ message,
43
+ progress,
44
+ blur = true,
45
+ opaque = false,
46
+ delay = 0,
47
+ class: className,
48
+ children,
49
+ ...restProps
50
+ }: Props = $props();
51
+
52
+ let visible = $state(false);
53
+
54
+ $effect(() => {
55
+ if (!loading) {
56
+ visible = false;
57
+ return;
58
+ }
59
+
60
+ if (delay === 0) {
61
+ visible = true;
62
+ return;
63
+ }
64
+
65
+ const timer = setTimeout(() => {
66
+ visible = true;
67
+ }, delay);
68
+
69
+ return () => clearTimeout(timer);
70
+ });
71
+
72
+ const bgClass = $derived(
73
+ opaque ? 'bg-background' : mode === 'fullscreen' ? 'bg-background/80' : 'bg-background/60'
74
+ );
75
+
76
+ const positionClass = $derived(
77
+ mode === 'fullscreen' ? 'fixed inset-0 z-50' : 'absolute inset-0 z-10'
78
+ );
79
+ </script>
80
+
81
+ {#if mode === 'inline'}
82
+ {#if visible}
83
+ <div
84
+ role="status"
85
+ aria-live="polite"
86
+ class={cn('inline-flex items-center gap-2', className)}
87
+ {...restProps}
88
+ >
89
+ {#if variant === 'spinner'}
90
+ <Spinner {size} />
91
+ {:else if variant === 'logo'}
92
+ <LoadingLogo loading width={logoSizeMap[size]} height={logoSizeMap[size]} />
93
+ {:else if variant === 'progress'}
94
+ <ProgressBar value={progress} class="w-48 max-w-full" />
95
+ {:else if variant === 'skeleton'}
96
+ <div class="w-full space-y-3">
97
+ <Skeleton variant="title" />
98
+ <Skeleton variant="text" />
99
+ <Skeleton variant="text" />
100
+ <Skeleton variant="text" class="w-2/3" />
101
+ </div>
102
+ {/if}
103
+ {#if message}
104
+ <p class="mt-2 text-sm text-muted-foreground animate-pulse-subtle">{message}</p>
105
+ {:else}
106
+ <span class="sr-only">Loading</span>
107
+ {/if}
108
+ </div>
109
+ {/if}
110
+ {:else}
111
+ {#if children}
112
+ {@render children()}
113
+ {/if}
114
+ {#if visible}
115
+ <div
116
+ role="status"
117
+ aria-live="polite"
118
+ class={cn(
119
+ positionClass,
120
+ bgClass,
121
+ blur && 'backdrop-blur-sm',
122
+ 'flex flex-col items-center justify-center',
123
+ className
124
+ )}
125
+ {...restProps}
126
+ >
127
+ {#if variant === 'spinner'}
128
+ <Spinner {size} />
129
+ {:else if variant === 'logo'}
130
+ <LoadingLogo loading width={logoSizeMap[size]} height={logoSizeMap[size]} />
131
+ {:else if variant === 'progress'}
132
+ <ProgressBar value={progress} class="w-48 max-w-full" />
133
+ {:else if variant === 'skeleton'}
134
+ <div class="w-full space-y-3">
135
+ <Skeleton variant="title" />
136
+ <Skeleton variant="text" />
137
+ <Skeleton variant="text" />
138
+ <Skeleton variant="text" class="w-2/3" />
139
+ </div>
140
+ {/if}
141
+ {#if message}
142
+ <p class="mt-2 text-sm text-muted-foreground animate-pulse-subtle">{message}</p>
143
+ {:else}
144
+ <span class="sr-only">Loading</span>
145
+ {/if}
146
+ </div>
147
+ {/if}
148
+ {/if}