@torch-ui/solid 0.1.3

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 (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. package/src/utilities/relativeDateDefault.ts +14 -0
@@ -0,0 +1,68 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { splitProps } from 'solid-js'
3
+ import { X } from 'lucide-solid'
4
+ import { Autocomplete } from './Autocomplete'
5
+ import { Select } from './Select'
6
+ import { cn } from '../../utilities/classNames'
7
+
8
+ export interface FilterRuleOption {
9
+ value: string
10
+ label: string
11
+ }
12
+
13
+ export interface FilterRuleRowProps extends JSX.HTMLAttributes<HTMLDivElement> {
14
+ fieldValue: string
15
+ fieldOptions: FilterRuleOption[]
16
+ operatorValue: string
17
+ operatorOptions: FilterRuleOption[]
18
+ onFieldChange: (value: string) => void
19
+ onOperatorChange: (value: string) => void
20
+ onRemove: () => void
21
+ children?: JSX.Element
22
+ }
23
+
24
+ export function FilterRuleRow(props: FilterRuleRowProps) {
25
+ const [local, others] = splitProps(props, [
26
+ 'fieldValue',
27
+ 'fieldOptions',
28
+ 'operatorValue',
29
+ 'operatorOptions',
30
+ 'onFieldChange',
31
+ 'onOperatorChange',
32
+ 'onRemove',
33
+ 'class',
34
+ 'children',
35
+ ])
36
+
37
+ return (
38
+ <div
39
+ class={cn('rounded-lg border border-surface-border bg-surface-raised p-4 space-y-3', local.class)}
40
+ {...others}
41
+ >
42
+ <div class="grid gap-3 sm:grid-cols-[1.4fr_1fr_auto]">
43
+ <Autocomplete
44
+ label="Field"
45
+ value={local.fieldValue}
46
+ onValueChange={local.onFieldChange}
47
+ options={local.fieldOptions}
48
+ placeholder="Search fields..."
49
+ />
50
+ <Select
51
+ label="Operator"
52
+ options={local.operatorOptions}
53
+ value={local.operatorValue}
54
+ onValueChange={local.onOperatorChange}
55
+ />
56
+ <button
57
+ type="button"
58
+ class="mt-6 inline-flex h-10 items-center justify-center rounded-lg border border-ink-200 text-ink-500 hover:bg-ink-100"
59
+ aria-label="Remove filter"
60
+ onClick={local.onRemove}
61
+ >
62
+ <X class="h-4 w-4" />
63
+ </button>
64
+ </div>
65
+ {local.children && <div>{local.children}</div>}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,200 @@
1
+ import { type JSX, splitProps, Show, createSignal } from 'solid-js'
2
+ import { Eye, EyeOff } from 'lucide-solid'
3
+ import { TextField as KobalteTextField } from '@kobalte/core/text-field'
4
+ import { cn } from '../../utilities/classNames'
5
+ import { type ComponentSize, inputSizeConfig } from '../../utilities/componentSize'
6
+
7
+ export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
8
+ label?: string
9
+ /** Optional content on the label row (e.g. "Forgot password?" link) */
10
+ labelTrailing?: JSX.Element
11
+ error?: string
12
+ helperText?: string
13
+ /** When true, never render label row or error/helper text (input only; error still affects border) */
14
+ bare?: boolean
15
+ /** When true, native required on input and required indicator (asterisk) on label. */
16
+ required?: boolean
17
+ /** When true, show "optional" on the label row when the field is not required. Default false. */
18
+ optional?: boolean
19
+ /** Input size. Controls height, text size, and padding. Default: md (36px). */
20
+ size?: ComponentSize
21
+ /** When true and type is "password", show a show/hide toggle (eye icon) to reveal the password. Default false. */
22
+ revealable?: boolean
23
+ /** Content at the start of the input (e.g. "$", or an icon). */
24
+ startAdornment?: JSX.Element
25
+ /** Content at the end of the input (e.g. "USD", or an icon). */
26
+ endAdornment?: JSX.Element
27
+ /** Icon at the start (same slot as startAdornment). Use for search, etc. */
28
+ startIcon?: JSX.Element
29
+ /** Icon at the end (same slot as endAdornment). */
30
+ endIcon?: JSX.Element
31
+ /** @deprecated Use startIcon or startAdornment. */
32
+ leftIcon?: JSX.Element
33
+ /** @deprecated Use endIcon or endAdornment. */
34
+ rightIcon?: JSX.Element
35
+ /** Applied to the native input element when you need to style the control itself. */
36
+ inputClass?: string
37
+ onValueChange?: (value: string) => void
38
+ onErrorClear?: () => void
39
+ }
40
+
41
+ export function Input(props: InputProps) {
42
+ const [local, others] = splitProps(props, [
43
+ 'label',
44
+ 'labelTrailing',
45
+ 'error',
46
+ 'helperText',
47
+ 'bare',
48
+ 'required',
49
+ 'optional',
50
+ 'size',
51
+ 'revealable',
52
+ 'type',
53
+ 'startAdornment',
54
+ 'endAdornment',
55
+ 'startIcon',
56
+ 'endIcon',
57
+ 'leftIcon',
58
+ 'rightIcon',
59
+ 'onValueChange',
60
+ 'onErrorClear',
61
+ 'class',
62
+ 'inputClass',
63
+ 'id',
64
+ 'value',
65
+ 'onInput',
66
+ 'ref',
67
+ 'disabled',
68
+ ])
69
+
70
+ const hasError = () => !!local.error
71
+ const sc = () => inputSizeConfig[local.size ?? 'md']
72
+
73
+ const handleChange = (val: string) => {
74
+ if (local.error && local.onErrorClear) {
75
+ local.onErrorClear()
76
+ }
77
+ local.onValueChange?.(val)
78
+ }
79
+
80
+ const handleInput = (e: InputEvent) => {
81
+ if (local.onInput) {
82
+ ;(local.onInput as (e: InputEvent) => void)(e)
83
+ }
84
+ }
85
+
86
+ const startContent = () => local.startAdornment ?? local.startIcon ?? local.leftIcon
87
+ const isPasswordRevealable = () => local.type === 'password' && local.revealable === true
88
+ const [showPassword, setShowPassword] = createSignal(false)
89
+ const effectiveType = () =>
90
+ isPasswordRevealable() && showPassword() ? 'text' : (local.type ?? 'text')
91
+ const endContent = () => {
92
+ if (isPasswordRevealable()) return null
93
+ return local.endAdornment ?? local.endIcon ?? local.rightIcon
94
+ }
95
+ const hasStart = () => !!startContent()
96
+ const hasEnd = () => !!endContent() || isPasswordRevealable()
97
+ const adornmentClass = 'absolute top-1/2 -translate-y-1/2 flex items-center justify-center text-ink-500 dark:text-ink-400 pointer-events-none z-10'
98
+
99
+ return (
100
+ <KobalteTextField
101
+ value={typeof local.value === 'string' ? local.value : local.value == null ? '' : String(local.value)}
102
+ onChange={handleChange}
103
+ validationState={hasError() ? 'invalid' : undefined}
104
+ required={local.required}
105
+ disabled={local.disabled}
106
+ class={cn('w-full', local.class)}
107
+ >
108
+ <Show when={!local.bare && (local.label || local.labelTrailing)}>
109
+ <div class="flex items-center justify-between gap-2 mb-1.5">
110
+ <Show when={local.label}>
111
+ <KobalteTextField.Label
112
+ class={cn(
113
+ 'block text-md font-medium',
114
+ hasError() ? 'text-danger-600' : 'text-ink-700 dark:text-ink-300'
115
+ )}
116
+ >
117
+ {local.label}
118
+ <Show when={local.required}>
119
+ <span class="text-danger-500 dark:text-danger-400 ml-0.5" aria-hidden="true">*</span>
120
+ </Show>
121
+ </KobalteTextField.Label>
122
+ </Show>
123
+ <Show when={!local.label}>
124
+ <span />
125
+ </Show>
126
+ <div class="flex items-center gap-2 flex-shrink-0">
127
+ <Show when={local.labelTrailing}>{local.labelTrailing}</Show>
128
+ <Show when={local.label && !local.required && local.optional}>
129
+ <span class="text-xs text-ink-500 dark:text-ink-400">optional</span>
130
+ </Show>
131
+ </div>
132
+ </div>
133
+ </Show>
134
+
135
+ <div class="relative flex items-center">
136
+ <Show when={startContent()}>
137
+ <div
138
+ class={cn(adornmentClass, sc().text, sc().adornStart)}
139
+ aria-hidden="true"
140
+ >
141
+ {startContent()}
142
+ </div>
143
+ </Show>
144
+
145
+ <KobalteTextField.Input
146
+ ref={local.ref}
147
+ id={local.id}
148
+ type={effectiveType()}
149
+ onInput={handleInput}
150
+ class={cn(
151
+ 'w-full rounded-lg transition-all outline-none border text-ink-900 dark:text-ink-100 placeholder:text-ink-400 dark:placeholder:text-ink-500 bg-surface-raised',
152
+ sc().h, sc().py, sc().text,
153
+ hasStart() ? sc().plAdorn : sc().pl,
154
+ hasEnd() ? sc().prAdorn : sc().pr,
155
+ hasError()
156
+ ? 'border-danger-500 focus:ring-2 focus:ring-inset focus:ring-danger-500 focus:border-transparent'
157
+ : 'border-ink-300 dark:border-ink-800 focus:ring-2 focus:ring-inset focus:ring-primary-500 focus:border-transparent',
158
+ 'disabled:bg-surface-base disabled:text-ink-500 dark:disabled:text-ink-500 disabled:cursor-not-allowed',
159
+ local.inputClass
160
+ )}
161
+ {...others}
162
+ />
163
+
164
+ <Show when={endContent()}>
165
+ <div
166
+ class={cn(adornmentClass, sc().text, sc().adornEnd)}
167
+ aria-hidden="true"
168
+ >
169
+ {endContent()}
170
+ </div>
171
+ </Show>
172
+ <Show when={isPasswordRevealable()}>
173
+ <button
174
+ type="button"
175
+ onClick={() => setShowPassword((v) => !v)}
176
+ class={cn(
177
+ 'absolute top-1/2 -translate-y-1/2 flex items-center justify-center rounded p-1 z-10 text-ink-500 dark:text-ink-400 hover:text-ink-700 dark:hover:text-ink-200 hover:bg-ink-100 dark:hover:bg-ink-800',
178
+ sc().adornEnd, sc().text
179
+ )}
180
+ aria-label={showPassword() ? 'Hide password' : 'Show password'}
181
+ >
182
+ {showPassword() ? <EyeOff class="h-4 w-4" aria-hidden="true" /> : <Eye class="h-4 w-4" aria-hidden="true" />}
183
+ </button>
184
+ </Show>
185
+ </div>
186
+
187
+ <Show when={!local.bare && local.helperText && !hasError()}>
188
+ <KobalteTextField.Description class="mt-2 text-sm text-ink-500 dark:text-ink-400">
189
+ {local.helperText}
190
+ </KobalteTextField.Description>
191
+ </Show>
192
+
193
+ <Show when={!local.bare}>
194
+ <KobalteTextField.ErrorMessage class="mt-2 text-sm text-danger-600">
195
+ {local.error}
196
+ </KobalteTextField.ErrorMessage>
197
+ </Show>
198
+ </KobalteTextField>
199
+ )
200
+ }
@@ -0,0 +1,361 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createSignal, createUniqueId, For, Show, splitProps } from 'solid-js'
3
+ import { Check, ChevronDown, GripVertical, X } from 'lucide-solid'
4
+ import { Select as KobalteSelect } from '@kobalte/core/select'
5
+ import {
6
+ DragDropProvider,
7
+ DragDropSensors,
8
+ SortableProvider,
9
+ createSortable,
10
+ } from '@thisbeyond/solid-dnd'
11
+ import { cn } from '../../utilities/classNames'
12
+ import { type ComponentSize, inputSizeConfig } from '../../utilities/componentSize'
13
+
14
+ export interface MultiSelectOption {
15
+ value: string
16
+ label: string
17
+ icon?: JSX.Element
18
+ }
19
+
20
+ export interface MultiSelectProps {
21
+ label?: string
22
+ /** Hint text below the control. Prefer helperText; description is supported for backward compatibility. */
23
+ description?: string
24
+ /** Hint text below the control (same as description). */
25
+ helperText?: string
26
+ /** Error message and invalid styling. */
27
+ error?: string
28
+ /** When true, never render label row or error/helper text (control only). */
29
+ bare?: boolean
30
+ /** When true, show required indicator on label. */
31
+ required?: boolean
32
+ /** When true, show "optional" on the label row when not required. Default false. */
33
+ optional?: boolean
34
+ options: MultiSelectOption[]
35
+ value: string[]
36
+ onChange: (value: string[]) => void
37
+ placeholder?: string
38
+ class?: string
39
+ /** When true, selected chips can be reordered via drag. Default false. */
40
+ reorderable?: boolean
41
+ /** When true, show a search input to filter options. Default false. */
42
+ searchable?: boolean
43
+ /** When true, the control is non-interactive and shows placeholder only. */
44
+ disabled?: boolean
45
+ /** Input size. Controls height, text size, and padding. Default: md (36px). */
46
+ size?: ComponentSize
47
+ /** Ref forwarded to the root wrapper div. */
48
+ ref?: (el: HTMLDivElement) => void
49
+ }
50
+
51
+ function ChipContent(props: {
52
+ opt: MultiSelectOption
53
+ onRemove: (opt: MultiSelectOption) => void
54
+ showGrip?: boolean
55
+ gripActivators?: Record<string, unknown>
56
+ isDragging?: boolean
57
+ }) {
58
+ return (
59
+ <span
60
+ class={cn(
61
+ 'inline-flex max-w-[200px] shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium',
62
+ 'bg-ink-100 text-ink-700',
63
+ props.isDragging && 'shadow-md',
64
+ )}
65
+ >
66
+ <Show when={props.showGrip}>
67
+ <button
68
+ type="button"
69
+ class="cursor-grab shrink-0 text-ink-400 hover:text-ink-600 dark:hover:text-ink-300 active:cursor-grabbing"
70
+ aria-label="Drag to reorder"
71
+ {...(props.gripActivators ?? {})}
72
+ >
73
+ <GripVertical class="h-3.5 w-3.5" aria-hidden="true" />
74
+ </button>
75
+ </Show>
76
+ <Show when={props.opt.icon}>
77
+ <span class="shrink-0 text-current opacity-70">{props.opt.icon}</span>
78
+ </Show>
79
+ <span class="min-w-0 truncate">{props.opt.label}</span>
80
+ <button
81
+ type="button"
82
+ onClick={(e) => {
83
+ e.stopPropagation()
84
+ e.preventDefault()
85
+ props.onRemove(props.opt)
86
+ }}
87
+ onPointerDown={(e) => e.stopPropagation()}
88
+ class="rounded p-0.5 hover:bg-black/10 dark:hover:bg-white/10 focus:outline-none focus:ring-1 focus:ring-ink-400 dark:focus:ring-ink-500"
89
+ aria-label={`Remove ${props.opt.label}`}
90
+ >
91
+ <X class="h-3.5 w-3.5" aria-hidden="true" />
92
+ </button>
93
+ </span>
94
+ )
95
+ }
96
+
97
+ function SortableChip(props: { opt: MultiSelectOption; onRemove: (opt: MultiSelectOption) => void }) {
98
+ const sortable = createSortable(props.opt.value)
99
+ const style = () => ({
100
+ transform: sortable.transform ? `translate3d(${sortable.transform.x}px, ${sortable.transform.y}px, 0)` : undefined,
101
+ })
102
+ return (
103
+ <span ref={sortable.ref} style={style()}>
104
+ <ChipContent
105
+ opt={props.opt}
106
+ onRemove={props.onRemove}
107
+ showGrip
108
+ gripActivators={sortable.dragActivators}
109
+ isDragging={sortable.isActiveDraggable}
110
+ />
111
+ </span>
112
+ )
113
+ }
114
+
115
+ function ChipsList(props: {
116
+ selectedOptions: MultiSelectOption[]
117
+ onRemove: (opt: MultiSelectOption) => void
118
+ reorderable: boolean
119
+ onReorder: (newOrder: string[]) => void
120
+ }) {
121
+ const ids = () => props.selectedOptions.map((o) => o.value)
122
+ const handleDragEnd = ({
123
+ draggable,
124
+ droppable,
125
+ }: {
126
+ draggable: { id: string }
127
+ droppable?: { id: string } | null
128
+ }) => {
129
+ if (!droppable || draggable.id === droppable.id) return
130
+ const fromIndex = props.selectedOptions.findIndex((o) => o.value === draggable.id)
131
+ const toIndex = props.selectedOptions.findIndex((o) => o.value === droppable!.id)
132
+ if (fromIndex === -1 || toIndex === -1) return
133
+ const next = [...props.selectedOptions]
134
+ const [moved] = next.splice(fromIndex, 1)
135
+ next.splice(toIndex, 0, moved)
136
+ props.onReorder(next.map((o) => o.value))
137
+ }
138
+
139
+ if (props.reorderable && props.selectedOptions.length > 0) {
140
+ return (
141
+ <DragDropProvider onDragEnd={handleDragEnd as (e: unknown) => void}>
142
+ <DragDropSensors />
143
+ <SortableProvider ids={ids()}>
144
+ <For each={props.selectedOptions}>
145
+ {(opt) => <SortableChip opt={opt} onRemove={props.onRemove} />}
146
+ </For>
147
+ </SortableProvider>
148
+ </DragDropProvider>
149
+ )
150
+ }
151
+ return (
152
+ <For each={props.selectedOptions}>
153
+ {(opt) => <ChipContent opt={opt} onRemove={props.onRemove} />}
154
+ </For>
155
+ )
156
+ }
157
+
158
+ export function MultiSelect(props: MultiSelectProps) {
159
+ const [local] = splitProps(props, [
160
+ 'label',
161
+ 'description',
162
+ 'helperText',
163
+ 'error',
164
+ 'bare',
165
+ 'required',
166
+ 'optional',
167
+ 'options',
168
+ 'value',
169
+ 'onChange',
170
+ 'placeholder',
171
+ 'class',
172
+ 'reorderable',
173
+ 'searchable',
174
+ 'disabled',
175
+ 'size',
176
+ 'ref',
177
+ ])
178
+ const sc = () => inputSizeConfig[local.size ?? 'md']
179
+ const helperOrDescription = () => local.helperText ?? local.description
180
+ const uid = createUniqueId()
181
+ const helperId = () => helperOrDescription() ? `ms-${uid}-help` : undefined
182
+ const errorId = () => local.error ? `ms-${uid}-error` : undefined
183
+ const describedBy = () => [helperId(), errorId()].filter(Boolean).join(' ') || undefined
184
+ const [searchQuery, setSearchQuery] = createSignal('')
185
+
186
+ const filteredOptions = (): MultiSelectOption[] => {
187
+ if (!local.searchable) return local.options
188
+ const q = searchQuery().trim().toLowerCase()
189
+ if (!q) return local.options
190
+ return local.options.filter((o) => o.label.toLowerCase().includes(q))
191
+ }
192
+
193
+ // Kobalte multiple mode uses option objects for value/onChange; we expose string[].
194
+ const selectedOptions = (): MultiSelectOption[] =>
195
+ local.value
196
+ .map((v) => local.options.find((o) => o.value === v))
197
+ .filter((o): o is MultiSelectOption => o != null)
198
+
199
+ const handleChange = (opts: MultiSelectOption | MultiSelectOption[] | null) => {
200
+ const arr = opts == null ? [] : Array.isArray(opts) ? opts : [opts]
201
+ local.onChange(arr.map((o) => o.value))
202
+ }
203
+
204
+ // Always include selected items in options so selection doesn't vanish when search filters them out
205
+ const optionsForSelect = (): MultiSelectOption[] => {
206
+ const base = filteredOptions()
207
+ const selected = selectedOptions()
208
+ const missing = selected.filter((s) => !base.some((o) => o.value === s.value))
209
+ return missing.length ? [...missing, ...base] : base
210
+ }
211
+
212
+ let searchInputRef: HTMLInputElement | undefined
213
+ const handleOpenChange = (open: boolean) => {
214
+ if (!open) setSearchQuery('')
215
+ else if (local.searchable) {
216
+ requestAnimationFrame(() => searchInputRef?.focus())
217
+ }
218
+ }
219
+
220
+ return (
221
+ <div ref={local.ref} class={cn('w-full', local.class)}>
222
+ <KobalteSelect<MultiSelectOption>
223
+ multiple
224
+ options={optionsForSelect()}
225
+ optionValue="value"
226
+ optionTextValue="label"
227
+ value={selectedOptions()}
228
+ onChange={handleChange}
229
+ placeholder={local.placeholder ?? 'Select...'}
230
+ disabled={local.disabled}
231
+ closeOnSelection={false}
232
+ onOpenChange={handleOpenChange}
233
+ itemComponent={(itemProps) => (
234
+ <KobalteSelect.Item
235
+ item={itemProps.item}
236
+ class="relative flex items-center justify-between px-4 py-2.5 text-sm cursor-pointer outline-none text-ink-900 data-[highlighted]:bg-primary-50 data-[highlighted]:text-primary-900 dark:data-[highlighted]:bg-primary-500/20 dark:data-[highlighted]:text-primary-200 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
237
+ >
238
+ <KobalteSelect.ItemLabel class="flex-1">
239
+ <span class="flex items-center gap-2">
240
+ <Show when={itemProps.item.rawValue.icon}>
241
+ <span class="flex-shrink-0 text-ink-500">{itemProps.item.rawValue.icon}</span>
242
+ </Show>
243
+ <span class="truncate">{itemProps.item.rawValue.label}</span>
244
+ </span>
245
+ </KobalteSelect.ItemLabel>
246
+ <KobalteSelect.ItemIndicator class="inline-flex items-center">
247
+ <Check class="w-4 h-4 text-primary-500" />
248
+ </KobalteSelect.ItemIndicator>
249
+ </KobalteSelect.Item>
250
+ )}
251
+ >
252
+ <Show when={!local.bare && local.label}>
253
+ <div class="flex items-center justify-between mb-2">
254
+ <KobalteSelect.Label class="block text-md font-medium text-ink-700">
255
+ {local.label}
256
+ <Show when={local.required}>
257
+ <span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>
258
+ </Show>
259
+ </KobalteSelect.Label>
260
+ <Show when={local.label && !local.required && local.optional}>
261
+ <span class="text-xs text-ink-500">optional</span>
262
+ </Show>
263
+ </div>
264
+ </Show>
265
+
266
+ <div
267
+ class={cn(
268
+ 'w-full flex flex-col min-h-0 rounded-lg border transition-all overflow-hidden',
269
+ sc().h,
270
+ local.error ? 'border-danger-500' : 'border-ink-300',
271
+ 'bg-surface-raised',
272
+ )}
273
+ >
274
+ <KobalteSelect.Trigger
275
+ as="button"
276
+ type="button"
277
+ aria-invalid={local.error ? 'true' : undefined}
278
+ aria-describedby={describedBy()}
279
+ aria-errormessage={local.error ? errorId() : undefined}
280
+ class={cn(
281
+ 'w-full h-full min-h-0 min-w-0 flex items-center gap-2 rounded-lg transition-all outline-none bg-transparent text-ink-900 text-left border-0 cursor-pointer',
282
+ sc().py, sc().text, sc().pl, sc().pr,
283
+ 'hover:bg-ink-50 dark:hover:bg-ink-800/50',
284
+ 'data-[expanded]:ring-2 data-[expanded]:ring-inset data-[expanded]:ring-primary-500',
285
+ 'data-[expanded]:hover:bg-transparent dark:data-[expanded]:hover:bg-transparent',
286
+ local.error
287
+ ? 'focus:ring-2 focus:ring-inset focus:ring-danger-500 focus:border-transparent'
288
+ : 'focus:ring-2 focus:ring-inset focus:ring-primary-500 focus:border-transparent',
289
+ 'data-[disabled]:bg-ink-50 dark:data-[disabled]:bg-ink-900 data-[disabled]:text-ink-500 dark:data-[disabled]:text-ink-500 data-[disabled]:cursor-not-allowed',
290
+ 'data-[placeholder-shown]:text-ink-400 dark:data-[placeholder-shown]:text-ink-500',
291
+ )}
292
+ >
293
+ <KobalteSelect.Value<MultiSelectOption> class="min-w-0 flex-1 flex flex-wrap items-center gap-2 basis-0">
294
+ {(state) => {
295
+ const selected = state.selectedOptions()
296
+ if (selected.length === 0) {
297
+ return (
298
+ <span class="text-ink-400">
299
+ {local.placeholder ?? 'Select...'}
300
+ </span>
301
+ )
302
+ }
303
+ return (
304
+ <ChipsList
305
+ selectedOptions={selected}
306
+ onRemove={(opt) => state.remove(opt)}
307
+ reorderable={local.reorderable ?? false}
308
+ onReorder={(newOrder) => local.onChange(newOrder)}
309
+ />
310
+ )
311
+ }}
312
+ </KobalteSelect.Value>
313
+ <KobalteSelect.Icon class="inline-flex shrink-0 w-4 items-center justify-center text-ink-400 transition-transform data-[expanded]:rotate-180">
314
+ <ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
315
+ </KobalteSelect.Icon>
316
+ </KobalteSelect.Trigger>
317
+ </div>
318
+
319
+ <Show when={!local.bare && !local.error && helperOrDescription()}>
320
+ <KobalteSelect.Description id={helperId()} class="mt-2 text-sm text-ink-500">
321
+ {helperOrDescription()}
322
+ </KobalteSelect.Description>
323
+ </Show>
324
+
325
+ <Show when={!local.bare && local.error}>
326
+ <p id={errorId()} class="mt-2 text-sm text-danger-600">{local.error}</p>
327
+ </Show>
328
+
329
+ <KobalteSelect.Portal>
330
+ <KobalteSelect.Content
331
+ class={cn(
332
+ 'bg-surface-raised rounded-lg border border-surface-border shadow-lg mt-2 z-[100] flex flex-col max-h-60',
333
+ local.searchable ? 'py-0 overflow-hidden' : 'py-1 overflow-auto',
334
+ )}
335
+ >
336
+ <Show when={local.searchable}>
337
+ <div
338
+ class="shrink-0 border-b border-surface-border p-2"
339
+ onKeyDown={(e) => e.stopPropagation()}
340
+ onPointerDown={(e) => e.stopPropagation()}
341
+ onMouseDown={(e) => e.stopPropagation()}
342
+ >
343
+ <input
344
+ ref={(el) => (searchInputRef = el)}
345
+ type="text"
346
+ value={searchQuery()}
347
+ onInput={(e) => setSearchQuery(e.currentTarget.value)}
348
+ placeholder="Search..."
349
+ class="h-9 w-full rounded-md border border-surface-border bg-surface-raised px-3 py-1.5 text-sm text-ink-900 placeholder:text-ink-400 dark:placeholder:text-ink-500 outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
350
+ />
351
+ </div>
352
+ </Show>
353
+ <div class={cn('outline-none min-h-0', local.searchable && 'flex-1 overflow-auto py-1')}>
354
+ <KobalteSelect.Listbox class="outline-none" />
355
+ </div>
356
+ </KobalteSelect.Content>
357
+ </KobalteSelect.Portal>
358
+ </KobalteSelect>
359
+ </div>
360
+ )
361
+ }