@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 ComboboxOption {
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 combobox */
34
+ label?: string;
35
+ /** Hint text displayed below the combobox */
36
+ hint?: string;
31
37
  /** Error message to display */
32
38
  error?: string;
33
39
  /** Whether async data is loading */
@@ -50,6 +56,8 @@
50
56
  required = false,
51
57
  name,
52
58
  id,
59
+ label,
60
+ hint,
53
61
  error,
54
62
  loading = false,
55
63
  onSearch,
@@ -57,6 +65,15 @@
57
65
  class: className,
58
66
  }: Props = $props();
59
67
 
68
+ // Generate unique IDs for accessibility
69
+ const randomSuffix = generateId('combobox').split('-').pop();
70
+ const componentId = $derived(id || `combobox-${randomSuffix}`);
71
+ const hintId = $derived(`${componentId}-hint`);
72
+ const errorId = $derived(`${componentId}-error`);
73
+
74
+ // Compute aria-describedby based on hint and error
75
+ const describedBy = $derived(computeDescribedBy(hintId, errorId, !!hint, !!error));
76
+
60
77
  let searchQuery = $state('');
61
78
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
62
79
  let open = $state(false);
@@ -104,86 +121,187 @@
104
121
  if (debounceTimer) clearTimeout(debounceTimer);
105
122
  };
106
123
  });
124
+
125
+ // Determine if we need a container wrapper
126
+ const hasWrapper = $derived(!!label || !!hint || !!error);
107
127
  </script>
108
128
 
109
- <ComboboxPrimitive.Root
110
- type="single"
111
- {disabled}
112
- {required}
113
- {name}
114
- bind:open
115
- onOpenChange={handleOpenChange}
116
- onValueChange={handleValueChange}
117
- {value}
118
- >
119
- <div class="relative">
120
- <ComboboxPrimitive.Input
121
- {id}
122
- placeholder={selectedLabel || placeholder}
123
- oninput={handleSearchInput}
124
- onfocus={() => (open = true)}
125
- onclick={() => (open = true)}
126
- class={cn(
127
- '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',
128
- error && 'border-destructive focus:ring-destructive',
129
- className
130
- )}
131
- aria-invalid={error ? 'true' : undefined}
132
- />
133
- </div>
129
+ {#if hasWrapper}
130
+ <div class="space-y-2">
131
+ {#if label}
132
+ <Label for={componentId} {disabled}>
133
+ {label}
134
+ {#if required}
135
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
136
+ <span class="sr-only">(required)</span>
137
+ {/if}
138
+ </Label>
139
+ {/if}
134
140
 
135
- <ComboboxPrimitive.Content
136
- 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"
137
- sideOffset={-1}
138
- collisionPadding={8}
139
- avoidCollisions={true}
140
- >
141
- <div class="p-1 max-h-[300px] overflow-y-auto">
142
- {#if loading}
143
- <div class="flex items-center justify-center py-6">
144
- <Spinner size="sm" />
145
- <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
146
- </div>
147
- {:else if filteredOptions.length === 0}
148
- <div class="py-6 text-center text-sm text-muted-foreground">
149
- {emptyMessage}
141
+ <ComboboxPrimitive.Root
142
+ type="single"
143
+ {disabled}
144
+ {required}
145
+ {name}
146
+ bind:open
147
+ onOpenChange={handleOpenChange}
148
+ onValueChange={handleValueChange}
149
+ {value}
150
+ >
151
+ <div class="relative">
152
+ <ComboboxPrimitive.Input
153
+ id={componentId}
154
+ placeholder={selectedLabel || placeholder}
155
+ oninput={handleSearchInput}
156
+ onfocus={() => (open = true)}
157
+ onclick={() => (open = true)}
158
+ class={cn(
159
+ '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',
160
+ error && 'border-destructive focus:ring-destructive',
161
+ className
162
+ )}
163
+ aria-invalid={error ? 'true' : undefined}
164
+ aria-describedby={describedBy}
165
+ />
166
+ </div>
167
+
168
+ <ComboboxPrimitive.Content
169
+ 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"
170
+ sideOffset={-1}
171
+ collisionPadding={8}
172
+ avoidCollisions={true}
173
+ >
174
+ <div class="p-1 max-h-[300px] overflow-y-auto">
175
+ {#if loading}
176
+ <div class="flex items-center justify-center py-6">
177
+ <Spinner size="sm" />
178
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
179
+ </div>
180
+ {:else if filteredOptions.length === 0}
181
+ <div class="py-6 text-center text-sm text-muted-foreground">
182
+ {emptyMessage}
183
+ </div>
184
+ {:else}
185
+ {#each filteredOptions as option}
186
+ <ComboboxPrimitive.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
+ </ComboboxPrimitive.Item>
212
+ {/each}
213
+ {/if}
150
214
  </div>
151
- {:else}
152
- {#each filteredOptions as option}
153
- <ComboboxPrimitive.Item
154
- value={option.value}
155
- label={option.label}
156
- disabled={option.disabled}
157
- 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"
158
- >
159
- {#if option.value === value}
160
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
161
- <svg
162
- xmlns="http://www.w3.org/2000/svg"
163
- width="16"
164
- height="16"
165
- viewBox="0 0 24 24"
166
- fill="none"
167
- stroke="currentColor"
168
- stroke-width="2"
169
- stroke-linecap="round"
170
- stroke-linejoin="round"
171
- class="h-4 w-4"
172
- >
173
- <polyline points="20 6 9 17 4 12" />
174
- </svg>
175
- </span>
176
- {/if}
177
- {option.label}
178
- </ComboboxPrimitive.Item>
179
- {/each}
180
- {/if}
215
+ </ComboboxPrimitive.Content>
216
+ </ComboboxPrimitive.Root>
217
+
218
+ {#if hint && !error}
219
+ <p id={hintId} class="text-sm text-muted-foreground">
220
+ {hint}
221
+ </p>
222
+ {/if}
223
+
224
+ {#if error}
225
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
226
+ {error}
227
+ </p>
228
+ {/if}
229
+ </div>
230
+ {:else}
231
+ <ComboboxPrimitive.Root
232
+ type="single"
233
+ {disabled}
234
+ {required}
235
+ {name}
236
+ bind:open
237
+ onOpenChange={handleOpenChange}
238
+ onValueChange={handleValueChange}
239
+ {value}
240
+ >
241
+ <div class="relative">
242
+ <ComboboxPrimitive.Input
243
+ id={componentId}
244
+ placeholder={selectedLabel || placeholder}
245
+ oninput={handleSearchInput}
246
+ onfocus={() => (open = true)}
247
+ onclick={() => (open = true)}
248
+ class={cn(
249
+ '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',
250
+ error && 'border-destructive focus:ring-destructive',
251
+ className
252
+ )}
253
+ aria-invalid={error ? 'true' : undefined}
254
+ aria-describedby={describedBy}
255
+ />
181
256
  </div>
182
- </ComboboxPrimitive.Content>
183
- </ComboboxPrimitive.Root>
184
257
 
185
- {#if error}
186
- <p class="text-sm text-destructive mt-1.5" role="alert">
187
- {error}
188
- </p>
258
+ <ComboboxPrimitive.Content
259
+ 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"
260
+ sideOffset={-1}
261
+ collisionPadding={8}
262
+ avoidCollisions={true}
263
+ >
264
+ <div class="p-1 max-h-[300px] overflow-y-auto">
265
+ {#if loading}
266
+ <div class="flex items-center justify-center py-6">
267
+ <Spinner size="sm" />
268
+ <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
269
+ </div>
270
+ {:else if filteredOptions.length === 0}
271
+ <div class="py-6 text-center text-sm text-muted-foreground">
272
+ {emptyMessage}
273
+ </div>
274
+ {:else}
275
+ {#each filteredOptions as option}
276
+ <ComboboxPrimitive.Item
277
+ value={option.value}
278
+ label={option.label}
279
+ disabled={option.disabled}
280
+ 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"
281
+ >
282
+ {#if option.value === value}
283
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
284
+ <svg
285
+ xmlns="http://www.w3.org/2000/svg"
286
+ width="16"
287
+ height="16"
288
+ viewBox="0 0 24 24"
289
+ fill="none"
290
+ stroke="currentColor"
291
+ stroke-width="2"
292
+ stroke-linecap="round"
293
+ stroke-linejoin="round"
294
+ class="h-4 w-4"
295
+ >
296
+ <polyline points="20 6 9 17 4 12" />
297
+ </svg>
298
+ </span>
299
+ {/if}
300
+ {option.label}
301
+ </ComboboxPrimitive.Item>
302
+ {/each}
303
+ {/if}
304
+ </div>
305
+ </ComboboxPrimitive.Content>
306
+ </ComboboxPrimitive.Root>
189
307
  {/if}
@@ -22,6 +22,10 @@ interface Props {
22
22
  name?: string;
23
23
  /** Element ID */
24
24
  id?: string;
25
+ /** Label text for the combobox */
26
+ label?: string;
27
+ /** Hint text displayed below the combobox */
28
+ hint?: string;
25
29
  /** Error message to display */
26
30
  error?: string;
27
31
  /** Whether async data is loading */