sv5ui 1.5.1 → 1.7.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.
Files changed (60) hide show
  1. package/dist/Calendar/Calendar.svelte +48 -6
  2. package/dist/Calendar/calendar.types.d.ts +19 -0
  3. package/dist/Calendar/calendar.variants.js +2 -1
  4. package/dist/Carousel/Carousel.svelte +279 -0
  5. package/dist/Carousel/Carousel.svelte.d.ts +26 -0
  6. package/dist/Carousel/carousel.types.d.ts +242 -0
  7. package/dist/Carousel/carousel.types.js +1 -0
  8. package/dist/Carousel/carousel.variants.d.ts +408 -0
  9. package/dist/Carousel/carousel.variants.js +88 -0
  10. package/dist/Carousel/index.d.ts +2 -0
  11. package/dist/Carousel/index.js +1 -0
  12. package/dist/Checkbox/Checkbox.svelte +8 -2
  13. package/dist/CheckboxGroup/CheckboxGroup.svelte +15 -2
  14. package/dist/FileUpload/FileUpload.svelte +81 -10
  15. package/dist/FileUpload/file-upload.types.d.ts +39 -0
  16. package/dist/FileUpload/index.d.ts +1 -1
  17. package/dist/Form/Form.svelte +203 -0
  18. package/dist/Form/Form.svelte.d.ts +26 -0
  19. package/dist/Form/form.context.svelte.d.ts +64 -0
  20. package/dist/Form/form.context.svelte.js +478 -0
  21. package/dist/Form/form.types.d.ts +164 -0
  22. package/dist/Form/form.types.js +12 -0
  23. package/dist/Form/form.variants.d.ts +39 -0
  24. package/dist/Form/form.variants.js +17 -0
  25. package/dist/Form/index.d.ts +4 -0
  26. package/dist/Form/index.js +6 -0
  27. package/dist/Form/validate-schema.d.ts +13 -0
  28. package/dist/Form/validate-schema.js +113 -0
  29. package/dist/FormField/FormField.svelte +71 -8
  30. package/dist/FormField/form-field.types.d.ts +15 -0
  31. package/dist/Input/Input.svelte +31 -5
  32. package/dist/Input/Input.svelte.d.ts +25 -4
  33. package/dist/Input/input.types.d.ts +24 -3
  34. package/dist/Modal/Modal.svelte +14 -3
  35. package/dist/Modal/modal.types.d.ts +15 -4
  36. package/dist/Modal/modal.variants.d.ts +110 -20
  37. package/dist/Modal/modal.variants.js +27 -9
  38. package/dist/PinInput/PinInput.svelte +27 -6
  39. package/dist/PinInput/pin-input.types.d.ts +11 -0
  40. package/dist/RadioGroup/RadioGroup.svelte +17 -3
  41. package/dist/Select/Select.svelte +100 -19
  42. package/dist/Select/select.types.d.ts +44 -2
  43. package/dist/SelectMenu/SelectMenu.svelte +215 -23
  44. package/dist/SelectMenu/select-menu.types.d.ts +62 -1
  45. package/dist/SelectMenu/select-menu.variants.d.ts +26 -0
  46. package/dist/SelectMenu/select-menu.variants.js +34 -6
  47. package/dist/Slideover/Slideover.svelte +13 -2
  48. package/dist/Slideover/slideover.types.d.ts +14 -3
  49. package/dist/Slideover/slideover.variants.d.ts +85 -5
  50. package/dist/Slideover/slideover.variants.js +42 -12
  51. package/dist/Slider/Slider.svelte +4 -1
  52. package/dist/Switch/Switch.svelte +8 -2
  53. package/dist/Textarea/Textarea.svelte +27 -1
  54. package/dist/hooks/index.d.ts +1 -1
  55. package/dist/hooks/index.js +1 -1
  56. package/dist/hooks/useFormField.svelte.d.ts +64 -0
  57. package/dist/hooks/useFormField.svelte.js +70 -1
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.js +2 -0
  60. package/package.json +31 -3
@@ -3,6 +3,12 @@ import type { ClassNameValue } from 'tailwind-merge';
3
3
  import type { SelectVariantProps, SelectSlots } from './select.variants.js';
4
4
  import type { AvatarProps } from '../Avatar/avatar.types.js';
5
5
  import type { SelectRootPropsWithoutHTML, SelectContentPropsWithoutHTML } from 'bits-ui';
6
+ /**
7
+ * The value shape for the Select.
8
+ * - When `multiple` is `false` (or omitted), the value is a single string (or undefined).
9
+ * - When `multiple` is `true`, the value is an array of strings.
10
+ */
11
+ export type SelectValue<Multiple extends boolean = false> = Multiple extends true ? string[] : string | undefined;
6
12
  /**
7
13
  * A single selectable option within the Select.
8
14
  */
@@ -67,6 +73,17 @@ export interface SelectItemSlotProps {
67
73
  /** Whether the item is currently selected */
68
74
  selected?: boolean;
69
75
  }
76
+ /**
77
+ * Props passed to the `selected` snippet when `multiple` is true.
78
+ */
79
+ export interface SelectSelectedSlotProps {
80
+ /** The full list of currently selected items resolved from values. */
81
+ items: SelectItem[];
82
+ /** Remove a value from the current selection. */
83
+ remove: (value: string) => void;
84
+ /** Clear all selected values. */
85
+ clear: () => void;
86
+ }
70
87
  type RootProps = Pick<SelectRootPropsWithoutHTML, 'open' | 'onOpenChange' | 'name' | 'required' | 'disabled'>;
71
88
  type ContentProps = Pick<SelectContentPropsWithoutHTML, 'side' | 'sideOffset' | 'align' | 'alignOffset' | 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'onEscapeKeydown' | 'onInteractOutside' | 'forceMount' | 'loop'>;
72
89
  /**
@@ -92,12 +109,31 @@ export interface SelectProps extends RootProps, ContentProps {
92
109
  ref?: HTMLElement | null;
93
110
  /**
94
111
  * The currently selected value. Supports two-way binding with `bind:value`.
112
+ *
113
+ * - When `multiple` is `false`/omitted, this is a `string`.
114
+ * - When `multiple` is `true`, this is a `string[]`.
95
115
  */
96
- value?: string;
116
+ value?: string | string[];
97
117
  /**
98
118
  * The default selected value when uncontrolled.
119
+ *
120
+ * - When `multiple` is `false`/omitted, this is a `string`.
121
+ * - When `multiple` is `true`, this is a `string[]`.
122
+ */
123
+ defaultValue?: string | string[];
124
+ /**
125
+ * Whether multiple items can be selected at once.
126
+ * When `true`, `value` becomes a `string[]` and the trigger displays
127
+ * a comma-separated list of selected labels by default.
128
+ * @default false
129
+ */
130
+ multiple?: boolean;
131
+ /**
132
+ * Separator used to join selected labels in the trigger when `multiple` is `true`.
133
+ * Ignored when the `selected` snippet is provided.
134
+ * @default ', '
99
135
  */
100
- defaultValue?: string;
136
+ separator?: string;
101
137
  /**
102
138
  * Array of items to display in the select dropdown.
103
139
  */
@@ -194,6 +230,12 @@ export interface SelectProps extends RootProps, ContentProps {
194
230
  * Takes precedence over `trailingIcon`.
195
231
  */
196
232
  trailingSlot?: Snippet;
233
+ /**
234
+ * Custom rendering for the selected value(s) displayed in the trigger.
235
+ * Useful in `multiple` mode to render chips/tags instead of the default
236
+ * comma-separated label list.
237
+ */
238
+ selected?: Snippet<[SelectSelectedSlotProps]>;
197
239
  /**
198
240
  * Custom snippet for rendering individual items in the dropdown.
199
241
  * When provided, replaces the default item rendering.
@@ -6,6 +6,8 @@
6
6
  } from './select-menu.types.js'
7
7
 
8
8
  export type Props = SelectMenuProps
9
+
10
+ const CREATE_ITEM_VALUE = '@@sv5ui/select-menu/create-item'
9
11
  </script>
10
12
 
11
13
  <script lang="ts">
@@ -21,7 +23,7 @@
21
23
  import Avatar from '../Avatar/Avatar.svelte'
22
24
  import Input from '../Input/Input.svelte'
23
25
  import type { AvatarSize } from '../Avatar/avatar.types.js'
24
- import { useFormField } from '../hooks/useFormField.svelte.js'
26
+ import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
25
27
 
26
28
  const config = getComponentConfig('selectMenu', selectMenuDefaults)
27
29
  const icons = getComponentConfig('icons', iconsDefaults)
@@ -37,6 +39,8 @@
37
39
  name,
38
40
  required = false,
39
41
  disabled = false,
42
+ multiple = false,
43
+ separator = ', ',
40
44
  ui,
41
45
  id,
42
46
  color = config.defaultVariants.color,
@@ -53,6 +57,10 @@
53
57
  filterFields = ['label', 'value'] as string[],
54
58
  ignoreFilter = false,
55
59
  emptyText = 'No results found.',
60
+ createItem = false,
61
+ createItemLabel = (value: string) => `Create "${value}"`,
62
+ createItemIcon,
63
+ onCreate,
56
64
  transition = config.defaultVariants.transition ?? true,
57
65
  portal = true,
58
66
  side = config.defaultVariants.side ?? 'bottom',
@@ -72,12 +80,14 @@
72
80
  itemLeading,
73
81
  itemLabel: itemLabelSlot,
74
82
  itemTrailing,
83
+ selected: selectedSlot,
75
84
  empty: emptySlot,
76
85
  content: contentSlot
77
86
  }: Props = $props()
78
87
 
79
88
  // ---- Form context ----
80
89
  const formFieldContext = useFormField()
90
+ const emit = useFormFieldEmit()
81
91
 
82
92
  const fieldGroupContext = getContext<
83
93
  | {
@@ -112,25 +122,70 @@
112
122
  : `${formFieldContext.ariaId}-description ${formFieldContext.ariaId}-help`
113
123
  )
114
124
 
125
+ // ---- Created items (internal state for createItem) ----
126
+ let createdItems = $state<SelectMenuItem[]>([])
127
+
128
+ const combinedItems = $derived.by(() => {
129
+ const propValues = new Set(
130
+ (items as SelectMenuItemType[])
131
+ .filter((i): i is SelectMenuItem => !('type' in i))
132
+ .map((i) => i.value)
133
+ )
134
+ const extras = createdItems.filter((c) => !propValues.has(c.value))
135
+ return [...(items as SelectMenuItemType[]), ...extras]
136
+ })
137
+
115
138
  // ---- Items lookup (O(1) via Map) ----
116
139
  const itemsMap = $derived(
117
140
  new Map(
118
- (items as SelectMenuItemType[])
141
+ combinedItems
119
142
  .filter((i): i is SelectMenuItem => !('type' in i))
120
143
  .map((i) => [i.value, i])
121
144
  )
122
145
  )
123
146
 
124
- const selectedItem = $derived(value ? itemsMap.get(value) : undefined)
125
- const displayLabel = $derived(selectedItem?.label ?? selectedItem?.value ?? '')
147
+ // ---- Selection (single + multiple) ----
148
+ const selectedValues = $derived(
149
+ multiple
150
+ ? Array.isArray(value)
151
+ ? (value as string[])
152
+ : []
153
+ : typeof value === 'string' && value !== ''
154
+ ? [value]
155
+ : []
156
+ )
157
+ const selectedItems = $derived(
158
+ selectedValues
159
+ .map((v) => itemsMap.get(v))
160
+ .filter((i): i is SelectMenuItem => i !== undefined)
161
+ )
162
+ const hasSelection = $derived(selectedValues.length > 0)
163
+ const singleSelectedItem = $derived(multiple ? undefined : selectedItems[0])
164
+ const displayLabel = $derived(
165
+ multiple
166
+ ? selectedItems.map((i) => i.label ?? i.value).join(separator)
167
+ : (singleSelectedItem?.label ?? singleSelectedItem?.value ?? '')
168
+ )
169
+
170
+ function removeValue(val: string) {
171
+ if (!multiple) return
172
+ value = selectedValues.filter((v) => v !== val)
173
+ emit.onChange()
174
+ }
175
+
176
+ function clearSelection() {
177
+ if (!multiple) return
178
+ value = []
179
+ emit.onChange()
180
+ }
126
181
 
127
182
  // ---- Search & filtering ----
128
183
  let searchTerm = $state('')
129
184
 
130
185
  const filteredItems = $derived(
131
186
  ignoreFilter || !searchTerm.trim()
132
- ? items
133
- : items.filter((item) => {
187
+ ? combinedItems
188
+ : combinedItems.filter((item) => {
134
189
  if ('type' in item) return true
135
190
  const query = searchTerm.toLowerCase()
136
191
  return filterFields.some((field) => {
@@ -142,9 +197,73 @@
142
197
 
143
198
  const hasFilteredSelectItems = $derived(filteredItems.some((item) => !('type' in item)))
144
199
 
200
+ // ---- Create item ----
201
+ const trimmedSearch = $derived(searchTerm.trim())
202
+ const exactMatchExists = $derived.by(() => {
203
+ if (!trimmedSearch) return false
204
+ const query = trimmedSearch.toLowerCase()
205
+ for (const i of combinedItems) {
206
+ if ('type' in i) continue
207
+ if (i.value.toLowerCase() === query || (i.label ?? i.value).toLowerCase() === query) {
208
+ return true
209
+ }
210
+ }
211
+ return false
212
+ })
213
+ const showCreateItem = $derived.by(() => {
214
+ if (!createItem) return false
215
+ if (!trimmedSearch) return false
216
+ const mode = createItem === true ? 'lazy' : createItem
217
+ if (mode === 'always') return true
218
+ return !exactMatchExists
219
+ })
220
+ const resolvedCreateLabel = $derived(
221
+ typeof createItemLabel === 'function' ? createItemLabel(trimmedSearch) : createItemLabel
222
+ )
223
+
224
+ function findItemByCaseInsensitive(query: string): SelectMenuItem | undefined {
225
+ const q = query.toLowerCase()
226
+ for (const it of itemsMap.values()) {
227
+ if (it.value.toLowerCase() === q || (it.label ?? it.value).toLowerCase() === q) {
228
+ return it
229
+ }
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ function selectValue(val: string) {
235
+ if (multiple) {
236
+ if (!selectedValues.includes(val)) {
237
+ value = [...selectedValues, val]
238
+ }
239
+ } else {
240
+ value = val
241
+ }
242
+ }
243
+
244
+ function handleCreate() {
245
+ if (!showCreateItem) return
246
+ const newValue = trimmedSearch
247
+ if (!newValue) return
248
+
249
+ const existing = findItemByCaseInsensitive(newValue)
250
+ if (existing) {
251
+ selectValue(existing.value)
252
+ } else {
253
+ createdItems = [...createdItems, { value: newValue, label: newValue }]
254
+ selectValue(newValue)
255
+ onCreate?.(newValue)
256
+ }
257
+
258
+ emit.onChange()
259
+ searchTerm = ''
260
+ }
261
+
145
262
  // ---- Leading / trailing ----
146
- const displayAvatar = $derived(selectedItem?.avatar ?? avatar)
147
- const displayIcon = $derived(selectedItem?.icon ?? leadingIcon ?? icon)
263
+ const displayAvatar = $derived(multiple ? avatar : (singleSelectedItem?.avatar ?? avatar))
264
+ const displayIcon = $derived(
265
+ multiple ? (leadingIcon ?? icon) : (singleSelectedItem?.icon ?? leadingIcon ?? icon)
266
+ )
148
267
  const isLeading = $derived(!!leadingSlot || !!displayAvatar || !!displayIcon)
149
268
  const leadingIconName = $derived(
150
269
  loading && isLeading ? loadingIcon : !displayAvatar ? displayIcon : undefined
@@ -218,6 +337,17 @@
218
337
  variantSlots.separator({ class: [config.slots.separator, ui?.separator] })
219
338
  )
220
339
  const emptyClass = $derived(variantSlots.empty({ class: [config.slots.empty, ui?.empty] }))
340
+ const createItemClass = $derived(
341
+ variantSlots.createItem({ class: [config.slots.createItem, ui?.createItem] })
342
+ )
343
+ const createItemIconClass = $derived(
344
+ variantSlots.createItemIcon({ class: [config.slots.createItemIcon, ui?.createItemIcon] })
345
+ )
346
+ const createItemLabelClass = $derived(
347
+ variantSlots.createItemLabel({
348
+ class: [config.slots.createItemLabel, ui?.createItemLabel]
349
+ })
350
+ )
221
351
 
222
352
  // ---- Item classes ----
223
353
  const itemClass = $derived(variantSlots.item({ class: [config.slots.item, ui?.item] }))
@@ -257,13 +387,16 @@
257
387
  function onUpdateOpen(val: boolean) {
258
388
  if (!val) {
259
389
  searchTerm = ''
390
+ emit.onBlur()
391
+ } else {
392
+ emit.onFocus()
260
393
  }
261
394
  onOpenChange?.(val)
262
395
  }
263
396
  </script>
264
397
 
265
398
  {#snippet renderItem(item: SelectMenuItem, index: number)}
266
- {@const isSelected = value === item.value}
399
+ {@const isSelected = selectedValues.includes(item.value)}
267
400
  <Combobox.Item
268
401
  value={item.value}
269
402
  label={item.label ?? item.value}
@@ -316,6 +449,12 @@
316
449
  placeholder={searchPlaceholder}
317
450
  value={searchTerm}
318
451
  oninput={(e) => (searchTerm = (e.currentTarget as HTMLInputElement).value)}
452
+ onkeydown={(e: KeyboardEvent) => {
453
+ if (e.key !== 'Enter') return
454
+ if (!showCreateItem) return
455
+ e.preventDefault()
456
+ handleCreate()
457
+ }}
319
458
  variant="none"
320
459
  size={resolvedSize}
321
460
  class={inputClass}
@@ -339,7 +478,7 @@
339
478
  {@render itemSlot({
340
479
  item: selectItem,
341
480
  index,
342
- selected: value === selectItem.value
481
+ selected: selectedValues.includes(selectItem.value)
343
482
  })}
344
483
  {:else}
345
484
  {@render renderItem(selectItem, index)}
@@ -347,28 +486,33 @@
347
486
  {/if}
348
487
  {/each}
349
488
 
350
- {#if !hasFilteredSelectItems}
489
+ {#if !hasFilteredSelectItems && !showCreateItem}
351
490
  {#if emptySlot}
352
491
  {@render emptySlot({ searchTerm })}
353
492
  {:else}
354
493
  <div class={emptyClass}>{emptyText}</div>
355
494
  {/if}
356
495
  {/if}
496
+
497
+ {#if showCreateItem}
498
+ <Combobox.Item
499
+ value={CREATE_ITEM_VALUE}
500
+ label={resolvedCreateLabel}
501
+ {disabled}
502
+ class={createItemClass}
503
+ >
504
+ {#if createItemIcon}
505
+ <Icon name={createItemIcon} class={createItemIconClass} />
506
+ {/if}
507
+ <span class={createItemLabelClass}>{resolvedCreateLabel}</span>
508
+ </Combobox.Item>
509
+ {/if}
357
510
  </div>
358
511
  {/if}
359
512
  </Combobox.Content>
360
513
  {/snippet}
361
514
 
362
- <Combobox.Root
363
- type="single"
364
- bind:open
365
- onOpenChange={onUpdateOpen}
366
- {disabled}
367
- {required}
368
- {value}
369
- onValueChange={(val) => (value = val)}
370
- name={resolvedName}
371
- >
515
+ {#snippet rootChildren()}
372
516
  <div bind:this={ref} class={rootClass}>
373
517
  {#if leadingSlot}
374
518
  <span class={leadingClass}>
@@ -400,7 +544,13 @@
400
544
  aria-invalid={resolvedHighlight ? true : undefined}
401
545
  class={baseClass}
402
546
  >
403
- {#if value && displayLabel}
547
+ {#if selectedSlot && hasSelection}
548
+ {@render selectedSlot({
549
+ items: selectedItems,
550
+ remove: removeValue,
551
+ clear: clearSelection
552
+ })}
553
+ {:else if hasSelection && displayLabel}
404
554
  <span class={valueClass}>{displayLabel}</span>
405
555
  {:else if placeholder}
406
556
  <span class={placeholderClass}>{placeholder}</span>
@@ -425,4 +575,46 @@
425
575
  {:else}
426
576
  {@render contentEl()}
427
577
  {/if}
428
- </Combobox.Root>
578
+ {/snippet}
579
+
580
+ {#if multiple}
581
+ <Combobox.Root
582
+ type="multiple"
583
+ bind:open
584
+ onOpenChange={onUpdateOpen}
585
+ {disabled}
586
+ {required}
587
+ value={selectedValues}
588
+ onValueChange={(val) => {
589
+ if (Array.isArray(val) && val.includes(CREATE_ITEM_VALUE)) {
590
+ handleCreate()
591
+ return
592
+ }
593
+ value = val
594
+ emit.onChange()
595
+ }}
596
+ name={resolvedName}
597
+ >
598
+ {@render rootChildren()}
599
+ </Combobox.Root>
600
+ {:else}
601
+ <Combobox.Root
602
+ type="single"
603
+ bind:open
604
+ onOpenChange={onUpdateOpen}
605
+ {disabled}
606
+ {required}
607
+ value={selectedValues[0] ?? ''}
608
+ onValueChange={(val) => {
609
+ if (val === CREATE_ITEM_VALUE) {
610
+ handleCreate()
611
+ return
612
+ }
613
+ value = val
614
+ emit.onChange()
615
+ }}
616
+ name={resolvedName}
617
+ >
618
+ {@render rootChildren()}
619
+ </Combobox.Root>
620
+ {/if}
@@ -67,6 +67,17 @@ export interface SelectMenuItemSlotProps {
67
67
  /** Whether the item is currently selected */
68
68
  selected?: boolean;
69
69
  }
70
+ /**
71
+ * Props passed to the `selected` snippet when `multiple` is true.
72
+ */
73
+ export interface SelectMenuSelectedSlotProps {
74
+ /** The full list of currently selected items resolved from values. */
75
+ items: SelectMenuItem[];
76
+ /** Remove a value from the current selection. */
77
+ remove: (value: string) => void;
78
+ /** Clear all selected values. */
79
+ clear: () => void;
80
+ }
70
81
  type ContentProps = Pick<ComboboxContentPropsWithoutHTML, 'side' | 'sideOffset' | 'align' | 'alignOffset' | 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'onEscapeKeydown' | 'onInteractOutside' | 'forceMount'>;
71
82
  /**
72
83
  * Props for the SelectMenu component.
@@ -83,8 +94,25 @@ export interface SelectMenuProps extends ContentProps {
83
94
  ref?: HTMLElement | null;
84
95
  /**
85
96
  * The currently selected value. Supports two-way binding with `bind:value`.
97
+ *
98
+ * - When `multiple` is `false`/omitted, this is a `string`.
99
+ * - When `multiple` is `true`, this is a `string[]`.
100
+ */
101
+ value?: string | string[];
102
+ /**
103
+ * Whether multiple items can be selected at once.
104
+ * When `true`, `value` becomes a `string[]` and the dropdown stays open
105
+ * after each selection. The trigger displays a comma-separated list of
106
+ * selected labels by default; use the `selected` snippet for chips/tags.
107
+ * @default false
108
+ */
109
+ multiple?: boolean;
110
+ /**
111
+ * Separator used to join selected labels in the trigger when `multiple` is `true`.
112
+ * Ignored when the `selected` snippet is provided.
113
+ * @default ', '
86
114
  */
87
- value?: string;
115
+ separator?: string;
88
116
  /**
89
117
  * Whether the dropdown is open. Supports two-way binding with `bind:open`.
90
118
  * @default false
@@ -192,6 +220,33 @@ export interface SelectMenuProps extends ContentProps {
192
220
  * @default 'No results found.'
193
221
  */
194
222
  emptyText?: string;
223
+ /**
224
+ * Allow the user to create a new item by typing in the search input.
225
+ * - `false` (default): the feature is disabled.
226
+ * - `true` / `'lazy'`: the create option only appears when no existing item
227
+ * matches the current search term (case-insensitive on `value` or `label`).
228
+ * - `'always'`: the create option is shown whenever the search term is
229
+ * non-empty, regardless of existing matches.
230
+ * @default false
231
+ */
232
+ createItem?: boolean | 'always' | 'lazy';
233
+ /**
234
+ * Label rendered on the "create" option. Receives the trimmed search term.
235
+ * @default (value) => `Create "${value}"`
236
+ */
237
+ createItemLabel?: string | ((value: string) => string);
238
+ /**
239
+ * Icon shown before the create option label. Pass `false` (or omit) to
240
+ * render no icon.
241
+ * @default undefined
242
+ */
243
+ createItemIcon?: string | false;
244
+ /**
245
+ * Called when the user picks the "create" option. The new value is also
246
+ * tracked internally so the trigger can render its label even if the
247
+ * caller does not push it into `items`.
248
+ */
249
+ onCreate?: (value: string) => void;
195
250
  /**
196
251
  * Animate the dropdown on open and close.
197
252
  * @default true
@@ -224,6 +279,12 @@ export interface SelectMenuProps extends ContentProps {
224
279
  * Takes precedence over `trailingIcon`.
225
280
  */
226
281
  trailingSlot?: Snippet;
282
+ /**
283
+ * Custom rendering for the selected value(s) displayed in the trigger.
284
+ * Useful in `multiple` mode to render chips/tags instead of the default
285
+ * comma-separated label list.
286
+ */
287
+ selected?: Snippet<[SelectMenuSelectedSlotProps]>;
227
288
  /**
228
289
  * Custom snippet for rendering individual items in the dropdown.
229
290
  * When provided, replaces the default item rendering.
@@ -3,18 +3,28 @@ export declare const selectMenuVariants: import("tailwind-variants").TVReturnTyp
3
3
  size: {
4
4
  xs: {
5
5
  empty: string;
6
+ createItem: string;
7
+ createItemIcon: string;
6
8
  };
7
9
  sm: {
8
10
  empty: string;
11
+ createItem: string;
12
+ createItemIcon: string;
9
13
  };
10
14
  md: {
11
15
  empty: string;
16
+ createItem: string;
17
+ createItemIcon: string;
12
18
  };
13
19
  lg: {
14
20
  empty: string;
21
+ createItem: string;
22
+ createItemIcon: string;
15
23
  };
16
24
  xl: {
17
25
  empty: string;
26
+ createItem: string;
27
+ createItemIcon: string;
18
28
  };
19
29
  };
20
30
  }, {
@@ -22,6 +32,9 @@ export declare const selectMenuVariants: import("tailwind-variants").TVReturnTyp
22
32
  input: string;
23
33
  viewport: string;
24
34
  empty: string;
35
+ createItem: string[];
36
+ createItemIcon: string;
37
+ createItemLabel: string;
25
38
  }, undefined, {
26
39
  variant: {
27
40
  outline: string;
@@ -594,18 +607,28 @@ export declare const selectMenuDefaults: {
594
607
  size: {
595
608
  xs: {
596
609
  empty: string;
610
+ createItem: string;
611
+ createItemIcon: string;
597
612
  };
598
613
  sm: {
599
614
  empty: string;
615
+ createItem: string;
616
+ createItemIcon: string;
600
617
  };
601
618
  md: {
602
619
  empty: string;
620
+ createItem: string;
621
+ createItemIcon: string;
603
622
  };
604
623
  lg: {
605
624
  empty: string;
625
+ createItem: string;
626
+ createItemIcon: string;
606
627
  };
607
628
  xl: {
608
629
  empty: string;
630
+ createItem: string;
631
+ createItemIcon: string;
609
632
  };
610
633
  };
611
634
  }, {
@@ -613,6 +636,9 @@ export declare const selectMenuDefaults: {
613
636
  input: string;
614
637
  viewport: string;
615
638
  empty: string;
639
+ createItem: string[];
640
+ createItemIcon: string;
641
+ createItemLabel: string;
616
642
  }, {
617
643
  variant: {
618
644
  outline: string;
@@ -15,15 +15,43 @@ export const selectMenuVariants = tv({
15
15
  ],
16
16
  input: 'border-b border-outline-variant',
17
17
  viewport: 'p-1 flex-1 overflow-y-auto scrollbar-thin',
18
- empty: 'text-center text-on-surface-variant'
18
+ empty: 'text-center text-on-surface-variant',
19
+ createItem: [
20
+ 'group relative flex items-center gap-2 w-full rounded-sm px-2 cursor-pointer select-none',
21
+ 'focus:outline-none',
22
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
23
+ 'data-[highlighted]:bg-surface-container-highest'
24
+ ],
25
+ createItemIcon: 'shrink-0 text-primary',
26
+ createItemLabel: 'flex-1 truncate text-on-surface'
19
27
  },
20
28
  variants: {
21
29
  size: {
22
- xs: { empty: 'p-2 text-xs' },
23
- sm: { empty: 'p-2.5 text-xs' },
24
- md: { empty: 'p-2.5 text-sm' },
25
- lg: { empty: 'p-3 text-sm' },
26
- xl: { empty: 'p-3 text-base' }
30
+ xs: {
31
+ empty: 'p-2 text-xs',
32
+ createItem: 'py-1 text-xs',
33
+ createItemIcon: 'size-3'
34
+ },
35
+ sm: {
36
+ empty: 'p-2.5 text-xs',
37
+ createItem: 'py-1.5 text-xs',
38
+ createItemIcon: 'size-3.5'
39
+ },
40
+ md: {
41
+ empty: 'p-2.5 text-sm',
42
+ createItem: 'py-1.5 text-sm',
43
+ createItemIcon: 'size-4'
44
+ },
45
+ lg: {
46
+ empty: 'p-3 text-sm',
47
+ createItem: 'py-2 text-sm',
48
+ createItemIcon: 'size-5'
49
+ },
50
+ xl: {
51
+ empty: 'p-3 text-base',
52
+ createItem: 'py-2.5 text-base',
53
+ createItemIcon: 'size-5'
54
+ }
27
55
  }
28
56
  }
29
57
  });
@@ -28,7 +28,8 @@
28
28
  description,
29
29
  side = config.defaultVariants.side ?? 'right',
30
30
  overlay: showOverlay = config.defaultVariants.overlay ?? true,
31
- transition = config.defaultVariants.transition ?? true,
31
+ transition = config.defaultVariants.transition ?? 'slide',
32
+ size = config.defaultVariants.size ?? 'md',
32
33
  inset = config.defaultVariants.inset ?? false,
33
34
  portal = true,
34
35
  close: closeProp = true,
@@ -56,8 +57,18 @@
56
57
  !!headerSlot || hasHeading || !!actionsSlot || showClose || !!closeSlot
57
58
  )
58
59
 
60
+ const resolvedTransition = $derived(
61
+ transition === false ? 'none' : transition === true ? 'slide' : transition
62
+ )
63
+
59
64
  const variantSlots = $derived(
60
- slideoverVariants({ transition, side, inset, overlay: showOverlay })
65
+ slideoverVariants({
66
+ transition: resolvedTransition,
67
+ side,
68
+ size,
69
+ inset,
70
+ overlay: showOverlay
71
+ })
61
72
  )
62
73
 
63
74
  const classes = $derived({