@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,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
+ import { generateId, computeDescribedBy } from '../utils/form.js';
4
5
  import Spinner from './Spinner.svelte';
6
+ import Label from './Label.svelte';
5
7
 
6
8
  export interface MultiSelectOption {
7
9
  value: string;
@@ -28,6 +30,10 @@
28
30
  name?: string;
29
31
  /** Element ID */
30
32
  id?: string;
33
+ /** Label text for the multiselect */
34
+ label?: string;
35
+ /** Hint text displayed below the multiselect */
36
+ hint?: string;
31
37
  /** Error message to display */
32
38
  error?: string;
33
39
  /** Maximum number of selections allowed */
@@ -52,6 +58,8 @@
52
58
  required = false,
53
59
  name,
54
60
  id,
61
+ label,
62
+ hint,
55
63
  error,
56
64
  max,
57
65
  loading = false,
@@ -60,6 +68,15 @@
60
68
  class: className,
61
69
  }: Props = $props();
62
70
 
71
+ // Generate unique IDs for accessibility
72
+ const randomSuffix = generateId('multiselect').split('-').pop();
73
+ const componentId = $derived(id || `multiselect-${randomSuffix}`);
74
+ const hintId = $derived(`${componentId}-hint`);
75
+ const errorId = $derived(`${componentId}-error`);
76
+
77
+ // Compute aria-describedby based on hint and error
78
+ const describedBy = $derived(computeDescribedBy(hintId, errorId, !!hint, !!error));
79
+
63
80
  let searchQuery = $state('');
64
81
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
65
82
  let open = $state(false);
@@ -118,129 +135,273 @@
118
135
  if (debounceTimer) clearTimeout(debounceTimer);
119
136
  };
120
137
  });
138
+
139
+ // Determine if we need a container wrapper
140
+ const hasWrapper = $derived(!!label || !!hint || !!error);
121
141
  </script>
122
142
 
123
- <div class={cn('w-full', className)}>
124
- <!-- Selected tags -->
125
- {#if selectedLabels.length > 0}
126
- <div class="flex flex-wrap gap-1 mb-2">
127
- {#each value as selectedValue}
128
- {@const label = options.find((o) => o.value === selectedValue)?.label}
129
- {#if label}
130
- <span
131
- class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
132
- >
133
- {label}
134
- <button
135
- type="button"
136
- onclick={() => removeValue(selectedValue)}
137
- class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
138
- {disabled}
139
- aria-label={`Remove ${label}`}
140
- >
141
- <svg
142
- xmlns="http://www.w3.org/2000/svg"
143
- width="14"
144
- height="14"
145
- viewBox="0 0 24 24"
146
- fill="none"
147
- stroke="currentColor"
148
- stroke-width="2"
149
- stroke-linecap="round"
150
- stroke-linejoin="round"
151
- >
152
- <path d="M18 6 6 18" />
153
- <path d="m6 6 12 12" />
154
- </svg>
155
- </button>
156
- </span>
143
+ {#if hasWrapper}
144
+ <div class="space-y-2">
145
+ {#if label}
146
+ <Label for={componentId} {disabled}>
147
+ {label}
148
+ {#if required}
149
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
150
+ <span class="sr-only">(required)</span>
157
151
  {/if}
158
- {/each}
159
- </div>
160
- {/if}
161
-
162
- <ComboboxPrimitive.Root
163
- type="multiple"
164
- {disabled}
165
- {required}
166
- {name}
167
- bind:open
168
- onOpenChange={handleOpenChange}
169
- onValueChange={handleValueChange}
170
- {value}
171
- >
172
- <div class="relative">
173
- <ComboboxPrimitive.Input
174
- {id}
175
- placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
176
- oninput={handleSearchInput}
177
- onfocus={() => (open = true)}
178
- onclick={() => (open = true)}
179
- class={cn(
180
- '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',
181
- error && 'border-destructive focus:ring-destructive'
182
- )}
183
- aria-invalid={error ? 'true' : undefined}
184
- />
185
- </div>
152
+ </Label>
153
+ {/if}
186
154
 
187
- <ComboboxPrimitive.Content
188
- class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border 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"
189
- sideOffset={-1}
190
- collisionPadding={8}
191
- avoidCollisions={true}
192
- >
193
- <div class="p-1 max-h-[300px] overflow-y-auto">
194
- {#if loading}
195
- <div class="flex items-center justify-center py-6">
196
- <Spinner size="sm" />
197
- <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
198
- </div>
199
- {:else if filteredOptions.length === 0}
200
- <div class="py-6 text-center text-sm text-muted-foreground">
201
- {emptyMessage}
202
- </div>
203
- {:else}
204
- {#each filteredOptions as option}
205
- {@const isSelected = value.includes(option.value)}
206
- {@const isDisabledByMax = maxReached && !isSelected}
207
- <ComboboxPrimitive.Item
208
- value={option.value}
209
- label={option.label}
210
- disabled={option.disabled || isDisabledByMax}
211
- 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"
212
- >
213
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
214
- {#if isSelected}
155
+ <div class={cn('w-full', className)}>
156
+ <!-- Selected tags -->
157
+ {#if selectedLabels.length > 0}
158
+ <div class="flex flex-wrap gap-1 mb-2">
159
+ {#each value as selectedValue}
160
+ {@const labelText = options.find((o) => o.value === selectedValue)?.label}
161
+ {#if labelText}
162
+ <span
163
+ class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
164
+ >
165
+ {labelText}
166
+ <button
167
+ type="button"
168
+ onclick={() => removeValue(selectedValue)}
169
+ class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
170
+ {disabled}
171
+ aria-label={`Remove ${labelText}`}
172
+ >
215
173
  <svg
216
174
  xmlns="http://www.w3.org/2000/svg"
217
- width="16"
218
- height="16"
175
+ width="14"
176
+ height="14"
219
177
  viewBox="0 0 24 24"
220
178
  fill="none"
221
179
  stroke="currentColor"
222
180
  stroke-width="2"
223
181
  stroke-linecap="round"
224
182
  stroke-linejoin="round"
225
- class="h-4 w-4"
226
183
  >
227
- <polyline points="20 6 9 17 4 12" />
184
+ <path d="M18 6 6 18" />
185
+ <path d="m6 6 12 12" />
228
186
  </svg>
229
- {:else}
230
- <span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
231
- {/if}
187
+ </button>
232
188
  </span>
233
- {option.label}
234
- </ComboboxPrimitive.Item>
189
+ {/if}
235
190
  {/each}
236
- {/if}
191
+ </div>
192
+ {/if}
193
+
194
+ <ComboboxPrimitive.Root
195
+ type="multiple"
196
+ {disabled}
197
+ {required}
198
+ {name}
199
+ bind:open
200
+ onOpenChange={handleOpenChange}
201
+ onValueChange={handleValueChange}
202
+ {value}
203
+ >
204
+ <div class="relative">
205
+ <ComboboxPrimitive.Input
206
+ id={componentId}
207
+ placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
208
+ oninput={handleSearchInput}
209
+ onfocus={() => (open = true)}
210
+ onclick={() => (open = true)}
211
+ class={cn(
212
+ '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',
213
+ error && 'border-destructive focus:ring-destructive'
214
+ )}
215
+ aria-invalid={error ? 'true' : undefined}
216
+ aria-describedby={describedBy}
217
+ />
218
+ </div>
219
+
220
+ <ComboboxPrimitive.Content
221
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border 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"
222
+ sideOffset={-1}
223
+ collisionPadding={8}
224
+ avoidCollisions={true}
225
+ >
226
+ <div class="p-1 max-h-[300px] overflow-y-auto">
227
+ {#if loading}
228
+ <div class="flex items-center justify-center py-6">
229
+ <Spinner size="sm" />
230
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
231
+ </div>
232
+ {:else if filteredOptions.length === 0}
233
+ <div class="py-6 text-center text-sm text-muted-foreground">
234
+ {emptyMessage}
235
+ </div>
236
+ {:else}
237
+ {#each filteredOptions as option}
238
+ {@const isSelected = value.includes(option.value)}
239
+ {@const isDisabledByMax = maxReached && !isSelected}
240
+ <ComboboxPrimitive.Item
241
+ value={option.value}
242
+ label={option.label}
243
+ disabled={option.disabled || isDisabledByMax}
244
+ 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"
245
+ >
246
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
247
+ {#if isSelected}
248
+ <svg
249
+ xmlns="http://www.w3.org/2000/svg"
250
+ width="16"
251
+ height="16"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ stroke-width="2"
256
+ stroke-linecap="round"
257
+ stroke-linejoin="round"
258
+ class="h-4 w-4"
259
+ >
260
+ <polyline points="20 6 9 17 4 12" />
261
+ </svg>
262
+ {:else}
263
+ <span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
264
+ {/if}
265
+ </span>
266
+ {option.label}
267
+ </ComboboxPrimitive.Item>
268
+ {/each}
269
+ {/if}
270
+ </div>
271
+ </ComboboxPrimitive.Content>
272
+ </ComboboxPrimitive.Root>
273
+ </div>
274
+
275
+ {#if hint && !error}
276
+ <p id={hintId} class="text-sm text-muted-foreground">
277
+ {hint}
278
+ </p>
279
+ {/if}
280
+
281
+ {#if error}
282
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
283
+ {error}
284
+ </p>
285
+ {/if}
286
+ </div>
287
+ {:else}
288
+ <div class={cn('w-full', className)}>
289
+ <!-- Selected tags -->
290
+ {#if selectedLabels.length > 0}
291
+ <div class="flex flex-wrap gap-1 mb-2">
292
+ {#each value as selectedValue}
293
+ {@const labelText = options.find((o) => o.value === selectedValue)?.label}
294
+ {#if labelText}
295
+ <span
296
+ class="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground"
297
+ >
298
+ {labelText}
299
+ <button
300
+ type="button"
301
+ onclick={() => removeValue(selectedValue)}
302
+ class="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
303
+ {disabled}
304
+ aria-label={`Remove ${labelText}`}
305
+ >
306
+ <svg
307
+ xmlns="http://www.w3.org/2000/svg"
308
+ width="14"
309
+ height="14"
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="M18 6 6 18" />
318
+ <path d="m6 6 12 12" />
319
+ </svg>
320
+ </button>
321
+ </span>
322
+ {/if}
323
+ {/each}
324
+ </div>
325
+ {/if}
326
+
327
+ <ComboboxPrimitive.Root
328
+ type="multiple"
329
+ {disabled}
330
+ {required}
331
+ {name}
332
+ bind:open
333
+ onOpenChange={handleOpenChange}
334
+ onValueChange={handleValueChange}
335
+ {value}
336
+ >
337
+ <div class="relative">
338
+ <ComboboxPrimitive.Input
339
+ id={componentId}
340
+ placeholder={value.length > 0 ? `${value.length} selected` : placeholder}
341
+ oninput={handleSearchInput}
342
+ onfocus={() => (open = true)}
343
+ onclick={() => (open = true)}
344
+ class={cn(
345
+ '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',
346
+ error && 'border-destructive focus:ring-destructive'
347
+ )}
348
+ aria-invalid={error ? 'true' : undefined}
349
+ aria-describedby={describedBy}
350
+ />
237
351
  </div>
238
- </ComboboxPrimitive.Content>
239
- </ComboboxPrimitive.Root>
240
- </div>
241
-
242
- {#if error}
243
- <p class="text-sm text-destructive mt-1.5" role="alert">
244
- {error}
245
- </p>
352
+
353
+ <ComboboxPrimitive.Content
354
+ class="isolate relative z-50 min-w-[var(--bits-floating-anchor-width)] overflow-hidden rounded-md border 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"
355
+ sideOffset={-1}
356
+ collisionPadding={8}
357
+ avoidCollisions={true}
358
+ >
359
+ <div class="p-1 max-h-[300px] overflow-y-auto">
360
+ {#if loading}
361
+ <div class="flex items-center justify-center py-6">
362
+ <Spinner size="sm" />
363
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
364
+ </div>
365
+ {:else if filteredOptions.length === 0}
366
+ <div class="py-6 text-center text-sm text-muted-foreground">
367
+ {emptyMessage}
368
+ </div>
369
+ {:else}
370
+ {#each filteredOptions as option}
371
+ {@const isSelected = value.includes(option.value)}
372
+ {@const isDisabledByMax = maxReached && !isSelected}
373
+ <ComboboxPrimitive.Item
374
+ value={option.value}
375
+ label={option.label}
376
+ disabled={option.disabled || isDisabledByMax}
377
+ 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"
378
+ >
379
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
380
+ {#if isSelected}
381
+ <svg
382
+ xmlns="http://www.w3.org/2000/svg"
383
+ width="16"
384
+ height="16"
385
+ viewBox="0 0 24 24"
386
+ fill="none"
387
+ stroke="currentColor"
388
+ stroke-width="2"
389
+ stroke-linecap="round"
390
+ stroke-linejoin="round"
391
+ class="h-4 w-4"
392
+ >
393
+ <polyline points="20 6 9 17 4 12" />
394
+ </svg>
395
+ {:else}
396
+ <span class="h-3.5 w-3.5 rounded-sm border border-primary"></span>
397
+ {/if}
398
+ </span>
399
+ {option.label}
400
+ </ComboboxPrimitive.Item>
401
+ {/each}
402
+ {/if}
403
+ </div>
404
+ </ComboboxPrimitive.Content>
405
+ </ComboboxPrimitive.Root>
406
+ </div>
246
407
  {/if}
@@ -22,6 +22,10 @@ interface Props {
22
22
  name?: string;
23
23
  /** Element ID */
24
24
  id?: string;
25
+ /** Label text for the multiselect */
26
+ label?: string;
27
+ /** Hint text displayed below the multiselect */
28
+ hint?: string;
25
29
  /** Error message to display */
26
30
  error?: string;
27
31
  /** Maximum number of selections allowed */