@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,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
+ }