@classic-homes/theme-svelte 0.1.48 → 0.1.50

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.
@@ -44,6 +44,7 @@
44
44
  class?: string;
45
45
  onclick?: (e: MouseEvent) => void;
46
46
  children: Snippet;
47
+ /** Allow passing additional HTML attributes (aria-*, data-*, etc.) */
47
48
  [key: string]: unknown;
48
49
  }
49
50
 
@@ -64,33 +65,41 @@
64
65
  const classes = $derived(cn(buttonVariants({ variant, size }), className));
65
66
  </script>
66
67
 
67
- {#if href}
68
- <a {href} class={classes} {...restProps}>
69
- {#if loading}
70
- <svg
71
- class="h-4 w-4 animate-spin"
72
- xmlns="http://www.w3.org/2000/svg"
73
- fill="none"
74
- viewBox="0 0 24 24"
75
- aria-hidden="true"
76
- >
77
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
78
- ></circle>
79
- <path
80
- class="opacity-75"
81
- fill="currentColor"
82
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
83
- ></path>
84
- </svg>
85
- {#if loadingText}
86
- <span>{loadingText}</span>
87
- <span class="sr-only">Loading</span>
88
- {:else}
89
- {@render children()}
90
- {/if}
68
+ {#snippet loadingSpinner()}
69
+ <svg
70
+ class="h-4 w-4 animate-spin"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ fill="none"
73
+ viewBox="0 0 24 24"
74
+ aria-hidden="true"
75
+ >
76
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
77
+ ></circle>
78
+ <path
79
+ class="opacity-75"
80
+ fill="currentColor"
81
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
82
+ ></path>
83
+ </svg>
84
+ {/snippet}
85
+
86
+ {#snippet buttonContent()}
87
+ {#if loading}
88
+ {@render loadingSpinner()}
89
+ {#if loadingText}
90
+ <span>{loadingText}</span>
91
+ <span class="sr-only">Loading</span>
91
92
  {:else}
92
93
  {@render children()}
93
94
  {/if}
95
+ {:else}
96
+ {@render children()}
97
+ {/if}
98
+ {/snippet}
99
+
100
+ {#if href}
101
+ <a {href} class={classes} {...restProps}>
102
+ {@render buttonContent()}
94
103
  </a>
95
104
  {:else}
96
105
  <button
@@ -102,30 +111,6 @@
102
111
  {onclick}
103
112
  {...restProps}
104
113
  >
105
- {#if loading}
106
- <svg
107
- class="h-4 w-4 animate-spin"
108
- xmlns="http://www.w3.org/2000/svg"
109
- fill="none"
110
- viewBox="0 0 24 24"
111
- aria-hidden="true"
112
- >
113
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
114
- ></circle>
115
- <path
116
- class="opacity-75"
117
- fill="currentColor"
118
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
119
- ></path>
120
- </svg>
121
- {#if loadingText}
122
- <span>{loadingText}</span>
123
- <span class="sr-only">Loading</span>
124
- {:else}
125
- {@render children()}
126
- {/if}
127
- {:else}
128
- {@render children()}
129
- {/if}
114
+ {@render buttonContent()}
130
115
  </button>
131
116
  {/if}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { Checkbox as CheckboxPrimitive } from 'bits-ui';
4
- import { cn } from '../utils.js';
4
+ import { cn, useId } from '../utils.js';
5
5
 
6
6
  interface Props {
7
7
  checked?: boolean;
@@ -32,7 +32,7 @@
32
32
  }: Props = $props();
33
33
 
34
34
  // Generate a stable unique ID if none provided (for accessibility)
35
- const generatedId = `checkbox-${Math.random().toString(36).substring(2, 11)}`;
35
+ const generatedId = useId('checkbox');
36
36
  const effectiveId = $derived(id ?? generatedId);
37
37
 
38
38
  function handleCheckedChange(newChecked: boolean | 'indeterminate') {
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { Popover, Calendar } from 'bits-ui';
3
3
  import { tick } from 'svelte';
4
- import { cn } from '../utils.js';
4
+ import { cn, useId } from '../utils.js';
5
5
  import { parseDate, toISOString } from '../utils/date.js';
6
6
  import Label from './Label.svelte';
7
7
 
@@ -65,10 +65,9 @@
65
65
  hint,
66
66
  }: Props = $props();
67
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}`);
68
+ // Generate unique IDs for accessibility using sequential counter
69
+ const generatedId = useId('datetimepicker');
70
+ const componentId = $derived(id || generatedId);
72
71
  const hintId = $derived(`${componentId}-hint`);
73
72
  const errorId = $derived(`${componentId}-error`);
74
73
 
@@ -12,7 +12,7 @@
12
12
  * - Accessible with keyboard navigation
13
13
  */
14
14
  import type { FileMetadata } from '../types/components.js';
15
- import { cn } from '../utils.js';
15
+ import { cn, useId } from '../utils.js';
16
16
  import Button from './Button.svelte';
17
17
 
18
18
  interface Props {
@@ -42,8 +42,10 @@
42
42
  class?: string;
43
43
  }
44
44
 
45
+ const generatedId = useId('file-upload');
46
+
45
47
  let {
46
- id = `file-upload-${Math.random().toString(36).slice(2, 9)}`,
48
+ id = generatedId,
47
49
  files = $bindable([]),
48
50
  maxFiles = 10,
49
51
  maxSizeBytes = 10 * 1024 * 1024, // 10MB
@@ -23,7 +23,7 @@
23
23
  <LabelPrimitive.Root
24
24
  for={htmlFor}
25
25
  class={cn(
26
- 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-700 dark:text-gray-300',
26
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground',
27
27
  disabled && 'opacity-50 cursor-not-allowed',
28
28
  className
29
29
  )}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Select as SelectPrimitive } from 'bits-ui';
3
- import { cn } from '../utils.js';
3
+ import { cn, useId } from '../utils.js';
4
4
  import { validateNonEmptyArray } from '../validation.js';
5
5
  import Label from './Label.svelte';
6
6
 
@@ -57,10 +57,9 @@
57
57
  hint,
58
58
  }: Props = $props();
59
59
 
60
- // Generate unique IDs for accessibility
61
- // Using a random suffix ensures uniqueness if id is not provided
62
- const randomSuffix = Math.random().toString(36).substring(2, 9);
63
- const componentId = $derived(id || `select-${randomSuffix}`);
60
+ // Generate unique IDs for accessibility using sequential counter
61
+ const generatedId = useId('select');
62
+ const componentId = $derived(id || generatedId);
64
63
  const hintId = $derived(`${componentId}-hint`);
65
64
  const errorId = $derived(`${componentId}-error`);
66
65
 
@@ -116,8 +115,128 @@
116
115
 
117
116
  // Determine if we need a container wrapper
118
117
  const hasWrapper = $derived(!!label || !!hint || !!error);
118
+
119
+ // Common trigger classes
120
+ const triggerClasses = $derived(
121
+ cn(
122
+ '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 placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
123
+ error && 'border-destructive focus:ring-destructive',
124
+ className
125
+ )
126
+ );
127
+
128
+ // Common content classes
129
+ const contentClasses =
130
+ 'isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2';
131
+
132
+ // Common item classes
133
+ const itemClasses =
134
+ 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 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';
119
135
  </script>
120
136
 
137
+ {#snippet chevronIcon()}
138
+ <svg
139
+ xmlns="http://www.w3.org/2000/svg"
140
+ width="24"
141
+ height="24"
142
+ viewBox="0 0 24 24"
143
+ fill="none"
144
+ stroke="currentColor"
145
+ stroke-width="2"
146
+ stroke-linecap="round"
147
+ stroke-linejoin="round"
148
+ class="h-4 w-4 opacity-50"
149
+ >
150
+ <path d="m6 9 6 6 6-6" />
151
+ </svg>
152
+ {/snippet}
153
+
154
+ {#snippet checkIcon()}
155
+ <svg
156
+ xmlns="http://www.w3.org/2000/svg"
157
+ width="16"
158
+ height="16"
159
+ viewBox="0 0 24 24"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ stroke-width="2"
163
+ stroke-linecap="round"
164
+ stroke-linejoin="round"
165
+ class="h-4 w-4"
166
+ >
167
+ <polyline points="20 6 9 17 4 12" />
168
+ </svg>
169
+ {/snippet}
170
+
171
+ {#snippet selectItem(option: SelectOption)}
172
+ <SelectPrimitive.Item
173
+ value={option.value}
174
+ label={option.label}
175
+ disabled={option.disabled}
176
+ class={itemClasses}
177
+ >
178
+ {#if option.value === value}
179
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
180
+ {@render checkIcon()}
181
+ </span>
182
+ {/if}
183
+ {option.label}
184
+ </SelectPrimitive.Item>
185
+ {/snippet}
186
+
187
+ {#snippet selectContent()}
188
+ <SelectPrimitive.Content
189
+ class={contentClasses}
190
+ sideOffset={-1}
191
+ collisionPadding={8}
192
+ avoidCollisions={true}
193
+ >
194
+ <div class="p-1 max-h-[300px] overflow-y-auto">
195
+ {#each options as item}
196
+ {#if isGroup(item)}
197
+ <SelectPrimitive.Group>
198
+ <div class="py-1.5 pl-8 pr-2 text-sm font-semibold">
199
+ {item.label}
200
+ </div>
201
+ {#each item.options as option}
202
+ {@render selectItem(option)}
203
+ {/each}
204
+ </SelectPrimitive.Group>
205
+ {:else}
206
+ {@render selectItem(item)}
207
+ {/if}
208
+ {/each}
209
+ </div>
210
+ </SelectPrimitive.Content>
211
+ {/snippet}
212
+
213
+ {#snippet selectRoot(ariaLabel: string | undefined)}
214
+ <SelectPrimitive.Root
215
+ type="single"
216
+ {name}
217
+ {disabled}
218
+ {required}
219
+ {value}
220
+ items={flatItems}
221
+ onValueChange={handleValueChange}
222
+ >
223
+ <SelectPrimitive.Trigger
224
+ id={componentId}
225
+ class={triggerClasses}
226
+ aria-label={ariaLabel}
227
+ aria-invalid={error ? 'true' : undefined}
228
+ aria-describedby={describedBy}
229
+ >
230
+ <span class={value ? '' : 'text-muted-foreground'}>
231
+ {selectedLabel || placeholder}
232
+ </span>
233
+ {@render chevronIcon()}
234
+ </SelectPrimitive.Trigger>
235
+
236
+ {@render selectContent()}
237
+ </SelectPrimitive.Root>
238
+ {/snippet}
239
+
121
240
  {#if hasWrapper}
122
241
  <div class="space-y-2">
123
242
  {#if label}
@@ -130,119 +249,7 @@
130
249
  </Label>
131
250
  {/if}
132
251
 
133
- <SelectPrimitive.Root
134
- type="single"
135
- {name}
136
- {disabled}
137
- {required}
138
- {value}
139
- items={flatItems}
140
- onValueChange={handleValueChange}
141
- >
142
- <SelectPrimitive.Trigger
143
- id={componentId}
144
- class={cn(
145
- '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 placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
146
- error && 'border-destructive focus:ring-destructive',
147
- className
148
- )}
149
- aria-label={!label ? placeholder : undefined}
150
- aria-invalid={error ? 'true' : undefined}
151
- aria-describedby={describedBy}
152
- >
153
- <span class={value ? '' : 'text-muted-foreground'}>
154
- {selectedLabel || placeholder}
155
- </span>
156
- <svg
157
- xmlns="http://www.w3.org/2000/svg"
158
- width="24"
159
- height="24"
160
- viewBox="0 0 24 24"
161
- fill="none"
162
- stroke="currentColor"
163
- stroke-width="2"
164
- stroke-linecap="round"
165
- stroke-linejoin="round"
166
- class="h-4 w-4 opacity-50"
167
- >
168
- <path d="m6 9 6 6 6-6" />
169
- </svg>
170
- </SelectPrimitive.Trigger>
171
-
172
- <SelectPrimitive.Content
173
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
174
- sideOffset={-1}
175
- collisionPadding={8}
176
- avoidCollisions={true}
177
- >
178
- <div class="p-1 max-h-[300px] overflow-y-auto">
179
- {#each options as item}
180
- {#if isGroup(item)}
181
- <SelectPrimitive.Group>
182
- <div class="py-1.5 pl-8 pr-2 text-sm font-semibold">
183
- {item.label}
184
- </div>
185
- {#each item.options as option}
186
- <SelectPrimitive.Item
187
- value={option.value}
188
- label={option.label}
189
- disabled={option.disabled}
190
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 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"
191
- >
192
- {#if option.value === value}
193
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
194
- <svg
195
- xmlns="http://www.w3.org/2000/svg"
196
- width="16"
197
- height="16"
198
- viewBox="0 0 24 24"
199
- fill="none"
200
- stroke="currentColor"
201
- stroke-width="2"
202
- stroke-linecap="round"
203
- stroke-linejoin="round"
204
- class="h-4 w-4"
205
- >
206
- <polyline points="20 6 9 17 4 12" />
207
- </svg>
208
- </span>
209
- {/if}
210
- {option.label}
211
- </SelectPrimitive.Item>
212
- {/each}
213
- </SelectPrimitive.Group>
214
- {:else}
215
- <SelectPrimitive.Item
216
- value={item.value}
217
- label={item.label}
218
- disabled={item.disabled}
219
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 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"
220
- >
221
- {#if item.value === value}
222
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
223
- <svg
224
- xmlns="http://www.w3.org/2000/svg"
225
- width="16"
226
- height="16"
227
- viewBox="0 0 24 24"
228
- fill="none"
229
- stroke="currentColor"
230
- stroke-width="2"
231
- stroke-linecap="round"
232
- stroke-linejoin="round"
233
- class="h-4 w-4"
234
- >
235
- <polyline points="20 6 9 17 4 12" />
236
- </svg>
237
- </span>
238
- {/if}
239
- {item.label}
240
- </SelectPrimitive.Item>
241
- {/if}
242
- {/each}
243
- </div>
244
- </SelectPrimitive.Content>
245
- </SelectPrimitive.Root>
252
+ {@render selectRoot(!label ? placeholder : undefined)}
246
253
 
247
254
  {#if hint && !error}
248
255
  <p id={hintId} class="text-sm text-muted-foreground">
@@ -257,117 +264,5 @@
257
264
  {/if}
258
265
  </div>
259
266
  {:else}
260
- <SelectPrimitive.Root
261
- type="single"
262
- {name}
263
- {disabled}
264
- {required}
265
- {value}
266
- items={flatItems}
267
- onValueChange={handleValueChange}
268
- >
269
- <SelectPrimitive.Trigger
270
- id={componentId}
271
- class={cn(
272
- '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 placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
273
- error && 'border-destructive focus:ring-destructive',
274
- className
275
- )}
276
- aria-label={placeholder}
277
- aria-invalid={error ? 'true' : undefined}
278
- aria-describedby={describedBy}
279
- >
280
- <span class={value ? '' : 'text-muted-foreground'}>
281
- {selectedLabel || placeholder}
282
- </span>
283
- <svg
284
- xmlns="http://www.w3.org/2000/svg"
285
- width="24"
286
- height="24"
287
- viewBox="0 0 24 24"
288
- fill="none"
289
- stroke="currentColor"
290
- stroke-width="2"
291
- stroke-linecap="round"
292
- stroke-linejoin="round"
293
- class="h-4 w-4 opacity-50"
294
- >
295
- <path d="m6 9 6 6 6-6" />
296
- </svg>
297
- </SelectPrimitive.Trigger>
298
-
299
- <SelectPrimitive.Content
300
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border border-black bg-popover text-popover-foreground shadow-md data-[side=bottom]:border-t-0 data-[side=bottom]:rounded-t-none data-[side=top]:border-b-0 data-[side=top]:rounded-b-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
301
- sideOffset={-1}
302
- collisionPadding={8}
303
- avoidCollisions={true}
304
- >
305
- <div class="p-1 max-h-[300px] overflow-y-auto">
306
- {#each options as item}
307
- {#if isGroup(item)}
308
- <SelectPrimitive.Group>
309
- <div class="py-1.5 pl-8 pr-2 text-sm font-semibold">
310
- {item.label}
311
- </div>
312
- {#each item.options as option}
313
- <SelectPrimitive.Item
314
- value={option.value}
315
- label={option.label}
316
- disabled={option.disabled}
317
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 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"
318
- >
319
- {#if option.value === value}
320
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
321
- <svg
322
- xmlns="http://www.w3.org/2000/svg"
323
- width="16"
324
- height="16"
325
- viewBox="0 0 24 24"
326
- fill="none"
327
- stroke="currentColor"
328
- stroke-width="2"
329
- stroke-linecap="round"
330
- stroke-linejoin="round"
331
- class="h-4 w-4"
332
- >
333
- <polyline points="20 6 9 17 4 12" />
334
- </svg>
335
- </span>
336
- {/if}
337
- {option.label}
338
- </SelectPrimitive.Item>
339
- {/each}
340
- </SelectPrimitive.Group>
341
- {:else}
342
- <SelectPrimitive.Item
343
- value={item.value}
344
- label={item.label}
345
- disabled={item.disabled}
346
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 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"
347
- >
348
- {#if item.value === value}
349
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
350
- <svg
351
- xmlns="http://www.w3.org/2000/svg"
352
- width="16"
353
- height="16"
354
- viewBox="0 0 24 24"
355
- fill="none"
356
- stroke="currentColor"
357
- stroke-width="2"
358
- stroke-linecap="round"
359
- stroke-linejoin="round"
360
- class="h-4 w-4"
361
- >
362
- <polyline points="20 6 9 17 4 12" />
363
- </svg>
364
- </span>
365
- {/if}
366
- {item.label}
367
- </SelectPrimitive.Item>
368
- {/if}
369
- {/each}
370
- </div>
371
- </SelectPrimitive.Content>
372
- </SelectPrimitive.Root>
267
+ {@render selectRoot(placeholder)}
373
268
  {/if}
@@ -21,7 +21,7 @@
21
21
  {orientation}
22
22
  {decorative}
23
23
  class={cn(
24
- 'shrink-0 bg-black',
24
+ 'shrink-0 bg-border',
25
25
  orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
26
26
  className
27
27
  )}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import { cn } from '../utils.js';
3
+ import { cn, useId } from '../utils.js';
4
4
  import { tv, type VariantProps } from 'tailwind-variants';
5
5
  import type {
6
6
  SignatureData,
@@ -841,8 +841,9 @@
841
841
  }
842
842
  });
843
843
 
844
- // Computed IDs
845
- const inputId = $derived(id || `signature-${Math.random().toString(36).substring(2, 9)}`);
844
+ // Computed IDs (using stable sequential ID generation)
845
+ const generatedInputId = useId('signature');
846
+ const inputId = $derived(id || generatedInputId);
846
847
  const errorId = $derived(`${inputId}-error`);
847
848
  const hintId = $derived(`${inputId}-hint`);
848
849
  const ariaDescribedBy = $derived(
@@ -11,7 +11,6 @@
11
11
  id?: string;
12
12
  class?: string;
13
13
  onchange?: (checked: boolean) => void;
14
- [key: string]: unknown;
15
14
  }
16
15
 
17
16
  let {
@@ -26,36 +25,24 @@
26
25
  ...restProps
27
26
  }: Props = $props();
28
27
 
29
- // Track if change was from user interaction (click)
30
- let isUserInteraction = false;
31
-
32
- function handleClick() {
33
- isUserInteraction = true;
34
- }
35
-
28
+ // Handle checked state changes from user interaction
29
+ // The primitive handles the actual state management via bind:checked
36
30
  function handleCheckedChange(newChecked: boolean) {
37
- // Guard against no-op mutations AND non-user-initiated changes
38
- // This prevents prop -> onCheckedChange -> checked = -> ... loops
39
- if (!isUserInteraction || newChecked === checked) {
40
- isUserInteraction = false;
41
- return;
31
+ // Only fire callback if value actually changed (prevents unnecessary calls)
32
+ if (newChecked !== checked) {
33
+ checked = newChecked;
34
+ onchange?.(newChecked);
42
35
  }
43
-
44
- // Only update state and notify if genuine user-initiated change
45
- checked = newChecked;
46
- onchange?.(newChecked);
47
- isUserInteraction = false;
48
36
  }
49
37
  </script>
50
38
 
51
39
  <SwitchPrimitive.Root
52
- bind:checked
40
+ {checked}
53
41
  {disabled}
54
42
  {required}
55
43
  {name}
56
44
  {value}
57
45
  {id}
58
- onclick={handleClick}
59
46
  onCheckedChange={handleCheckedChange}
60
47
  class={cn(
61
48
  'peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
@@ -7,7 +7,6 @@ interface Props {
7
7
  id?: string;
8
8
  class?: string;
9
9
  onchange?: (checked: boolean) => void;
10
- [key: string]: unknown;
11
10
  }
12
11
  declare const Switch: import("svelte").Component<Props, {}, "checked">;
13
12
  type Switch = ReturnType<typeof Switch>;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { setContext } from 'svelte';
4
- import { cn } from '../utils.js';
4
+ import { cn, useId } from '../utils.js';
5
5
  import type { Tab } from '../types/components.js';
6
6
 
7
7
  interface Props {
@@ -19,10 +19,12 @@
19
19
  onValueChange?: (value: string) => void;
20
20
  }
21
21
 
22
+ const generatedId = useId('tabs');
23
+
22
24
  let {
23
25
  tabs,
24
26
  value = $bindable(tabs[0]?.id ?? ''),
25
- id = `tabs-${Math.random().toString(36).slice(2, 11)}`,
27
+ id = generatedId,
26
28
  class: className,
27
29
  children,
28
30
  onValueChange,
@@ -6,13 +6,14 @@
6
6
  * - Centered title and description header
7
7
  * - Optional notice cards section
8
8
  * - Two-column layout with optional sidebar
9
- * - Help text footer
9
+ * - Help text footer (supports safe HTML - sanitized automatically)
10
10
  * - Responsive design (sidebar above main on mobile)
11
11
  * - Transparent background by default (inherits from parent layout)
12
12
  * - Optional sage background via sageBackground prop
13
13
  */
14
14
  import type { Snippet } from 'svelte';
15
15
  import { cn } from '../../utils.js';
16
+ import { sanitizeHtml } from '../../notices/sanitize.js';
16
17
  import PageHeader from '../PageHeader.svelte';
17
18
 
18
19
  interface Props {
@@ -82,7 +83,7 @@
82
83
  {#if helpText}
83
84
  <footer class="mt-8 text-center">
84
85
  <p class="text-sm text-gray-500">
85
- {@html helpText}
86
+ {@html sanitizeHtml(helpText)}
86
87
  </p>
87
88
  </footer>
88
89
  {/if}
@@ -5,7 +5,7 @@
5
5
  * - Centered title and description header
6
6
  * - Optional notice cards section
7
7
  * - Two-column layout with optional sidebar
8
- * - Help text footer
8
+ * - Help text footer (supports safe HTML - sanitized automatically)
9
9
  * - Responsive design (sidebar above main on mobile)
10
10
  * - Transparent background by default (inherits from parent layout)
11
11
  * - Optional sage background via sageBackground prop
@@ -4,3 +4,11 @@ import { type ClassValue } from 'clsx';
4
4
  * Uses clsx for conditional classes and tailwind-merge to handle conflicts
5
5
  */
6
6
  export declare function cn(...inputs: ClassValue[]): string;
7
+ /**
8
+ * Generate a unique ID with an optional prefix.
9
+ * IDs are sequential and stable, preventing SSR hydration mismatches.
10
+ *
11
+ * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
12
+ * @returns A unique ID string like 'checkbox-1' or 'ct-1' (ct = classic-theme)
13
+ */
14
+ export declare function useId(prefix?: string): string;
package/dist/lib/utils.js CHANGED
@@ -7,3 +7,22 @@ import { twMerge } from 'tailwind-merge';
7
7
  export function cn(...inputs) {
8
8
  return twMerge(clsx(inputs));
9
9
  }
10
+ /**
11
+ * Counter for generating unique sequential IDs.
12
+ * Uses a sequential counter instead of Math.random() to ensure:
13
+ * - Deterministic IDs that match between SSR and hydration
14
+ * - Stable IDs across component re-renders
15
+ * - Accessibility-friendly ID associations
16
+ */
17
+ let idCounter = 0;
18
+ /**
19
+ * Generate a unique ID with an optional prefix.
20
+ * IDs are sequential and stable, preventing SSR hydration mismatches.
21
+ *
22
+ * @param prefix - Optional prefix for the ID (e.g., 'checkbox', 'tabs')
23
+ * @returns A unique ID string like 'checkbox-1' or 'ct-1' (ct = classic-theme)
24
+ */
25
+ export function useId(prefix) {
26
+ const id = ++idCounter;
27
+ return prefix ? `${prefix}-${id}` : `ct-${id}`;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",
@@ -18,6 +18,9 @@
18
18
  },
19
19
  "./styles.css": "./dist/styles.css"
20
20
  },
21
+ "sideEffects": [
22
+ "*.css"
23
+ ],
21
24
  "files": [
22
25
  "dist"
23
26
  ],