@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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete: text input with a dropdown of suggested options (combobox).
|
|
3
|
+
* Built on Kobalte Combobox. For "free solo" or creatable, use custom options or a creatable pattern in the app layer.
|
|
4
|
+
*/
|
|
5
|
+
import { createEffect, createMemo, createSignal, Show, splitProps } from 'solid-js'
|
|
6
|
+
import { type JSX } from 'solid-js'
|
|
7
|
+
import { ChevronDown, X } from 'lucide-solid'
|
|
8
|
+
import { Combobox as KobalteCombobox } from '@kobalte/core/combobox'
|
|
9
|
+
import { cn } from '../../utilities/classNames'
|
|
10
|
+
import { type ComponentSize, inputSizeConfig } from '../../utilities/componentSize'
|
|
11
|
+
|
|
12
|
+
const autocompleteStyles = `
|
|
13
|
+
.torchui-combobox-content {
|
|
14
|
+
transform-origin: var(--kb-combobox-content-transform-origin);
|
|
15
|
+
opacity: 0;
|
|
16
|
+
animation: torchui-combobox-hide 150ms ease-in forwards;
|
|
17
|
+
}
|
|
18
|
+
.torchui-combobox-content[data-expanded] {
|
|
19
|
+
opacity: 1;
|
|
20
|
+
animation: torchui-combobox-show 150ms ease-out;
|
|
21
|
+
}
|
|
22
|
+
@keyframes torchui-combobox-show {
|
|
23
|
+
from { opacity: 0; transform: translateY(-8px); }
|
|
24
|
+
to { opacity: 1; transform: translateY(0); }
|
|
25
|
+
}
|
|
26
|
+
@keyframes torchui-combobox-hide {
|
|
27
|
+
from { opacity: 1; transform: translateY(0); }
|
|
28
|
+
to { opacity: 0; transform: translateY(-8px); }
|
|
29
|
+
}
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
let comboboxStylesInjected = false
|
|
33
|
+
/** Injects animation styles once. Note: uses inline <style> which requires 'unsafe-inline' in CSP style-src. */
|
|
34
|
+
function ensureComboboxStyles() {
|
|
35
|
+
if (comboboxStylesInjected || typeof document === 'undefined') return
|
|
36
|
+
const style = document.createElement('style')
|
|
37
|
+
style.textContent = autocompleteStyles
|
|
38
|
+
document.head.appendChild(style)
|
|
39
|
+
comboboxStylesInjected = true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AutocompleteOption {
|
|
43
|
+
value: string
|
|
44
|
+
label: string
|
|
45
|
+
/** When true, option is not selectable. */
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AutocompleteProps {
|
|
50
|
+
label?: string
|
|
51
|
+
/** Error message and invalid styling. */
|
|
52
|
+
error?: string
|
|
53
|
+
/** Hint text below the control. */
|
|
54
|
+
helperText?: string
|
|
55
|
+
/** When true, never render label row or error/helper text (control only). */
|
|
56
|
+
bare?: boolean
|
|
57
|
+
/** When true, show required indicator on label. */
|
|
58
|
+
required?: boolean
|
|
59
|
+
/** When true, show "optional" on the label row when not required. Default false. */
|
|
60
|
+
optional?: boolean
|
|
61
|
+
options: AutocompleteOption[]
|
|
62
|
+
placeholder?: string
|
|
63
|
+
value?: string
|
|
64
|
+
onValueChange?: (value: string) => void
|
|
65
|
+
class?: string
|
|
66
|
+
/** Disable the control and input. */
|
|
67
|
+
disabled?: boolean
|
|
68
|
+
/** When true, hide the clear (X) button. */
|
|
69
|
+
disableClearable?: boolean
|
|
70
|
+
/** Input size. Controls height, text size, and padding. Default: md (36px). */
|
|
71
|
+
size?: ComponentSize
|
|
72
|
+
/** Disable specific options. Overrides option.disabled when provided. */
|
|
73
|
+
getOptionDisabled?: (option: AutocompleteOption) => boolean
|
|
74
|
+
/** Custom filter. Receives full options and current input value; return filtered options. Use (x) => x for async (you manage options). */
|
|
75
|
+
filterOptions?: (options: AutocompleteOption[], inputValue: string) => AutocompleteOption[]
|
|
76
|
+
/** Controlled input value (typed text). When provided with onInputChange, enables controlled input for async/search-as-you-type. */
|
|
77
|
+
inputValue?: string
|
|
78
|
+
/** Called when the user types. Use with filterOptions or for controlled input. */
|
|
79
|
+
onInputChange?: (value: string) => void
|
|
80
|
+
/** Custom render for each option. Receives the option; return JSX (e.g. label + description). */
|
|
81
|
+
renderOption?: (option: AutocompleteOption) => JSX.Element
|
|
82
|
+
/** Ref forwarded to the root wrapper div. */
|
|
83
|
+
ref?: (el: HTMLDivElement) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Option shape passed to Combobox when we add disabled from getOptionDisabled. */
|
|
87
|
+
type OptionWithDisabled = AutocompleteOption & { disabled?: boolean }
|
|
88
|
+
|
|
89
|
+
export const Autocomplete = (props: AutocompleteProps) => {
|
|
90
|
+
const [local] = splitProps(props, [
|
|
91
|
+
'label',
|
|
92
|
+
'error',
|
|
93
|
+
'helperText',
|
|
94
|
+
'bare',
|
|
95
|
+
'required',
|
|
96
|
+
'optional',
|
|
97
|
+
'options',
|
|
98
|
+
'placeholder',
|
|
99
|
+
'value',
|
|
100
|
+
'onValueChange',
|
|
101
|
+
'class',
|
|
102
|
+
'disabled',
|
|
103
|
+
'disableClearable',
|
|
104
|
+
'size',
|
|
105
|
+
'getOptionDisabled',
|
|
106
|
+
'filterOptions',
|
|
107
|
+
'inputValue',
|
|
108
|
+
'onInputChange',
|
|
109
|
+
'renderOption',
|
|
110
|
+
'ref',
|
|
111
|
+
])
|
|
112
|
+
const hasError = () => !!local.error
|
|
113
|
+
|
|
114
|
+
const [inputValueState, setInputValueState] = createSignal('')
|
|
115
|
+
const [dirty, setDirty] = createSignal(false)
|
|
116
|
+
|
|
117
|
+
const inputValue = () => local.inputValue ?? inputValueState()
|
|
118
|
+
|
|
119
|
+
createEffect(() => {
|
|
120
|
+
if (local.inputValue !== undefined) return
|
|
121
|
+
if (dirty()) return
|
|
122
|
+
const opt = local.options.find((o) => o.value === local.value) ?? null
|
|
123
|
+
setInputValueState(opt?.label ?? '')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const optionsWithDisabled = (opts: AutocompleteOption[]): OptionWithDisabled[] =>
|
|
127
|
+
local.getOptionDisabled ? opts.map((o) => ({ ...o, disabled: local.getOptionDisabled!(o) })) : opts
|
|
128
|
+
|
|
129
|
+
const optionsForRoot = createMemo<OptionWithDisabled[]>(() => {
|
|
130
|
+
let base = local.filterOptions && dirty()
|
|
131
|
+
? local.filterOptions(local.options, inputValue())
|
|
132
|
+
: local.options
|
|
133
|
+
const selected = local.options.find((o) => o.value === local.value)
|
|
134
|
+
if (selected && !base.some((o) => o.value === selected.value)) {
|
|
135
|
+
base = [selected, ...base]
|
|
136
|
+
}
|
|
137
|
+
return optionsWithDisabled(base)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const selectedOption = createMemo<OptionWithDisabled | null>(() => {
|
|
141
|
+
if (!local.value) return null
|
|
142
|
+
return optionsForRoot().find((o: OptionWithDisabled) => o.value === local.value) ?? null
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const handleChange = (option: OptionWithDisabled | null) => {
|
|
146
|
+
setDirty(false)
|
|
147
|
+
if (!option) {
|
|
148
|
+
if (local.inputValue === undefined) setInputValueState('')
|
|
149
|
+
if (local.value) local.onValueChange?.('')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
local.onValueChange?.(option.value)
|
|
153
|
+
if (local.inputValue === undefined) setInputValueState(option.label)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const handleInputChange = (value: string) => {
|
|
157
|
+
setDirty(true)
|
|
158
|
+
if (local.inputValue === undefined) setInputValueState(value)
|
|
159
|
+
local.onInputChange?.(value)
|
|
160
|
+
if (value === '') {
|
|
161
|
+
local.onValueChange?.('')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const handleClear = (e: MouseEvent) => {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
e.stopPropagation()
|
|
168
|
+
setDirty(false)
|
|
169
|
+
if (local.inputValue === undefined) setInputValueState('')
|
|
170
|
+
local.onInputChange?.('')
|
|
171
|
+
local.onValueChange?.('')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ensureComboboxStyles()
|
|
175
|
+
const sc = () => inputSizeConfig[local.size ?? 'md']
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div ref={local.ref} class={cn('w-full', local.class)}>
|
|
179
|
+
<KobalteCombobox<OptionWithDisabled>
|
|
180
|
+
options={optionsForRoot()}
|
|
181
|
+
optionValue="value"
|
|
182
|
+
optionTextValue="label"
|
|
183
|
+
optionLabel="label"
|
|
184
|
+
optionDisabled="disabled"
|
|
185
|
+
value={selectedOption()}
|
|
186
|
+
defaultFilter={local.filterOptions ? undefined : 'contains'}
|
|
187
|
+
triggerMode="input"
|
|
188
|
+
disabled={local.disabled}
|
|
189
|
+
onChange={handleChange}
|
|
190
|
+
onInputChange={handleInputChange}
|
|
191
|
+
itemComponent={(itemProps) => (
|
|
192
|
+
<KobalteCombobox.Item
|
|
193
|
+
item={itemProps.item}
|
|
194
|
+
class="relative flex items-center justify-between px-3 py-2 text-sm text-ink-900 cursor-pointer outline-none 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"
|
|
195
|
+
>
|
|
196
|
+
{local.renderOption ? (
|
|
197
|
+
local.renderOption(itemProps.item.rawValue)
|
|
198
|
+
) : (
|
|
199
|
+
<span>{itemProps.item.rawValue.label}</span>
|
|
200
|
+
)}
|
|
201
|
+
</KobalteCombobox.Item>
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<KobalteCombobox.HiddenSelect />
|
|
205
|
+
<Show when={!local.bare && local.label}>
|
|
206
|
+
<div class="flex items-center justify-between mb-2">
|
|
207
|
+
<KobalteCombobox.Label class="block text-md font-medium text-ink-700">
|
|
208
|
+
{local.label}
|
|
209
|
+
<Show when={local.required}>
|
|
210
|
+
<span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>
|
|
211
|
+
</Show>
|
|
212
|
+
</KobalteCombobox.Label>
|
|
213
|
+
<Show when={local.label && !local.required && local.optional}>
|
|
214
|
+
<span class="text-xs text-ink-500">optional</span>
|
|
215
|
+
</Show>
|
|
216
|
+
</div>
|
|
217
|
+
</Show>
|
|
218
|
+
<KobalteCombobox.Control
|
|
219
|
+
class={cn(
|
|
220
|
+
'w-full flex cursor-pointer items-center justify-between gap-2 rounded-lg transition-colors outline-none text-ink-900 bg-surface-raised border',
|
|
221
|
+
hasError() ? 'border-danger-500 focus-within:ring-2 focus-within:ring-inset focus-within:ring-danger-500' : 'border-ink-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-primary-500 focus-within:border-transparent',
|
|
222
|
+
sc().h, sc().py, sc().text, sc().pl, sc().pr,
|
|
223
|
+
local.disabled &&
|
|
224
|
+
'cursor-not-allowed bg-surface-base text-ink-500 pointer-events-none'
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
<KobalteCombobox.Input
|
|
228
|
+
class="flex-1 min-w-0 bg-transparent outline-none text-ink-900 placeholder:text-ink-400 dark:placeholder:text-ink-500 disabled:cursor-not-allowed"
|
|
229
|
+
placeholder={local.placeholder || 'Search...'}
|
|
230
|
+
disabled={local.disabled}
|
|
231
|
+
/>
|
|
232
|
+
<Show when={!local.disableClearable}>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
aria-label="Clear"
|
|
236
|
+
class={cn(
|
|
237
|
+
'shrink-0 rounded p-0.5 text-ink-400 hover:bg-ink-100 dark:hover:bg-ink-900 hover:text-ink-600 dark:hover:text-ink-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-inset',
|
|
238
|
+
!local.value && inputValue().length === 0 && 'invisible'
|
|
239
|
+
)}
|
|
240
|
+
tabIndex={!local.value && inputValue().length === 0 ? -1 : 0}
|
|
241
|
+
onClick={handleClear}
|
|
242
|
+
>
|
|
243
|
+
<X class="h-4 w-4" aria-hidden="true" />
|
|
244
|
+
</button>
|
|
245
|
+
</Show>
|
|
246
|
+
<KobalteCombobox.Trigger
|
|
247
|
+
class="shrink-0 rounded p-0.5 text-ink-400 hover:bg-ink-100 dark:hover:bg-ink-900 hover:text-ink-600 dark:hover:text-ink-300"
|
|
248
|
+
aria-label="Open options"
|
|
249
|
+
>
|
|
250
|
+
<ChevronDown class="h-4 w-4" aria-hidden="true" />
|
|
251
|
+
</KobalteCombobox.Trigger>
|
|
252
|
+
</KobalteCombobox.Control>
|
|
253
|
+
<Show when={!local.bare && !hasError() && local.helperText}>
|
|
254
|
+
<p class="mt-2 text-sm text-ink-500">{local.helperText}</p>
|
|
255
|
+
</Show>
|
|
256
|
+
<Show when={!local.bare && hasError()}>
|
|
257
|
+
<p class="mt-2 text-sm text-danger-600">{local.error}</p>
|
|
258
|
+
</Show>
|
|
259
|
+
|
|
260
|
+
<KobalteCombobox.Portal>
|
|
261
|
+
<KobalteCombobox.Content class="torchui-combobox-content bg-surface-raised rounded-lg border border-surface-border shadow-lg mt-2 py-1 max-h-60 overflow-auto z-50">
|
|
262
|
+
<KobalteCombobox.Listbox class="outline-none" />
|
|
263
|
+
</KobalteCombobox.Content>
|
|
264
|
+
</KobalteCombobox.Portal>
|
|
265
|
+
</KobalteCombobox>
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type JSX, splitProps, Show } from 'solid-js'
|
|
2
|
+
import { Checkbox as KobalteCheckbox } from '@kobalte/core/checkbox'
|
|
3
|
+
import { cn } from '../../utilities/classNames'
|
|
4
|
+
|
|
5
|
+
export type CheckboxSize = 'sm' | 'md'
|
|
6
|
+
|
|
7
|
+
export interface CheckboxProps extends Omit<JSX.HTMLAttributes<HTMLInputElement>, 'onChange'> {
|
|
8
|
+
/** Label text (or use children). */
|
|
9
|
+
label?: string
|
|
10
|
+
/** Error message shown below the checkbox. */
|
|
11
|
+
error?: string
|
|
12
|
+
/** Hint text below the checkbox. */
|
|
13
|
+
helperText?: string
|
|
14
|
+
/** When true, never render label row or error/helper text (checkbox only). */
|
|
15
|
+
bare?: boolean
|
|
16
|
+
/** When true, show as required (e.g. asterisk). */
|
|
17
|
+
required?: boolean
|
|
18
|
+
/** When true, show "optional" on the label row when not required. Default false. */
|
|
19
|
+
optional?: boolean
|
|
20
|
+
/** When true, use smaller label text and optional tighter spacing. */
|
|
21
|
+
compact?: boolean
|
|
22
|
+
/** Controlled checked state. */
|
|
23
|
+
checked?: boolean
|
|
24
|
+
/** Controlled change handler. Receives the new checked boolean. */
|
|
25
|
+
onChange?: (checked: boolean) => void
|
|
26
|
+
/** Visual size. */
|
|
27
|
+
size?: CheckboxSize
|
|
28
|
+
/** Indeterminate state (e.g. parent when some children selected). */
|
|
29
|
+
indeterminate?: boolean
|
|
30
|
+
/** Disable the checkbox. */
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
class?: string
|
|
33
|
+
id?: string
|
|
34
|
+
/** Form field name. */
|
|
35
|
+
name?: string
|
|
36
|
+
/** Form field value. */
|
|
37
|
+
value?: string
|
|
38
|
+
children?: JSX.Element
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sizeClasses: Record<CheckboxSize, string> = {
|
|
42
|
+
sm: 'h-3.5 w-3.5',
|
|
43
|
+
md: 'h-4 w-4',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Checkbox(props: CheckboxProps) {
|
|
47
|
+
const [local, others] = splitProps(props, [
|
|
48
|
+
'label',
|
|
49
|
+
'error',
|
|
50
|
+
'helperText',
|
|
51
|
+
'bare',
|
|
52
|
+
'required',
|
|
53
|
+
'optional',
|
|
54
|
+
'compact',
|
|
55
|
+
'checked',
|
|
56
|
+
'onChange',
|
|
57
|
+
'size',
|
|
58
|
+
'indeterminate',
|
|
59
|
+
'class',
|
|
60
|
+
'id',
|
|
61
|
+
'disabled',
|
|
62
|
+
'children',
|
|
63
|
+
'name',
|
|
64
|
+
'value',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
if (import.meta.env.DEV && local.bare) {
|
|
68
|
+
if (
|
|
69
|
+
others['aria-label'] == null &&
|
|
70
|
+
others['aria-labelledby'] == null &&
|
|
71
|
+
others['title'] == null
|
|
72
|
+
) {
|
|
73
|
+
console.warn('Checkbox: bare mode requires aria-label, aria-labelledby, or title for accessibility.')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const hasError = () => !!local.error
|
|
78
|
+
const size = () => local.size ?? 'md'
|
|
79
|
+
const iconSize = () => size() === 'sm' ? 'w-2.5 h-2.5' : 'w-3 h-3'
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<KobalteCheckbox
|
|
83
|
+
checked={local.checked}
|
|
84
|
+
onChange={(v) => local.onChange?.(v === true)}
|
|
85
|
+
indeterminate={local.indeterminate}
|
|
86
|
+
validationState={hasError() ? 'invalid' : undefined}
|
|
87
|
+
required={local.required}
|
|
88
|
+
disabled={local.disabled}
|
|
89
|
+
name={local.name}
|
|
90
|
+
value={local.value}
|
|
91
|
+
id={local.id}
|
|
92
|
+
class={cn('w-full', local.class)}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
class={cn(
|
|
96
|
+
'inline-flex items-center select-none',
|
|
97
|
+
local.compact ? 'gap-1.5' : 'gap-2',
|
|
98
|
+
local.disabled && 'opacity-50',
|
|
99
|
+
hasError() && 'text-danger-600',
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<KobalteCheckbox.Input {...others} />
|
|
103
|
+
<KobalteCheckbox.Control
|
|
104
|
+
class={cn(
|
|
105
|
+
'relative inline-flex shrink-0 items-center justify-center rounded border cursor-pointer',
|
|
106
|
+
sizeClasses[size()],
|
|
107
|
+
'bg-surface-raised border-ink-300',
|
|
108
|
+
'data-[checked]:border-primary-500 data-[checked]:bg-primary-500 dark:data-[checked]:border-primary-400 dark:data-[checked]:bg-primary-500',
|
|
109
|
+
'data-[indeterminate]:border-primary-500 data-[indeterminate]:bg-primary-500 dark:data-[indeterminate]:border-primary-400 dark:data-[indeterminate]:bg-primary-500',
|
|
110
|
+
hasError() && 'border-danger-500 dark:border-danger-500',
|
|
111
|
+
local.disabled && 'cursor-not-allowed',
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<KobalteCheckbox.Indicator class="absolute inset-0 flex items-center justify-center text-white">
|
|
115
|
+
<Show
|
|
116
|
+
when={!local.indeterminate}
|
|
117
|
+
fallback={
|
|
118
|
+
<svg class={iconSize()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
119
|
+
<line x1="4" y1="12" x2="20" y2="12" />
|
|
120
|
+
</svg>
|
|
121
|
+
}
|
|
122
|
+
>
|
|
123
|
+
<svg class={iconSize()} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
124
|
+
<polyline points="20 6 9 17 4 12" />
|
|
125
|
+
</svg>
|
|
126
|
+
</Show>
|
|
127
|
+
</KobalteCheckbox.Indicator>
|
|
128
|
+
</KobalteCheckbox.Control>
|
|
129
|
+
<Show when={!local.bare && (local.label ?? local.children)}>
|
|
130
|
+
<KobalteCheckbox.Label class={cn('text-ink-700 cursor-pointer', local.compact ? 'text-xs' : 'text-sm', local.disabled && 'cursor-not-allowed')}>
|
|
131
|
+
{local.label ?? local.children}
|
|
132
|
+
<Show when={local.required}>
|
|
133
|
+
<span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>
|
|
134
|
+
</Show>
|
|
135
|
+
<Show when={!local.required && local.optional}>
|
|
136
|
+
<span class="text-xs text-ink-500 ml-1">optional</span>
|
|
137
|
+
</Show>
|
|
138
|
+
</KobalteCheckbox.Label>
|
|
139
|
+
</Show>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<Show when={!local.bare && local.helperText && !hasError()}>
|
|
143
|
+
<KobalteCheckbox.Description class="mt-1.5 text-sm text-ink-500">
|
|
144
|
+
{local.helperText}
|
|
145
|
+
</KobalteCheckbox.Description>
|
|
146
|
+
</Show>
|
|
147
|
+
|
|
148
|
+
<Show when={!local.bare && local.error}>
|
|
149
|
+
<KobalteCheckbox.ErrorMessage class="mt-1.5 text-sm text-danger-600">
|
|
150
|
+
{local.error}
|
|
151
|
+
</KobalteCheckbox.ErrorMessage>
|
|
152
|
+
</Show>
|
|
153
|
+
</KobalteCheckbox>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createEffect, type JSX, splitProps, createUniqueId } from 'solid-js'
|
|
2
|
+
import { cn } from '../../utilities/classNames'
|
|
3
|
+
import { mergeRefs } from '../../utilities/mergeRefs'
|
|
4
|
+
|
|
5
|
+
export interface CodeInputProps
|
|
6
|
+
extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'type' | 'maxLength'> {
|
|
7
|
+
/** 'single' = one input; 'digits' = one input per digit with paste/keyboard nav (e.g. verification code) */
|
|
8
|
+
variant?: 'single' | 'digits'
|
|
9
|
+
label?: string
|
|
10
|
+
error?: string
|
|
11
|
+
helperText?: string
|
|
12
|
+
/** Length of code (default 6) */
|
|
13
|
+
length?: number
|
|
14
|
+
value?: string
|
|
15
|
+
onValueChange?: (value: string) => void
|
|
16
|
+
onErrorClear?: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CodeInput(props: CodeInputProps) {
|
|
20
|
+
const variant = () => props.variant ?? 'single'
|
|
21
|
+
if (variant() === 'digits') {
|
|
22
|
+
return <CodeInputDigits {...props} />
|
|
23
|
+
}
|
|
24
|
+
return <CodeInputSingle {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CodeInputSingle(props: CodeInputProps) {
|
|
28
|
+
const [local, others] = splitProps(props, [
|
|
29
|
+
'variant',
|
|
30
|
+
'label',
|
|
31
|
+
'error',
|
|
32
|
+
'helperText',
|
|
33
|
+
'length',
|
|
34
|
+
'value',
|
|
35
|
+
'onValueChange',
|
|
36
|
+
'onErrorClear',
|
|
37
|
+
'class',
|
|
38
|
+
'id',
|
|
39
|
+
'onInput',
|
|
40
|
+
'disabled',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const length = () => local.length ?? 6
|
|
44
|
+
const generatedId = createUniqueId()
|
|
45
|
+
const inputId = () => local.id ?? `code-input-${generatedId}`
|
|
46
|
+
const hasError = () => !!local.error
|
|
47
|
+
const msgId = () => (local.error || local.helperText) ? `${inputId()}-msg` : undefined
|
|
48
|
+
const describedBy = () => {
|
|
49
|
+
const user = (others as Record<string, unknown>)['aria-describedby'] as string | undefined
|
|
50
|
+
const own = msgId()
|
|
51
|
+
return user && own ? `${user} ${own}` : (user ?? own)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleInput = (e: InputEvent) => {
|
|
55
|
+
const input = e.target as HTMLInputElement
|
|
56
|
+
const raw = input.value.replace(/\D/g, '').slice(0, length())
|
|
57
|
+
input.value = raw
|
|
58
|
+
if (local.error && local.onErrorClear) local.onErrorClear()
|
|
59
|
+
local.onValueChange?.(raw)
|
|
60
|
+
if (local.onInput) (local.onInput as (e: InputEvent) => void)(e)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div class="w-full">
|
|
65
|
+
{local.label && (
|
|
66
|
+
<label
|
|
67
|
+
for={inputId()}
|
|
68
|
+
class={cn(
|
|
69
|
+
'block text-md font-medium mb-1.5',
|
|
70
|
+
hasError() ? 'text-danger-600' : 'text-ink-700'
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{local.label}
|
|
74
|
+
</label>
|
|
75
|
+
)}
|
|
76
|
+
<input
|
|
77
|
+
id={inputId()}
|
|
78
|
+
type="text"
|
|
79
|
+
inputMode="numeric"
|
|
80
|
+
autocomplete="one-time-code"
|
|
81
|
+
pattern="[0-9]*"
|
|
82
|
+
maxLength={length()}
|
|
83
|
+
value={local.value}
|
|
84
|
+
onInput={handleInput}
|
|
85
|
+
disabled={local.disabled}
|
|
86
|
+
aria-invalid={hasError() ? 'true' : undefined}
|
|
87
|
+
aria-describedby={describedBy()}
|
|
88
|
+
class={cn(
|
|
89
|
+
'w-full px-4 py-3 rounded-lg transition-all outline-none text-center text-xl tracking-[0.25em] font-mono',
|
|
90
|
+
'text-ink-900 placeholder:text-ink-400 dark:placeholder:text-ink-500 placeholder:tracking-normal',
|
|
91
|
+
'bg-surface-raised border',
|
|
92
|
+
hasError()
|
|
93
|
+
? 'border-danger-500 focus:ring-2 focus:ring-inset focus:ring-danger-500 focus:border-transparent'
|
|
94
|
+
: 'border-ink-300 focus:ring-2 focus:ring-inset focus:ring-primary-500 focus:border-transparent',
|
|
95
|
+
'disabled:bg-ink-50 dark:disabled:bg-ink-900 disabled:text-ink-500 dark:disabled:text-ink-500 disabled:cursor-not-allowed',
|
|
96
|
+
local.class
|
|
97
|
+
)}
|
|
98
|
+
{...others}
|
|
99
|
+
/>
|
|
100
|
+
{(local.error || local.helperText) && (
|
|
101
|
+
<p
|
|
102
|
+
id={msgId()}
|
|
103
|
+
role={local.error ? 'alert' : undefined}
|
|
104
|
+
class={cn('mt-2 text-sm', hasError() ? 'text-danger-600' : 'text-ink-500')}
|
|
105
|
+
>
|
|
106
|
+
{local.error || local.helperText}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function CodeInputDigits(props: CodeInputProps) {
|
|
114
|
+
const length = () => props.length ?? 6
|
|
115
|
+
let inputRefs: (HTMLInputElement | undefined)[] = []
|
|
116
|
+
|
|
117
|
+
createEffect(() => {
|
|
118
|
+
inputRefs = new Array(length())
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const emit = (next: string) => {
|
|
122
|
+
if (props.error && props.onErrorClear) props.onErrorClear()
|
|
123
|
+
props.onValueChange?.(next)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
createEffect(() => {
|
|
127
|
+
const val = (props.value ?? '').replace(/\D/g, '').slice(0, length())
|
|
128
|
+
if (val !== (props.value ?? '')) emit(val)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const onInput = (index: number, e: InputEvent) => {
|
|
132
|
+
const target = e.currentTarget as HTMLInputElement
|
|
133
|
+
const raw = (target.value ?? '').replace(/\D/g, '')
|
|
134
|
+
const val = props.value ?? ''
|
|
135
|
+
if (!raw) {
|
|
136
|
+
emit((val.slice(0, index) + val.slice(index + 1)).slice(0, length()))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const rest = raw.slice(0, length() - index)
|
|
140
|
+
const next = (val.slice(0, index) + rest).slice(0, length())
|
|
141
|
+
emit(next)
|
|
142
|
+
requestAnimationFrame(() =>
|
|
143
|
+
inputRefs[Math.min(index + rest.length, length() - 1)]?.focus()
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const onKeyDown = (index: number, e: KeyboardEvent) => {
|
|
148
|
+
const val = props.value ?? ''
|
|
149
|
+
if (e.key === 'Backspace' && !val[index] && index > 0) {
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
emit(val.slice(0, index - 1) + val.slice(index))
|
|
152
|
+
requestAnimationFrame(() => inputRefs[index - 1]?.focus())
|
|
153
|
+
} else if (e.key === 'Backspace' && val[index]) {
|
|
154
|
+
emit(val.slice(0, index) + val.slice(index + 1))
|
|
155
|
+
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
inputRefs[index - 1]?.focus()
|
|
158
|
+
} else if (e.key === 'ArrowRight' && index < length() - 1) {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
inputRefs[index + 1]?.focus()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
165
|
+
e.preventDefault()
|
|
166
|
+
const pasted = (e.clipboardData?.getData('text') ?? '')
|
|
167
|
+
.replace(/\D/g, '')
|
|
168
|
+
.slice(0, length())
|
|
169
|
+
if (!pasted) return
|
|
170
|
+
emit(pasted)
|
|
171
|
+
requestAnimationFrame(() =>
|
|
172
|
+
inputRefs[Math.min(pasted.length, length() - 1)]?.focus()
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const generatedId = createUniqueId()
|
|
177
|
+
const firstId = () => props.id ?? `code-digits-${generatedId}`
|
|
178
|
+
const hasError = () => !!props.error
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div class={cn('w-full', props.class)}>
|
|
182
|
+
{props.label && (
|
|
183
|
+
<label
|
|
184
|
+
for={firstId()}
|
|
185
|
+
class={cn(
|
|
186
|
+
'block text-sm font-medium mb-2',
|
|
187
|
+
hasError() ? 'text-danger-600' : 'text-ink-700'
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
{props.label}
|
|
191
|
+
</label>
|
|
192
|
+
)}
|
|
193
|
+
<div
|
|
194
|
+
class="flex gap-2 justify-center"
|
|
195
|
+
role="group"
|
|
196
|
+
aria-label="Verification code digits"
|
|
197
|
+
>
|
|
198
|
+
{Array.from({ length: length() }, (_, i) => (
|
|
199
|
+
<input
|
|
200
|
+
ref={i === 0 ? mergeRefs((el: HTMLInputElement) => (inputRefs[i] = el), props.ref) : (el: HTMLInputElement) => (inputRefs[i] = el)}
|
|
201
|
+
id={i === 0 ? firstId() : undefined}
|
|
202
|
+
type="text"
|
|
203
|
+
inputMode="numeric"
|
|
204
|
+
autocomplete={i === 0 ? 'one-time-code' : 'off'}
|
|
205
|
+
maxLength={1}
|
|
206
|
+
value={(props.value ?? '')[i] ?? ''}
|
|
207
|
+
onInput={(e) => onInput(i, e)}
|
|
208
|
+
onKeyDown={(e) => onKeyDown(i, e)}
|
|
209
|
+
onPaste={onPaste}
|
|
210
|
+
disabled={props.disabled}
|
|
211
|
+
class={cn(
|
|
212
|
+
'w-11 h-12 rounded-lg border text-center text-xl font-semibold font-mono tabular-nums outline-none transition-colors',
|
|
213
|
+
'text-ink-900 bg-surface-raised',
|
|
214
|
+
hasError()
|
|
215
|
+
? 'border-danger-500 focus:ring-2 focus:ring-danger-500/20 focus:border-danger-500'
|
|
216
|
+
: 'border-ink-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20',
|
|
217
|
+
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
218
|
+
)}
|
|
219
|
+
aria-label={`Digit ${i + 1}`}
|
|
220
|
+
aria-invalid={hasError() ? 'true' : undefined}
|
|
221
|
+
/>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
{(props.error || props.helperText) && (
|
|
225
|
+
<p
|
|
226
|
+
class={cn(
|
|
227
|
+
'mt-1.5 text-sm',
|
|
228
|
+
hasError() ? 'text-danger-600' : 'text-ink-500'
|
|
229
|
+
)}
|
|
230
|
+
role={props.error ? 'alert' : undefined}
|
|
231
|
+
>
|
|
232
|
+
{props.error || props.helperText}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|