@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.
- package/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- 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
|
+
}
|