@classic-homes/theme-svelte 1.27.1 → 1.29.0

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,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { Combobox as ComboboxPrimitive } from 'bits-ui';
3
+ import { DEV } from 'esm-env';
3
4
  import { cn } from '../utils.js';
5
+ import type { SelectGroup } from '../types/components.js';
4
6
  import { computeDescribedBy } from '../utils/form.js';
5
7
  import Spinner from './Spinner.svelte';
6
8
  import Label from './Label.svelte';
@@ -17,8 +19,8 @@
17
19
  value?: string;
18
20
  /** Callback when value changes */
19
21
  onValueChange?: (value: string) => void;
20
- /** Array of selectable options */
21
- options: ComboboxOption[];
22
+ /** Array of options or grouped options */
23
+ options: (ComboboxOption | SelectGroup)[];
22
24
  /** Placeholder text for the input */
23
25
  placeholder?: string;
24
26
  /** Message to display when no results found */
@@ -45,6 +47,10 @@
45
47
  debounceMs?: number;
46
48
  /** Additional class for the trigger */
47
49
  class?: string;
50
+ /** Accessible name for the input, when there is no visible label */
51
+ 'aria-label'?: string;
52
+ /** ID of an element that labels the input */
53
+ 'aria-labelledby'?: string;
48
54
  }
49
55
 
50
56
  let {
@@ -64,6 +70,8 @@
64
70
  onSearch,
65
71
  debounceMs = 300,
66
72
  class: className,
73
+ 'aria-label': ariaLabelProp,
74
+ 'aria-labelledby': ariaLabelledby,
67
75
  }: Props = $props();
68
76
 
69
77
  // Generate unique IDs for accessibility ($props.id() is SSR-stable)
@@ -79,16 +87,53 @@
79
87
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
80
88
  let open = $state(false);
81
89
 
82
- // Find selected option label
83
- const selectedLabel = $derived(options.find((opt) => opt.value === value)?.label);
90
+ // The placeholder is only a last-resort name: an aria-label would override
91
+ // an external <label for={id}> or aria-labelledby.
92
+ const fallbackAriaLabel = $derived(
93
+ ariaLabelProp ?? (!label && !id && !ariaLabelledby ? placeholder : undefined)
94
+ );
84
95
 
85
- // Always filter options locally based on search query
86
- const filteredOptions = $derived.by(() => {
96
+ if (DEV) {
97
+ $effect(() => {
98
+ if (!label && !id && !ariaLabelProp && !ariaLabelledby) {
99
+ console.warn(
100
+ '[Combobox] Missing accessible label. Provide a label prop or an id with a matching <label for="...">.'
101
+ );
102
+ }
103
+ });
104
+ }
105
+
106
+ function isGroup(item: ComboboxOption | SelectGroup): item is SelectGroup {
107
+ return 'options' in item;
108
+ }
109
+
110
+ // Flat list for the label lookup and for bits-ui's items
111
+ const flatItems = $derived(options.flatMap((item) => (isGroup(item) ? item.options : [item])));
112
+
113
+ const selectedLabel = $derived(flatItems.find((opt) => opt.value === value)?.label);
114
+
115
+ // Always filter options locally based on search query; groups keep only
116
+ // their matching options and drop out when none match.
117
+ const filteredOptions = $derived.by((): (ComboboxOption | SelectGroup)[] => {
87
118
  if (!searchQuery) return options;
88
119
  const query = searchQuery.toLowerCase();
89
- return options.filter((opt) => opt.label.toLowerCase().includes(query));
120
+ const matches = (opt: ComboboxOption) => opt.label.toLowerCase().includes(query);
121
+ return options.flatMap((item): (ComboboxOption | SelectGroup)[] => {
122
+ if (!isGroup(item)) return matches(item) ? [item] : [];
123
+ const groupOptions = item.options.filter(matches);
124
+ return groupOptions.length > 0 ? [{ ...item, options: groupOptions }] : [];
125
+ });
90
126
  });
91
127
 
128
+ const filteredCount = $derived(
129
+ filteredOptions.reduce((n: number, item) => n + (isGroup(item) ? item.options.length : 1), 0)
130
+ );
131
+
132
+ // Closed, the input shows the selected label; open, it holds what the user
133
+ // types, with the selected label as the placeholder so the full list shows.
134
+ const inputValue = $derived(open ? searchQuery : (selectedLabel ?? ''));
135
+ const inputPlaceholder = $derived(open ? selectedLabel || placeholder : placeholder);
136
+
92
137
  function handleSearchInput(e: Event) {
93
138
  const target = e.target as HTMLInputElement;
94
139
  searchQuery = target.value;
@@ -127,12 +172,41 @@
127
172
  const resultsAnnouncement = $derived.by(() => {
128
173
  if (loading) return 'Loading options...';
129
174
  if (!open) return '';
130
- if (filteredOptions.length === 0) return emptyMessage;
131
- const count = filteredOptions.length;
175
+ if (filteredCount === 0) return emptyMessage;
176
+ const count = filteredCount;
132
177
  return `${count} ${count === 1 ? 'option' : 'options'} available`;
133
178
  });
134
179
  </script>
135
180
 
181
+ {#snippet comboboxItem(option: ComboboxOption)}
182
+ <ComboboxPrimitive.Item
183
+ value={option.value}
184
+ label={option.label}
185
+ disabled={option.disabled}
186
+ class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
187
+ >
188
+ {#if option.value === value}
189
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
190
+ <svg
191
+ xmlns="http://www.w3.org/2000/svg"
192
+ width="16"
193
+ height="16"
194
+ viewBox="0 0 24 24"
195
+ fill="none"
196
+ stroke="currentColor"
197
+ stroke-width="2"
198
+ stroke-linecap="round"
199
+ stroke-linejoin="round"
200
+ class="h-4 w-4"
201
+ >
202
+ <polyline points="20 6 9 17 4 12" />
203
+ </svg>
204
+ </span>
205
+ {/if}
206
+ {option.label}
207
+ </ComboboxPrimitive.Item>
208
+ {/snippet}
209
+
136
210
  <!-- One structure whether or not there is a label, hint or error, so the
137
211
  focused control is never remounted when an error appears. -->
138
212
  <div class="space-y-2">
@@ -155,11 +229,12 @@
155
229
  onOpenChange={handleOpenChange}
156
230
  onValueChange={handleValueChange}
157
231
  {value}
232
+ items={flatItems}
158
233
  >
159
234
  <div class="relative">
160
235
  <ComboboxPrimitive.Input
161
236
  id={componentId}
162
- placeholder={selectedLabel || placeholder}
237
+ placeholder={inputPlaceholder}
163
238
  oninput={handleSearchInput}
164
239
  onfocus={() => (open = true)}
165
240
  onclick={() => (open = true)}
@@ -170,9 +245,15 @@
170
245
  )}
171
246
  role="combobox"
172
247
  aria-expanded={open}
248
+ aria-label={fallbackAriaLabel}
249
+ aria-labelledby={ariaLabelledby}
173
250
  aria-invalid={error ? 'true' : undefined}
174
251
  aria-describedby={describedBy}
175
- />
252
+ >
253
+ {#snippet child({ props })}
254
+ <input {...props} value={inputValue} />
255
+ {/snippet}
256
+ </ComboboxPrimitive.Input>
176
257
  </div>
177
258
 
178
259
  <!-- Portal to <body>: escapes transformed/overflow-hidden ancestors
@@ -190,38 +271,24 @@
190
271
  <Spinner size="sm" />
191
272
  <span class="ml-2 text-sm text-muted-foreground">Loading...</span>
192
273
  </div>
193
- {:else if filteredOptions.length === 0}
274
+ {:else if filteredCount === 0}
194
275
  <div class="py-6 text-center text-sm text-muted-foreground">
195
276
  {emptyMessage}
196
277
  </div>
197
278
  {:else}
198
- {#each filteredOptions as option}
199
- <ComboboxPrimitive.Item
200
- value={option.value}
201
- label={option.label}
202
- disabled={option.disabled}
203
- class="relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default"
204
- >
205
- {#if option.value === value}
206
- <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
207
- <svg
208
- xmlns="http://www.w3.org/2000/svg"
209
- width="16"
210
- height="16"
211
- viewBox="0 0 24 24"
212
- fill="none"
213
- stroke="currentColor"
214
- stroke-width="2"
215
- stroke-linecap="round"
216
- stroke-linejoin="round"
217
- class="h-4 w-4"
218
- >
219
- <polyline points="20 6 9 17 4 12" />
220
- </svg>
221
- </span>
222
- {/if}
223
- {option.label}
224
- </ComboboxPrimitive.Item>
279
+ {#each filteredOptions as item}
280
+ {#if isGroup(item)}
281
+ <ComboboxPrimitive.Group>
282
+ <ComboboxPrimitive.GroupHeading class="py-1.5 pl-8 pr-2 text-sm font-semibold">
283
+ {item.label}
284
+ </ComboboxPrimitive.GroupHeading>
285
+ {#each item.options as option}
286
+ {@render comboboxItem(option)}
287
+ {/each}
288
+ </ComboboxPrimitive.Group>
289
+ {:else}
290
+ {@render comboboxItem(item)}
291
+ {/if}
225
292
  {/each}
226
293
  {/if}
227
294
  </div>
@@ -1,3 +1,4 @@
1
+ import type { SelectGroup } from '../types/components.js';
1
2
  export interface ComboboxOption {
2
3
  value: string;
3
4
  label: string;
@@ -8,8 +9,8 @@ interface Props {
8
9
  value?: string;
9
10
  /** Callback when value changes */
10
11
  onValueChange?: (value: string) => void;
11
- /** Array of selectable options */
12
- options: ComboboxOption[];
12
+ /** Array of options or grouped options */
13
+ options: (ComboboxOption | SelectGroup)[];
13
14
  /** Placeholder text for the input */
14
15
  placeholder?: string;
15
16
  /** Message to display when no results found */
@@ -36,6 +37,10 @@ interface Props {
36
37
  debounceMs?: number;
37
38
  /** Additional class for the trigger */
38
39
  class?: string;
40
+ /** Accessible name for the input, when there is no visible label */
41
+ 'aria-label'?: string;
42
+ /** ID of an element that labels the input */
43
+ 'aria-labelledby'?: string;
39
44
  }
40
45
  declare const Combobox: import("svelte").Component<Props, {}, "value">;
41
46
  type Combobox = ReturnType<typeof Combobox>;
@@ -2,6 +2,7 @@
2
2
  import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
3
3
  import { cn } from '../utils.js';
4
4
  import type { Snippet } from 'svelte';
5
+ import type { Attachment } from 'svelte/attachments';
5
6
  import Icon, { type IconName } from './Icon.svelte';
6
7
  import { buttonVariants, type ButtonVariants } from './button-variants.js';
7
8
 
@@ -60,6 +61,19 @@
60
61
  * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
61
62
  */
62
63
  showChevron?: boolean;
64
+ /**
65
+ * Disable the trigger `<button>`: sets native `disabled` and stops the menu from opening
66
+ * (pointer and keyboard). Use this instead of nesting a disabled `<Button>` in `trigger`.
67
+ */
68
+ triggerDisabled?: boolean;
69
+ /**
70
+ * Keep the trigger's pointer, mouse, click and opening-key (Enter, Space, ArrowDown)
71
+ * events from bubbling past the trigger, so a menu inside a clickable row or card
72
+ * opens without also activating the row. The menu still opens as usual.
73
+ */
74
+ triggerStopPropagation?: boolean;
75
+ /** Called on a click of the trigger, before the event bubbles to its ancestors */
76
+ onTriggerClick?: (event: MouseEvent) => void;
63
77
  }
64
78
 
65
79
  let {
@@ -75,8 +89,56 @@
75
89
  triggerSize = 'sm',
76
90
  triggerLabel,
77
91
  showChevron = false,
92
+ triggerDisabled = false,
93
+ triggerStopPropagation = false,
94
+ onTriggerClick,
78
95
  }: Props = $props();
79
96
 
97
+ type TriggerHandler = ((event: Event) => void) | undefined;
98
+
99
+ /** Keys the bits-ui trigger acts on (toggle/open), so they are the row-activating ones too. */
100
+ const OPENING_KEYS = new Set(['Enter', ' ', 'ArrowDown']);
101
+
102
+ /** Events that would reach a clickable ancestor (row click, row link, row keyboard activate). */
103
+ const ISOLATED_EVENTS = [
104
+ 'pointerdown',
105
+ 'pointerup',
106
+ 'mousedown',
107
+ 'mouseup',
108
+ 'click',
109
+ 'auxclick',
110
+ 'keydown',
111
+ ] as const;
112
+
113
+ /**
114
+ * With `triggerStopPropagation`, listen natively on the trigger itself: run the bits-ui
115
+ * handler (and `onTriggerClick`) at the target, then stop the event there. Svelte's
116
+ * delegated handlers run at the root, so a stop inside one would also keep bits-ui's own
117
+ * (delegated) handlers from running and could not stop native listeners on ancestors.
118
+ */
119
+ function isolateTrigger(props: Record<string, unknown>): Attachment<HTMLButtonElement> {
120
+ return (node) => {
121
+ const listeners = ISOLATED_EVENTS.map((type) => {
122
+ const listener = (event: Event) => {
123
+ (props[`on${type}`] as TriggerHandler)?.(event);
124
+ if (type === 'click') onTriggerClick?.(event as MouseEvent);
125
+ if (type === 'keydown' && !OPENING_KEYS.has((event as KeyboardEvent).key)) return;
126
+ event.stopPropagation();
127
+ };
128
+ node.addEventListener(type, listener);
129
+ return () => node.removeEventListener(type, listener);
130
+ });
131
+ return () => listeners.forEach((remove) => remove());
132
+ };
133
+ }
134
+
135
+ /** Trigger props without the event handlers that `isolateTrigger` calls itself. */
136
+ function withoutHandlers(props: Record<string, unknown>): Record<string, unknown> {
137
+ const rest = { ...props };
138
+ for (const type of ISOLATED_EVENTS) delete rest[`on${type}`];
139
+ return rest;
140
+ }
141
+
80
142
  function handleOpenChange(newOpen: boolean) {
81
143
  open = newOpen;
82
144
  onOpenChange?.(newOpen);
@@ -87,6 +149,21 @@
87
149
  }
88
150
  </script>
89
151
 
152
+ {#snippet triggerContent()}
153
+ {@render trigger()}
154
+ {#if showChevron}
155
+ <Icon
156
+ name="chevron-down"
157
+ size={16}
158
+ class={cn(
159
+ 'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
160
+ open && 'rotate-180'
161
+ )}
162
+ aria-hidden="true"
163
+ />
164
+ {/if}
165
+ {/snippet}
166
+
90
167
  {#snippet itemContent(item: DropdownMenuItem)}
91
168
  {#if item.icon}
92
169
  <Icon name={item.icon} size={16} class="mr-2 shrink-0" aria-hidden="true" />
@@ -143,29 +220,32 @@
143
220
  {/snippet}
144
221
 
145
222
  <DropdownMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
146
- <DropdownMenuPrimitive.Trigger>
223
+ <DropdownMenuPrimitive.Trigger disabled={triggerDisabled}>
147
224
  {#snippet child({ props })}
148
- <button
149
- type="button"
150
- {...props}
151
- class={triggerVariant
152
- ? cn(buttonVariants({ variant: triggerVariant, size: triggerSize }), triggerClass)
153
- : triggerClass}
154
- aria-label={triggerLabel}
155
- >
156
- {@render trigger()}
157
- {#if showChevron}
158
- <Icon
159
- name="chevron-down"
160
- size={16}
161
- class={cn(
162
- 'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
163
- open && 'rotate-180'
164
- )}
165
- aria-hidden="true"
166
- />
167
- {/if}
168
- </button>
225
+ {@const buttonClass = triggerVariant
226
+ ? cn(buttonVariants({ variant: triggerVariant, size: triggerSize }), triggerClass)
227
+ : triggerClass}
228
+ {#if triggerStopPropagation}
229
+ <button
230
+ type="button"
231
+ {...withoutHandlers(props)}
232
+ class={buttonClass}
233
+ aria-label={triggerLabel}
234
+ {@attach isolateTrigger(props)}
235
+ >
236
+ {@render triggerContent()}
237
+ </button>
238
+ {:else}
239
+ <button
240
+ type="button"
241
+ {...props}
242
+ class={buttonClass}
243
+ aria-label={triggerLabel}
244
+ onclick={onTriggerClick}
245
+ >
246
+ {@render triggerContent()}
247
+ </button>
248
+ {/if}
169
249
  {/snippet}
170
250
  </DropdownMenuPrimitive.Trigger>
171
251
 
@@ -54,6 +54,19 @@ interface Props {
54
54
  * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
55
55
  */
56
56
  showChevron?: boolean;
57
+ /**
58
+ * Disable the trigger `<button>`: sets native `disabled` and stops the menu from opening
59
+ * (pointer and keyboard). Use this instead of nesting a disabled `<Button>` in `trigger`.
60
+ */
61
+ triggerDisabled?: boolean;
62
+ /**
63
+ * Keep the trigger's pointer, mouse, click and opening-key (Enter, Space, ArrowDown)
64
+ * events from bubbling past the trigger, so a menu inside a clickable row or card
65
+ * opens without also activating the row. The menu still opens as usual.
66
+ */
67
+ triggerStopPropagation?: boolean;
68
+ /** Called on a click of the trigger, before the event bubbles to its ancestors */
69
+ onTriggerClick?: (event: MouseEvent) => void;
57
70
  }
58
71
  declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
59
72
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
@@ -4,6 +4,7 @@
4
4
  import { validateNonEmptyArray } from '../validation.js';
5
5
  import { DEV } from 'esm-env';
6
6
  import Label from './Label.svelte';
7
+ import Combobox from './Combobox.svelte';
7
8
 
8
9
  export interface SelectOption {
9
10
  value: string;
@@ -45,6 +46,11 @@
45
46
  'aria-label'?: string;
46
47
  /** ID of an element that labels the trigger */
47
48
  'aria-labelledby'?: string;
49
+ /**
50
+ * Type to narrow the options: `true` always renders a Combobox; a number
51
+ * renders one once there are at least that many options.
52
+ */
53
+ searchable?: boolean | number;
48
54
  }
49
55
 
50
56
  let {
@@ -62,6 +68,7 @@
62
68
  hint,
63
69
  'aria-label': ariaLabelProp,
64
70
  'aria-labelledby': ariaLabelledby,
71
+ searchable = false,
65
72
  }: Props = $props();
66
73
 
67
74
  // Generate unique IDs for accessibility using sequential counter
@@ -132,6 +139,10 @@
132
139
  return items;
133
140
  });
134
141
 
142
+ const useCombobox = $derived(
143
+ typeof searchable === 'number' ? flatItems.length >= searchable : searchable
144
+ );
145
+
135
146
  function handleValueChange(newValue: string) {
136
147
  value = newValue;
137
148
  onValueChange?.(newValue);
@@ -267,30 +278,49 @@
267
278
  </SelectPrimitive.Root>
268
279
  {/snippet}
269
280
 
270
- <!-- One structure whether or not there is a label, hint or error, so the
281
+ {#if useCombobox}
282
+ <Combobox
283
+ bind:value
284
+ {onValueChange}
285
+ {options}
286
+ {placeholder}
287
+ {disabled}
288
+ {name}
289
+ {required}
290
+ {label}
291
+ {id}
292
+ {error}
293
+ {hint}
294
+ aria-label={ariaLabelProp}
295
+ aria-labelledby={ariaLabelledby}
296
+ class={className}
297
+ />
298
+ {:else}
299
+ <!-- One structure whether or not there is a label, hint or error, so the
271
300
  focused control is never remounted when an error appears. -->
272
- <div class="space-y-2">
273
- {#if label}
274
- <Label for={componentId} {disabled}>
275
- {label}
276
- {#if required}
277
- <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
278
- <span class="sr-only">(required)</span>
279
- {/if}
280
- </Label>
281
- {/if}
301
+ <div class="space-y-2">
302
+ {#if label}
303
+ <Label for={componentId} {disabled}>
304
+ {label}
305
+ {#if required}
306
+ <span class="text-destructive ml-0.5" aria-hidden="true">*</span>
307
+ <span class="sr-only">(required)</span>
308
+ {/if}
309
+ </Label>
310
+ {/if}
282
311
 
283
- {@render selectRoot(fallbackAriaLabel)}
312
+ {@render selectRoot(fallbackAriaLabel)}
284
313
 
285
- {#if hint && !error}
286
- <p id={hintId} class="text-sm text-muted-foreground">
287
- {hint}
288
- </p>
289
- {/if}
314
+ {#if hint && !error}
315
+ <p id={hintId} class="text-sm text-muted-foreground">
316
+ {hint}
317
+ </p>
318
+ {/if}
290
319
 
291
- {#if error}
292
- <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
293
- {error}
294
- </p>
295
- {/if}
296
- </div>
320
+ {#if error}
321
+ <p id={errorId} class="text-sm text-destructive" role="alert" aria-live="polite">
322
+ {error}
323
+ </p>
324
+ {/if}
325
+ </div>
326
+ {/if}
@@ -36,6 +36,11 @@ interface Props {
36
36
  'aria-label'?: string;
37
37
  /** ID of an element that labels the trigger */
38
38
  'aria-labelledby'?: string;
39
+ /**
40
+ * Type to narrow the options: `true` always renders a Combobox; a number
41
+ * renders one once there are at least that many options.
42
+ */
43
+ searchable?: boolean | number;
39
44
  }
40
45
  declare const Select: import("svelte").Component<Props, {}, "value">;
41
46
  type Select = ReturnType<typeof Select>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.27.1",
3
+ "version": "1.29.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",