@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
+ import { createSignal, createUniqueId, splitProps, Show, type JSX } from 'solid-js'
2
+ import { Check, ChevronDown } from 'lucide-solid'
3
+ import { Select as KobalteSelect } from '@kobalte/core/select'
4
+ import { cn } from '../../utilities/classNames'
5
+ import { type ComponentSize, inputSizeConfig } from '../../utilities/componentSize'
6
+
7
+ export interface SelectOption {
8
+ value: string
9
+ label: string
10
+ /** Optional icon shown before the label. */
11
+ icon?: JSX.Element
12
+ /** Optional hex or CSS color for a status dot (e.g. #22c55e). When set, a small colored dot is shown before the label; useful for status/state fields. */
13
+ color?: string | null
14
+ }
15
+
16
+ function statusColorStyle(color: string | null | undefined): string | undefined {
17
+ if (color == null || color === '') return undefined
18
+ const t = color.trim()
19
+ if (t.startsWith('#') || t.startsWith('rgb') || /^[a-z]+$/i.test(t)) return t
20
+ return undefined
21
+ }
22
+
23
+ function StatusDot(props: { color?: string | null }) {
24
+ const style = () => {
25
+ const c = statusColorStyle(props.color)
26
+ return c ? { 'background-color': c } : undefined
27
+ }
28
+ return (
29
+ <span
30
+ class="size-2.5 shrink-0 rounded-full border border-ink-200/80/80"
31
+ classList={{ 'bg-ink-400': !statusColorStyle(props.color) }}
32
+ style={style()}
33
+ aria-hidden
34
+ />
35
+ )
36
+ }
37
+
38
+ export interface SelectProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children'> {
39
+ label?: string
40
+ error?: string
41
+ helperText?: string
42
+ /** When true, never render label row or error/helper text (control only). */
43
+ bare?: boolean
44
+ required?: boolean
45
+ /** When true, show "optional" on the label row when not required. Default false. */
46
+ optional?: boolean
47
+ options: SelectOption[]
48
+ placeholder?: string
49
+ value?: string
50
+ onValueChange?: (value: string) => void
51
+ onErrorClear?: () => void
52
+ disabled?: boolean
53
+ /** Applied to the root wrapper (label + control + helper/error). Use for layout (e.g. max-w-sm). */
54
+ class?: string
55
+ /** Applied to the trigger element for height/sizing (e.g. min-h-[50px] h-[50px]). */
56
+ triggerClass?: string
57
+ /** Input size. Controls height, text size, and padding. Default: md (36px). */
58
+ size?: ComponentSize
59
+ /** When true, show a search input in the dropdown to filter options by label. Default false. */
60
+ searchable?: boolean
61
+ /** Ref forwarded to the root wrapper div. */
62
+ ref?: (el: HTMLDivElement) => void
63
+ /** Id for the root wrapper (e.g. for aria-labelledby / label for). */
64
+ id?: string
65
+ }
66
+
67
+ export const Select = (props: SelectProps) => {
68
+ const [local, others] = splitProps(props, [
69
+ 'label',
70
+ 'error',
71
+ 'helperText',
72
+ 'bare',
73
+ 'required',
74
+ 'optional',
75
+ 'options',
76
+ 'placeholder',
77
+ 'value',
78
+ 'onValueChange',
79
+ 'onErrorClear',
80
+ 'disabled',
81
+ 'class',
82
+ 'triggerClass',
83
+ 'size',
84
+ 'searchable',
85
+ 'ref',
86
+ 'id',
87
+ ])
88
+ const [searchQuery, setSearchQuery] = createSignal('')
89
+ const sc = () => inputSizeConfig[local.size ?? 'md']
90
+
91
+ const hasError = () => !!local.error
92
+ const uid = createUniqueId()
93
+ const helperId = () => (!local.bare && local.helperText ? `select-${uid}-help` : undefined)
94
+ const errorId = () => (!local.bare && local.error ? `select-${uid}-error` : undefined)
95
+ const describedBy = () => [helperId(), errorId()].filter(Boolean).join(' ') || undefined
96
+ const selectedOption = () =>
97
+ local.value != null && local.value !== ''
98
+ ? local.options.find((opt) => opt.value === local.value)
99
+ : undefined
100
+
101
+ const filteredOptions = (): SelectOption[] => {
102
+ if (!local.searchable) return local.options
103
+ const q = searchQuery().trim().toLowerCase()
104
+ if (!q) return local.options
105
+ const selected = selectedOption()
106
+ const filtered = local.options.filter((o) => o.label.toLowerCase().includes(q))
107
+ if (selected && !filtered.some((o) => o.value === selected.value))
108
+ return [selected, ...filtered]
109
+ return filtered
110
+ }
111
+
112
+ const handleChange = (option: SelectOption | null) => {
113
+ if (local.error && local.onErrorClear) {
114
+ local.onErrorClear()
115
+ }
116
+ local.onValueChange?.(option ? option.value : '')
117
+ }
118
+
119
+ let searchInputRef: HTMLInputElement | undefined
120
+ const handleOpenChange = (open: boolean) => {
121
+ if (!open) setSearchQuery('')
122
+ else if (local.searchable) {
123
+ requestAnimationFrame(() => searchInputRef?.focus())
124
+ }
125
+ }
126
+
127
+ return (
128
+ <div ref={local.ref} id={local.id} class={cn('w-full', local.class)} {...others}>
129
+ <KobalteSelect<SelectOption>
130
+ value={selectedOption() ?? undefined}
131
+ onChange={handleChange}
132
+ options={filteredOptions()}
133
+ onOpenChange={handleOpenChange}
134
+ optionValue="value"
135
+ optionTextValue="label"
136
+ placeholder={local.placeholder || 'Select an option'}
137
+ disabled={local.disabled}
138
+ validationState={hasError() ? 'invalid' : undefined}
139
+ closeOnSelection={true}
140
+ itemComponent={(itemProps) => (
141
+ <KobalteSelect.Item
142
+ item={itemProps.item}
143
+ 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"
144
+ >
145
+ <KobalteSelect.ItemLabel class="flex-1">
146
+ <span class="flex min-w-0 items-center gap-2">
147
+ <Show when={statusColorStyle(itemProps.item.rawValue.color)}>
148
+ <StatusDot color={itemProps.item.rawValue.color} />
149
+ </Show>
150
+ <Show when={itemProps.item.rawValue.icon}>
151
+ <span class="flex-shrink-0 text-ink-500">{itemProps.item.rawValue.icon}</span>
152
+ </Show>
153
+ <span class="min-w-0 truncate">{itemProps.item.rawValue.label}</span>
154
+ </span>
155
+ </KobalteSelect.ItemLabel>
156
+ <KobalteSelect.ItemIndicator class="inline-flex items-center">
157
+ <Check class="w-4 h-4 text-primary-500" />
158
+ </KobalteSelect.ItemIndicator>
159
+ </KobalteSelect.Item>
160
+ )}
161
+ >
162
+ <Show when={!local.bare && local.label}>
163
+ <div class="flex items-center justify-between mb-2">
164
+ <KobalteSelect.Label class="block text-md font-medium text-ink-700">
165
+ {local.label}
166
+ <Show when={local.required}>
167
+ <span class="ml-0.5 text-danger-500 dark:text-danger-400" aria-hidden="true">*</span>
168
+ </Show>
169
+ </KobalteSelect.Label>
170
+ <Show when={local.label && !local.required && local.optional}>
171
+ <span class="text-xs text-ink-500">optional</span>
172
+ </Show>
173
+ </div>
174
+ </Show>
175
+
176
+ <div
177
+ class={cn(
178
+ 'w-full flex flex-col min-h-0 rounded-lg border transition-all overflow-hidden',
179
+ sc().h,
180
+ hasError()
181
+ ? 'border-danger-500 focus-within:ring-2 focus-within:ring-danger-500 focus-within:border-transparent'
182
+ : 'border-ink-300 focus-within:ring-2 focus-within:ring-primary-500 focus-within:border-transparent',
183
+ 'bg-surface-raised'
184
+ )}
185
+ >
186
+ <KobalteSelect.Trigger
187
+ aria-invalid={hasError() ? 'true' : undefined}
188
+ aria-describedby={describedBy()}
189
+ aria-errormessage={hasError() ? errorId() : undefined}
190
+ class={cn(
191
+ 'w-full h-full min-h-0 min-w-0 flex items-center gap-1 rounded-lg transition-all outline-none bg-transparent text-ink-900 text-left border-0 focus:ring-0',
192
+ sc().py, sc().text, sc().pl, sc().pr,
193
+ hasError()
194
+ ? 'focus:border-transparent'
195
+ : '',
196
+ 'disabled:bg-surface-base disabled:text-ink-500 dark:disabled:text-ink-500 disabled:cursor-not-allowed',
197
+ 'data-[placeholder-shown]:text-ink-400 dark:data-[placeholder-shown]:text-ink-500',
198
+ local.triggerClass
199
+ )}
200
+ >
201
+ <KobalteSelect.Value<SelectOption> class="min-w-0 flex-1 truncate text-left basis-0">
202
+ {(state) => {
203
+ const opt = state.selectedOption()
204
+ if (!opt) return <span class="truncate">{local.placeholder || 'Select an option'}</span>
205
+ return (
206
+ <span class="min-w-0 flex-1 truncate text-left flex items-center gap-2">
207
+ <Show when={statusColorStyle(opt.color)}>
208
+ <StatusDot color={opt.color} />
209
+ </Show>
210
+ <Show when={opt.icon}>
211
+ <span class="shrink-0 text-ink-500">{opt.icon}</span>
212
+ </Show>
213
+ <span class="min-w-0 truncate">{opt.label}</span>
214
+ </span>
215
+ )
216
+ }}
217
+ </KobalteSelect.Value>
218
+ <KobalteSelect.Icon class="inline-flex shrink-0 w-4 items-center justify-center text-ink-400">
219
+ <ChevronDown class="h-3.5 w-3.5" aria-hidden="true" />
220
+ </KobalteSelect.Icon>
221
+ </KobalteSelect.Trigger>
222
+ </div>
223
+
224
+ <Show when={!local.bare && !hasError() && local.helperText}>
225
+ <KobalteSelect.Description id={helperId()} class="mt-2 text-sm text-ink-500">
226
+ {local.helperText}
227
+ </KobalteSelect.Description>
228
+ </Show>
229
+
230
+ <Show when={!local.bare && hasError()}>
231
+ <KobalteSelect.ErrorMessage id={errorId()} class="mt-2 text-sm text-danger-600">
232
+ {local.error}
233
+ </KobalteSelect.ErrorMessage>
234
+ </Show>
235
+
236
+ <KobalteSelect.Portal>
237
+ <KobalteSelect.Content
238
+ class={cn(
239
+ 'bg-surface-raised rounded-lg border border-surface-border shadow-lg mt-2 z-[100] flex flex-col max-h-60',
240
+ local.searchable ? 'py-0 overflow-hidden' : 'py-1 overflow-auto',
241
+ )}
242
+ >
243
+ <Show when={local.searchable}>
244
+ <div
245
+ class="shrink-0 border-b border-surface-border p-2"
246
+ onKeyDown={(e) => e.stopPropagation()}
247
+ onPointerDown={(e) => e.stopPropagation()}
248
+ onMouseDown={(e) => e.stopPropagation()}
249
+ >
250
+ <input
251
+ ref={(el) => (searchInputRef = el)}
252
+ type="text"
253
+ value={searchQuery()}
254
+ onInput={(e) => setSearchQuery(e.currentTarget.value)}
255
+ placeholder="Search..."
256
+ 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"
257
+ />
258
+ </div>
259
+ </Show>
260
+ <div class={cn('outline-none min-h-0', local.searchable && 'flex-1 overflow-auto py-1')}>
261
+ <KobalteSelect.Listbox class="outline-none" />
262
+ </div>
263
+ </KobalteSelect.Content>
264
+ </KobalteSelect.Portal>
265
+ </KobalteSelect>
266
+ </div>
267
+ )
268
+ }
@@ -0,0 +1,260 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { splitProps, Show, For } from 'solid-js'
3
+ import { Slider as KobalteSlider } from '@kobalte/core/slider'
4
+ import { cn } from '../../utilities/classNames'
5
+
6
+ export interface SliderProps {
7
+ /** Label for the slider. */
8
+ label?: string
9
+ /** Optional description. */
10
+ description?: string
11
+ /** Error message and invalid styling. */
12
+ error?: string
13
+ /** Controlled value(s). Single thumb: [number], range: [min, max]. */
14
+ value?: number[]
15
+ /** Default value(s) when uncontrolled. */
16
+ defaultValue?: number[]
17
+ /** Called when value changes. */
18
+ onChange?: (value: number[]) => void
19
+ /** Called when user finishes dragging. */
20
+ onChangeEnd?: (value: number[]) => void
21
+ /** Minimum value. Default 0. */
22
+ minValue?: number
23
+ /** Maximum value. Default 100. */
24
+ maxValue?: number
25
+ /** Step. Default 1. */
26
+ step?: number
27
+ /** Minimum steps between thumbs (for range). */
28
+ minStepsBetweenThumbs?: number
29
+ /** Custom accessible value label. */
30
+ getValueLabel?: (params: { values: number[] }) => string
31
+ /** Orientation. */
32
+ orientation?: 'horizontal' | 'vertical'
33
+ /** Content before the track (e.g. icon or min label). */
34
+ startContent?: JSX.Element
35
+ /** Content after the track (e.g. icon or max label). */
36
+ endContent?: JSX.Element
37
+ /** Track thickness (height for horizontal, width for vertical). Default sm. Length is controlled by container. */
38
+ size?: 'sm' | 'md' | 'lg'
39
+ /** Track and thumb color. Default primary (theme). */
40
+ color?: 'primary' | 'indigo' | 'rose'
41
+ /** Optional marks (e.g. [0, 25, 50, 75, 100]) shown below the track, aligned with step positions. */
42
+ marks?: number[]
43
+ /** Disabled. */
44
+ disabled?: boolean
45
+ /** Name for form submission. */
46
+ name?: string
47
+ /** Root class. */
48
+ class?: string
49
+ }
50
+
51
+ export function Slider(props: SliderProps) {
52
+ const [local, others] = splitProps(props, [
53
+ 'label',
54
+ 'description',
55
+ 'error',
56
+ 'value',
57
+ 'defaultValue',
58
+ 'onChange',
59
+ 'onChangeEnd',
60
+ 'minValue',
61
+ 'maxValue',
62
+ 'step',
63
+ 'minStepsBetweenThumbs',
64
+ 'getValueLabel',
65
+ 'orientation',
66
+ 'startContent',
67
+ 'endContent',
68
+ 'size',
69
+ 'color',
70
+ 'marks',
71
+ 'disabled',
72
+ 'name',
73
+ 'class',
74
+ ])
75
+ const hasError = () => !!local.error
76
+ const thumbCount = () => {
77
+ const v = local.value ?? local.defaultValue ?? [50]
78
+ return Math.max(1, Math.min(v.length, 2))
79
+ }
80
+
81
+ const orientation = () => local.orientation ?? 'horizontal'
82
+ const isHorizontal = () => orientation() === 'horizontal'
83
+ const size = () => local.size ?? 'sm'
84
+
85
+ const trackSizeClass = () =>
86
+ isHorizontal()
87
+ ? size() === 'sm'
88
+ ? 'h-2'
89
+ : size() === 'lg'
90
+ ? 'h-4'
91
+ : 'h-3'
92
+ : size() === 'sm'
93
+ ? 'w-2'
94
+ : size() === 'lg'
95
+ ? 'w-4'
96
+ : 'w-3'
97
+ const thumbSizeClass = () =>
98
+ size() === 'sm' ? 'h-4 w-4' : size() === 'lg' ? 'h-6 w-6' : 'h-5 w-5'
99
+
100
+ const color = () => local.color ?? 'primary'
101
+ const trackBgClass = () =>
102
+ color() === 'indigo'
103
+ ? 'bg-indigo-300/80 dark:bg-indigo-500/40'
104
+ : color() === 'rose'
105
+ ? 'bg-rose-300/80 dark:bg-rose-500/40'
106
+ : 'bg-primary-300/80 dark:bg-primary-500/40'
107
+ const fillThumbClass = () =>
108
+ color() === 'indigo'
109
+ ? 'bg-indigo-500 dark:bg-indigo-400'
110
+ : color() === 'rose'
111
+ ? 'bg-rose-500 dark:bg-rose-400'
112
+ : 'bg-primary-500 dark:bg-primary-400'
113
+ const focusRingClass = () =>
114
+ color() === 'indigo'
115
+ ? 'focus-visible:ring-indigo-400 dark:focus-visible:ring-indigo-300'
116
+ : color() === 'rose'
117
+ ? 'focus-visible:ring-rose-400 dark:focus-visible:ring-rose-300'
118
+ : 'focus-visible:ring-primary-400 dark:focus-visible:ring-primary-300'
119
+
120
+ const thumbCenterStyle = (): Record<string, string> =>
121
+ isHorizontal()
122
+ ? { top: '50%', transform: 'translate(-50%, -50%)' }
123
+ : { left: '50%', transform: 'translate(-50%, -50%)' }
124
+
125
+ return (
126
+ <KobalteSlider
127
+ value={local.value}
128
+ defaultValue={local.defaultValue ?? [50]}
129
+ onChange={local.onChange}
130
+ onChangeEnd={local.onChangeEnd}
131
+ minValue={local.minValue ?? 0}
132
+ maxValue={local.maxValue ?? 100}
133
+ step={local.step ?? 1}
134
+ minStepsBetweenThumbs={local.minStepsBetweenThumbs}
135
+ getValueLabel={local.getValueLabel}
136
+ orientation={orientation()}
137
+ disabled={local.disabled}
138
+ name={local.name}
139
+ validationState={hasError() ? 'invalid' : undefined}
140
+ class={cn(
141
+ 'group/slider flex flex-col gap-1.5',
142
+ isHorizontal() ? 'w-full' : 'h-full w-fit min-h-0 flex-col items-center',
143
+ local.disabled && 'is-disabled',
144
+ local.class
145
+ )}
146
+ >
147
+ <Show when={local.label && isHorizontal()}>
148
+ <div class="flex items-center justify-between gap-2 min-w-0">
149
+ <KobalteSlider.Label
150
+ class={cn(
151
+ 'text-sm font-medium text-ink-700 shrink-0',
152
+ hasError() && 'text-danger-600 dark:text-danger-400'
153
+ )}
154
+ >
155
+ {local.label}
156
+ </KobalteSlider.Label>
157
+ <KobalteSlider.ValueLabel class="text-sm text-ink-500 shrink-0 min-w-[2.5rem] text-right tabular-nums" />
158
+ </div>
159
+ </Show>
160
+ <Show when={local.label && !isHorizontal()}>
161
+ <KobalteSlider.Label
162
+ class={cn(
163
+ 'text-sm font-medium text-ink-700',
164
+ hasError() && 'text-danger-600 dark:text-danger-400'
165
+ )}
166
+ >
167
+ {local.label}
168
+ </KobalteSlider.Label>
169
+ </Show>
170
+ <div
171
+ class={cn(
172
+ 'flex min-w-0',
173
+ isHorizontal() ? 'w-full flex-row items-center gap-2' : 'h-full min-h-0 w-fit flex-col items-center gap-2'
174
+ )}
175
+ >
176
+ <Show when={local.startContent}>
177
+ <div class="shrink-0 text-ink-500 [&>svg]:h-4 [&>svg]:w-4">
178
+ {local.startContent}
179
+ </div>
180
+ </Show>
181
+ <div class={cn('flex min-w-0 flex-col gap-1', isHorizontal() ? 'flex-1' : 'min-h-0 flex-1')}>
182
+ <KobalteSlider.Track
183
+ class={cn(
184
+ 'relative shrink-0 overflow-visible rounded-full transition-colors',
185
+ trackBgClass(),
186
+ 'group-[.is-disabled]/slider:bg-ink-100 dark:group-[.is-disabled]/slider:bg-ink-800',
187
+ trackSizeClass(),
188
+ isHorizontal() ? 'w-full min-w-0' : 'h-full min-h-0'
189
+ )}
190
+ >
191
+ <KobalteSlider.Fill
192
+ class={cn(
193
+ 'absolute rounded-full transition-colors',
194
+ isHorizontal() ? 'inset-y-0 left-0' : 'inset-x-0',
195
+ fillThumbClass(),
196
+ 'group-[.is-disabled]/slider:bg-ink-300 dark:group-[.is-disabled]/slider:bg-ink-600'
197
+ )}
198
+ />
199
+ <For each={Array.from({ length: thumbCount() })}>
200
+ {() => (
201
+ <KobalteSlider.Thumb
202
+ style={thumbCenterStyle()}
203
+ class={cn(
204
+ 'relative z-10 block rounded-full border-0 outline-none transition cursor-pointer touch-none',
205
+ thumbSizeClass(),
206
+ fillThumbClass(),
207
+ 'shadow-[0_1px_3px_rgba(0,0,0,0.12)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.4)]',
208
+ 'ring-0 hover:ring-0 focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-ink-900',
209
+ focusRingClass(),
210
+ 'group-[.is-disabled]/slider:bg-ink-300 dark:group-[.is-disabled]/slider:bg-ink-600 group-[.is-disabled]/slider:cursor-not-allowed group-[.is-disabled]/slider:shadow-none'
211
+ )}
212
+ >
213
+ <KobalteSlider.Input />
214
+ </KobalteSlider.Thumb>
215
+ )}
216
+ </For>
217
+ </KobalteSlider.Track>
218
+ <Show when={local.marks && local.marks.length > 0 && isHorizontal()}>
219
+ <div class="relative min-h-[1.25rem] w-full min-w-0 pt-0.5 text-xs text-ink-500">
220
+ <For each={local.marks!}>
221
+ {(m) => {
222
+ const min = local.minValue ?? 0
223
+ const max = local.maxValue ?? 100
224
+ const range = max - min
225
+ const pct = range === 0 ? 0 : ((m - min) / range) * 100
226
+ return (
227
+ <span
228
+ class="absolute -translate-x-1/2"
229
+ style={{ left: `${pct}%` }}
230
+ >
231
+ {m}
232
+ </span>
233
+ )
234
+ }}
235
+ </For>
236
+ </div>
237
+ </Show>
238
+ </div>
239
+ <Show when={local.endContent}>
240
+ <div class="shrink-0 text-ink-500 [&>svg]:h-4 [&>svg]:w-4">
241
+ {local.endContent}
242
+ </div>
243
+ </Show>
244
+ </div>
245
+ <Show when={local.label && !isHorizontal()}>
246
+ <KobalteSlider.ValueLabel class="text-sm text-ink-500 text-center tabular-nums" />
247
+ </Show>
248
+ <Show when={local.description}>
249
+ <KobalteSlider.Description class="mt-1 text-xs text-ink-500">
250
+ {local.description}
251
+ </KobalteSlider.Description>
252
+ </Show>
253
+ <Show when={local.error}>
254
+ <KobalteSlider.ErrorMessage class="mt-1 text-xs text-danger-600 dark:text-danger-400">
255
+ {local.error}
256
+ </KobalteSlider.ErrorMessage>
257
+ </Show>
258
+ </KobalteSlider>
259
+ )
260
+ }
@@ -0,0 +1,135 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { splitProps, Show } from 'solid-js'
3
+ import { Switch as KobalteSwitch } from '@kobalte/core/switch'
4
+ import { cn } from '../../utilities/classNames'
5
+
6
+ export interface SwitchProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
7
+ /** Label text. */
8
+ label?: string
9
+ /** Optional description below the label. */
10
+ description?: string
11
+ /** Error message and invalid styling. */
12
+ error?: string
13
+ /** When true, show required indicator on label. */
14
+ required?: boolean
15
+ /** Controlled checked state. */
16
+ checked?: boolean
17
+ /** Default checked (uncontrolled). */
18
+ defaultChecked?: boolean
19
+ /** Called when checked state changes. */
20
+ onChange?: (checked: boolean) => void
21
+ /** Disables the switch. */
22
+ disabled?: boolean
23
+ /** For form submission when checked (e.g. "on"). */
24
+ value?: string
25
+ /** Form name. */
26
+ name?: string
27
+ /** Additional class for the root wrapper. */
28
+ class?: string
29
+ /** Additional class for the control (track). */
30
+ controlClass?: string
31
+ /** Ref forwarded to the root wrapper div. */
32
+ ref?: (el: HTMLDivElement) => void
33
+ }
34
+
35
+ export function Switch(props: SwitchProps) {
36
+ const [local, others] = splitProps(props, [
37
+ 'label',
38
+ 'description',
39
+ 'error',
40
+ 'required',
41
+ 'checked',
42
+ 'defaultChecked',
43
+ 'onChange',
44
+ 'disabled',
45
+ 'value',
46
+ 'name',
47
+ 'class',
48
+ 'controlClass',
49
+ 'ref',
50
+ ])
51
+
52
+ const hasError = () => !!local.error
53
+
54
+ // If there's no visible label, the switch still needs a name.
55
+ const a11yNameOk = () =>
56
+ local.label != null ||
57
+ (others['aria-label'] as string | undefined) != null ||
58
+ (others['aria-labelledby'] as string | undefined) != null ||
59
+ (others['title'] as string | undefined) != null
60
+
61
+ if (import.meta.env.DEV && !a11yNameOk()) {
62
+ console.warn('Switch: when label is omitted, provide aria-label, aria-labelledby, or title for accessibility.')
63
+ }
64
+
65
+ // Forward "naming" attributes to the actual switch root (not just the wrapper div).
66
+ const switchA11yProps = () => ({
67
+ 'aria-label': local.label ? undefined : (others['aria-label'] as string | undefined),
68
+ 'aria-labelledby': local.label ? undefined : (others['aria-labelledby'] as string | undefined),
69
+ title: local.label ? undefined : (others['title'] as string | undefined),
70
+ })
71
+
72
+ return (
73
+ <div ref={local.ref} class={cn('w-full', local.class)} {...others}>
74
+ <KobalteSwitch
75
+ {...switchA11yProps()}
76
+ checked={local.checked}
77
+ defaultChecked={local.defaultChecked}
78
+ onChange={local.onChange}
79
+ disabled={local.disabled}
80
+ validationState={hasError() ? 'invalid' : undefined}
81
+ name={local.name}
82
+ value={local.value ?? 'on'}
83
+ >
84
+ <div class={cn(local.label ? 'flex items-center justify-between gap-3 mb-1.5' : 'inline-flex items-center gap-2')}>
85
+ <Show when={local.label}>
86
+ <KobalteSwitch.Label
87
+ class={cn(
88
+ 'text-sm font-medium cursor-pointer select-none',
89
+ hasError() ? 'text-danger-600' : 'text-ink-700',
90
+ local.disabled && 'opacity-50 cursor-not-allowed'
91
+ )}
92
+ >
93
+ {local.label}
94
+ <Show when={local.required}>
95
+ <span class="text-danger-500 ml-0.5" aria-hidden="true">*</span>
96
+ </Show>
97
+ </KobalteSwitch.Label>
98
+ </Show>
99
+
100
+ <KobalteSwitch.Input class="sr-only" />
101
+
102
+ <KobalteSwitch.Control
103
+ class={cn(
104
+ 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
105
+ 'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-surface-base',
106
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
107
+ 'bg-ink-200 data-[checked]:bg-primary-500',
108
+ hasError() && 'border-danger-500 focus:ring-danger-500',
109
+ local.controlClass
110
+ )}
111
+ >
112
+ <KobalteSwitch.Thumb
113
+ class={cn(
114
+ 'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform',
115
+ 'translate-x-0.5 data-[checked]:translate-x-5'
116
+ )}
117
+ />
118
+ </KobalteSwitch.Control>
119
+ </div>
120
+
121
+ <Show when={local.description && !hasError()}>
122
+ <KobalteSwitch.Description class="mt-1.5 text-sm text-ink-500">
123
+ {local.description}
124
+ </KobalteSwitch.Description>
125
+ </Show>
126
+
127
+ <Show when={hasError()}>
128
+ <KobalteSwitch.ErrorMessage class="mt-1.5 text-sm text-danger-600">
129
+ {local.error}
130
+ </KobalteSwitch.ErrorMessage>
131
+ </Show>
132
+ </KobalteSwitch>
133
+ </div>
134
+ )
135
+ }