@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,469 @@
1
+ import { createSignal, createMemo, Show, For, splitProps, createEffect } from 'solid-js'
2
+ import { Pipette, X } from 'lucide-solid'
3
+ import { Popover as KobaltePopover } from '@kobalte/core/popover'
4
+ import { ColorArea as KobalteColorArea } from '@kobalte/core/color-area'
5
+ import { ColorSlider as KobalteColorSlider } from '@kobalte/core/color-slider'
6
+ import { ColorChannelField as KobalteColorChannelField } from '@kobalte/core/color-channel-field'
7
+ import { parseColor as KobalteParseColor } from '@kobalte/core/colors'
8
+ import type { Color as KobalteColor } from '@kobalte/core/colors'
9
+ import { Button } from '../../actions'
10
+ import { cn } from '../../../utilities/classNames'
11
+ import { normalizeHex, hexToHslString, rgbaToHex } from './color-utils'
12
+
13
+ const DEFAULT_PRESETS = [
14
+ '#000000',
15
+ '#374151',
16
+ '#6b7280',
17
+ '#9ca3af',
18
+ '#d1d5db',
19
+ '#ffffff',
20
+ '#ef4444',
21
+ '#f97316',
22
+ '#eab308',
23
+ '#22c55e',
24
+ '#3b82f6',
25
+ '#8b5cf6',
26
+ '#ec4899',
27
+ ] as const
28
+
29
+ export type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb'
30
+
31
+ export interface ColorPickerProps {
32
+ /** Current value as hex (e.g. #3b82f6). */
33
+ value?: string
34
+ onValueChange?: (hex: string) => void
35
+ /** Preset hex colors shown as swatches. Defaults to a built-in set. */
36
+ presets?: string[]
37
+ /** Optional label above the control. */
38
+ label?: string
39
+ /** If true, only show the trigger + modal (no preset strip). */
40
+ compact?: boolean
41
+ disabled?: boolean
42
+ class?: string
43
+ /** Max number of "last used" colors to keep. 0 to hide. Default 9. */
44
+ lastUsedCount?: number
45
+ /** Which format(s) to show in the custom panel. Default ['hex']. Use more for Hex/RGB/HSL/HSB tabs. */
46
+ allowedFormats?: ColorFormat[]
47
+ /** Predefined hex colors shown at the bottom of the custom panel, below last used. Use for theme presets etc. */
48
+ predefined?: string[]
49
+ }
50
+
51
+ /** Returns 6-digit hex only. Used for Apply and public value (alpha not persisted). */
52
+ function colorToHex(c: KobalteColor): string {
53
+ const rgb = c.toFormat('rgb')
54
+ const r = Math.round(rgb.getChannelValue('red'))
55
+ const g = Math.round(rgb.getChannelValue('green'))
56
+ const b = Math.round(rgb.getChannelValue('blue'))
57
+ return rgbaToHex(r, g, b, 1)
58
+ }
59
+
60
+ function safeParseColor(hex: string): KobalteColor {
61
+ const normalized = normalizeHex(hex)
62
+ if (!normalized) return KobalteParseColor('#000000')
63
+ try {
64
+ return KobalteParseColor(hexToHslString(normalized))
65
+ } catch {
66
+ return KobalteParseColor('#000000')
67
+ }
68
+ }
69
+
70
+ export function ColorPicker(props: ColorPickerProps) {
71
+ const [local, rest] = splitProps(props, [
72
+ 'value',
73
+ 'onValueChange',
74
+ 'presets',
75
+ 'label',
76
+ 'compact',
77
+ 'disabled',
78
+ 'class',
79
+ 'lastUsedCount',
80
+ 'allowedFormats',
81
+ 'predefined',
82
+ ])
83
+
84
+ const presets = () => local.presets ?? [...DEFAULT_PRESETS]
85
+ const lastUsedMax = () => local.lastUsedCount ?? 9
86
+
87
+ const [customOpen, setCustomOpen] = createSignal(false)
88
+ const [lastUsed, setLastUsed] = createSignal<string[]>([])
89
+
90
+ const currentHex = () => {
91
+ const v = local.value?.trim()
92
+ if (!v) return ''
93
+ return normalizeHex(v)
94
+ }
95
+
96
+ const addToLastUsed = (hex: string) => {
97
+ const n = normalizeHex(hex)
98
+ if (!n) return
99
+ setLastUsed((prev) => {
100
+ const next = [n, ...prev.filter((c) => c !== n)].slice(0, lastUsedMax())
101
+ return next
102
+ })
103
+ }
104
+
105
+ const handlePresetClick = (hex: string) => {
106
+ const n = normalizeHex(hex)
107
+ if (n) {
108
+ local.onValueChange?.(n)
109
+ addToLastUsed(n)
110
+ }
111
+ }
112
+
113
+ const handleCustomApply = (hex: string) => {
114
+ const n = normalizeHex(hex)
115
+ if (!n) return
116
+ local.onValueChange?.(n)
117
+ addToLastUsed(n)
118
+ setCustomOpen(false)
119
+ }
120
+
121
+ const presetSet = createMemo(() => new Set(presets().map(normalizeHex).filter(Boolean)))
122
+ const isPreset = (hex: string) => presetSet().has(normalizeHex(hex))
123
+
124
+ return (
125
+ <div class={cn('w-full', local.class)} {...rest}>
126
+ <Show when={local.label}>
127
+ <label class="mb-1.5 block text-sm font-medium text-ink-700">{local.label}</label>
128
+ </Show>
129
+
130
+ <div class="flex flex-wrap items-center gap-2">
131
+ {/* Current color swatch + trigger for custom */}
132
+ <KobaltePopover open={customOpen()} onOpenChange={setCustomOpen}>
133
+ <KobaltePopover.Trigger
134
+ as="button"
135
+ type="button"
136
+ disabled={local.disabled}
137
+ class={cn(
138
+ 'h-10 w-10 shrink-0 rounded-lg border-2 border-surface-border shadow-sm transition hover:border-ink-300 dark:hover:border-ink-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 focus:ring-offset-surface-base disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
139
+ )}
140
+ style={{ 'background-color': currentHex() || 'transparent' }}
141
+ title="Choose color"
142
+ aria-label="Choose color"
143
+ />
144
+ <KobaltePopover.Portal>
145
+ <KobaltePopover.Content
146
+ class="z-[200] outline-none"
147
+ >
148
+ <ColorPickerCustomPanel
149
+ value={currentHex()}
150
+ isOpen={customOpen()}
151
+ onApply={handleCustomApply}
152
+ onCancel={() => setCustomOpen(false)}
153
+ lastUsed={lastUsed()}
154
+ onLastUsedClick={(hex) => {
155
+ handleCustomApply(hex)
156
+ }}
157
+ allowedFormats={local.allowedFormats ?? ['hex']}
158
+ predefined={local.predefined}
159
+ />
160
+ </KobaltePopover.Content>
161
+ </KobaltePopover.Portal>
162
+ </KobaltePopover>
163
+
164
+ <Show when={!local.compact}>
165
+ {/* Preset swatches */}
166
+ <div class="flex flex-wrap gap-1.5">
167
+ <For each={presets()}>
168
+ {(hex) => (
169
+ <button
170
+ type="button"
171
+ class={cn(
172
+ 'h-8 w-8 shrink-0 rounded-full border-2 shadow-sm transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary-500',
173
+ normalizeHex(hex) === currentHex()
174
+ ? 'border-primary-500 ring-2 ring-primary-200 dark:ring-primary-500/30'
175
+ : 'border-surface-border hover:border-ink-300 dark:hover:border-ink-600',
176
+ )}
177
+ style={{ 'background-color': hex }}
178
+ title={hex}
179
+ aria-label={`Set color to ${hex}`}
180
+ onClick={() => handlePresetClick(hex)}
181
+ disabled={local.disabled}
182
+ />
183
+ )}
184
+ </For>
185
+ {/* Custom button */}
186
+ <button
187
+ type="button"
188
+ class={cn(
189
+ 'flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-ink-300 bg-ink-50 text-ink-500 transition hover:border-primary-400 hover:bg-primary-50 hover:text-primary-600 dark:hover:border-primary-500 dark:hover:bg-primary-500/20 dark:hover:text-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500',
190
+ !isPreset(currentHex()) && currentHex()
191
+ ? 'border-primary-400 dark:border-primary-500 bg-primary-50 dark:bg-primary-500/20 text-primary-600 dark:text-primary-400'
192
+ : '',
193
+ )}
194
+ title="Custom color"
195
+ aria-label="Custom color"
196
+ onClick={() => setCustomOpen(true)}
197
+ disabled={local.disabled}
198
+ >
199
+ <Pipette class="h-4 w-4" />
200
+ </button>
201
+ </div>
202
+ </Show>
203
+ </div>
204
+ </div>
205
+ )
206
+ }
207
+
208
+ /** Matches 3, 6, or 8 digit hex (with optional leading #). 8-digit = #RRGGBBAA (CSS Color Level 4). */
209
+ function isValidHex(v: string): boolean {
210
+ const hex = v.trim().replace(/^#/, '')
211
+ return /^[0-9a-fA-F]{3}$/.test(hex) || /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{8}$/.test(hex)
212
+ }
213
+
214
+ interface ColorPickerCustomPanelProps {
215
+ value: string
216
+ /** When true, panel is visible. Used to sync color from value when popover opens. */
217
+ isOpen?: boolean
218
+ onApply: (hex: string) => void
219
+ onCancel: () => void
220
+ lastUsed: string[]
221
+ onLastUsedClick: (hex: string) => void
222
+ allowedFormats: ColorFormat[]
223
+ /** Predefined colors shown below last used. */
224
+ predefined?: string[]
225
+ }
226
+
227
+ function ColorPickerCustomPanel(props: ColorPickerCustomPanelProps) {
228
+ const formats = () => props.allowedFormats
229
+ const defaultFormat = () => (formats().includes('hex') ? 'hex' : formats()[0] ?? 'hex')
230
+ const [format, setFormat] = createSignal<ColorFormat>(defaultFormat())
231
+
232
+ // Reset format if current selection is no longer in allowedFormats
233
+ createEffect(() => {
234
+ if (!props.allowedFormats.includes(format())) setFormat(defaultFormat())
235
+ })
236
+
237
+ const initialColor = () => {
238
+ const v = props.value
239
+ if (v && isValidHex(v)) return safeParseColor(normalizeHex(v))
240
+ return KobalteParseColor('#000000')
241
+ }
242
+
243
+ const [color, setColor] = createSignal<KobalteColor>(initialColor())
244
+ const [hexText, setHexText] = createSignal(colorToHex(initialColor()))
245
+ let prevOpen = false
246
+
247
+ // When popover opens (false -> true), sync panel color from current value so it shows the selected color
248
+ createEffect(() => {
249
+ const open = props.isOpen ?? false
250
+ if (open && !prevOpen && props.value && isValidHex(props.value)) {
251
+ const c = safeParseColor(normalizeHex(props.value))
252
+ setColor(c)
253
+ setHexText(colorToHex(c))
254
+ }
255
+ prevOpen = open
256
+ })
257
+
258
+ const hex = () => colorToHex(color())
259
+
260
+ // Sync hexText when color changes via area/slider/channel fields (not hex input)
261
+ createEffect(() => { setHexText(hex()) })
262
+
263
+ const formatTabs: { id: ColorFormat; label: string }[] = [
264
+ { id: 'hex', label: 'Hex' },
265
+ { id: 'rgb', label: 'RGB' },
266
+ { id: 'hsl', label: 'HSL' },
267
+ { id: 'hsb', label: 'HSB' },
268
+ ]
269
+ const visibleFormatTabs = () => formatTabs.filter((tab) => props.allowedFormats.includes(tab.id))
270
+ const showFormatTabs = () => visibleFormatTabs().length > 1
271
+
272
+ return (
273
+ <div class="w-[320px] overflow-hidden rounded-xl border border-surface-border bg-surface-raised p-4 shadow-xl">
274
+ <div class="mb-3 flex items-center justify-between">
275
+ <span class="text-sm font-semibold text-ink-900">Color Picker</span>
276
+ <button
277
+ type="button"
278
+ class="rounded p-1 text-ink-400 hover:bg-ink-100 dark:hover:bg-ink-200 hover:text-ink-600 dark:hover:text-ink-200"
279
+ onClick={props.onCancel}
280
+ aria-label="Close"
281
+ >
282
+ <X class="h-4 w-4" />
283
+ </button>
284
+ </div>
285
+
286
+ {/* 2D saturation/brightness area (HSB). Use Kobalte default gradient so brightness (dark/light) axis matches interaction. */}
287
+ <div class="mb-3">
288
+ <KobalteColorArea
289
+ value={color()}
290
+ onChange={setColor}
291
+ colorSpace="hsb"
292
+ xChannel="saturation"
293
+ yChannel="brightness"
294
+ class="relative block w-full"
295
+ >
296
+ <KobalteColorArea.Background class="relative block h-32 w-full rounded-lg border border-surface-border cursor-crosshair touch-none" />
297
+ <KobalteColorArea.Thumb class="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white shadow-md [transform:translate(-50%,-50%)]">
298
+ <KobalteColorArea.HiddenInputX />
299
+ <KobalteColorArea.HiddenInputY />
300
+ </KobalteColorArea.Thumb>
301
+ </KobalteColorArea>
302
+ </div>
303
+
304
+ {/* Hue slider */}
305
+ <div class="mb-3">
306
+ <KobalteColorSlider channel="hue" value={color()} onChange={setColor} colorSpace="hsb">
307
+ <KobalteColorSlider.Track class="relative block h-6 w-full rounded-full border border-surface-border cursor-pointer touch-none">
308
+ <KobalteColorSlider.Thumb class="absolute h-6 w-6 rounded-full border-2 border-white shadow [transform:translate(-50%,-50%)] touch-none">
309
+ <KobalteColorSlider.Input class="sr-only" />
310
+ </KobalteColorSlider.Thumb>
311
+ </KobalteColorSlider.Track>
312
+ </KobalteColorSlider>
313
+ </div>
314
+
315
+ {/* Format tabs (only when more than one format allowed) */}
316
+ <Show when={showFormatTabs()}>
317
+ <div class="mb-3 flex gap-1 rounded-lg bg-ink-100 p-1">
318
+ <For each={visibleFormatTabs()}>
319
+ {(tab) => (
320
+ <button
321
+ type="button"
322
+ class={cn(
323
+ 'flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition',
324
+ format() === tab.id
325
+ ? 'bg-surface-raised text-ink-900 shadow-sm'
326
+ : 'text-ink-600 hover:text-ink-900 dark:hover:text-ink-100',
327
+ )}
328
+ onClick={() => setFormat(tab.id)}
329
+ >
330
+ {tab.label}
331
+ </button>
332
+ )}
333
+ </For>
334
+ </div>
335
+ </Show>
336
+
337
+ {/* Channel fields based on format */}
338
+ <div class="mb-4 flex flex-wrap items-center gap-2">
339
+ <Show when={format() === 'hex'}>
340
+ <div class="flex flex-1 items-center gap-2">
341
+ <Pipette class="h-4 w-4 shrink-0 text-ink-400" aria-hidden="true" />
342
+ <input
343
+ type="text"
344
+ value={hexText()}
345
+ onInput={(e) => {
346
+ const v = (e.target as HTMLInputElement).value
347
+ setHexText(v)
348
+ if (isValidHex(v)) setColor(safeParseColor(v))
349
+ }}
350
+ aria-label="Hex color value"
351
+ class="w-full rounded-lg border border-surface-border bg-surface-raised px-2 py-1.5 font-mono text-sm text-ink-900"
352
+ />
353
+ </div>
354
+ </Show>
355
+ <Show when={format() === 'hsl'}>
356
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="hue" colorSpace="hsl">
357
+ <div class="flex items-center gap-1">
358
+ <KobalteColorChannelField.Label class="sr-only">H</KobalteColorChannelField.Label>
359
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
360
+ </div>
361
+ </KobalteColorChannelField>
362
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="saturation" colorSpace="hsl">
363
+ <div class="flex items-center gap-1">
364
+ <KobalteColorChannelField.Label class="sr-only">S</KobalteColorChannelField.Label>
365
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
366
+ </div>
367
+ </KobalteColorChannelField>
368
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="lightness" colorSpace="hsl">
369
+ <div class="flex items-center gap-1">
370
+ <KobalteColorChannelField.Label class="sr-only">L</KobalteColorChannelField.Label>
371
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
372
+ </div>
373
+ </KobalteColorChannelField>
374
+ </Show>
375
+ <Show when={format() === 'rgb'}>
376
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="red" colorSpace="rgb">
377
+ <div class="flex items-center gap-1">
378
+ <KobalteColorChannelField.Label class="sr-only">R</KobalteColorChannelField.Label>
379
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
380
+ </div>
381
+ </KobalteColorChannelField>
382
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="green" colorSpace="rgb">
383
+ <div class="flex items-center gap-1">
384
+ <KobalteColorChannelField.Label class="sr-only">G</KobalteColorChannelField.Label>
385
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
386
+ </div>
387
+ </KobalteColorChannelField>
388
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="blue" colorSpace="rgb">
389
+ <div class="flex items-center gap-1">
390
+ <KobalteColorChannelField.Label class="sr-only">B</KobalteColorChannelField.Label>
391
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
392
+ </div>
393
+ </KobalteColorChannelField>
394
+ </Show>
395
+ <Show when={format() === 'hsb'}>
396
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="hue" colorSpace="hsb">
397
+ <div class="flex items-center gap-1">
398
+ <KobalteColorChannelField.Label class="sr-only">H</KobalteColorChannelField.Label>
399
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
400
+ </div>
401
+ </KobalteColorChannelField>
402
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="saturation" colorSpace="hsb">
403
+ <div class="flex items-center gap-1">
404
+ <KobalteColorChannelField.Label class="sr-only">S</KobalteColorChannelField.Label>
405
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
406
+ </div>
407
+ </KobalteColorChannelField>
408
+ <KobalteColorChannelField value={color()} onChange={setColor} channel="brightness" colorSpace="hsb">
409
+ <div class="flex items-center gap-1">
410
+ <KobalteColorChannelField.Label class="sr-only">B</KobalteColorChannelField.Label>
411
+ <KobalteColorChannelField.Input class="w-14 rounded border border-surface-border bg-surface-raised px-1.5 py-1 text-sm text-ink-900" />
412
+ </div>
413
+ </KobalteColorChannelField>
414
+ </Show>
415
+ </div>
416
+
417
+ {/* Last used */}
418
+ <Show when={props.lastUsed.length > 0}>
419
+ <div class="mb-4">
420
+ <p class="mb-1.5 text-xs font-medium text-ink-500">Last used</p>
421
+ <div class="flex flex-wrap gap-1.5">
422
+ <For each={props.lastUsed}>
423
+ {(hex) => (
424
+ <button
425
+ type="button"
426
+ class="h-7 w-7 shrink-0 rounded-md border border-surface-border shadow-sm transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary-500"
427
+ style={{ 'background-color': hex }}
428
+ title={hex}
429
+ aria-label={`Set color to ${hex}`}
430
+ onClick={() => props.onLastUsedClick(hex)}
431
+ />
432
+ )}
433
+ </For>
434
+ </div>
435
+ </div>
436
+ </Show>
437
+
438
+ {/* Predefined (e.g. theme presets) */}
439
+ <Show when={props.predefined && props.predefined.length > 0}>
440
+ <div class="mb-4">
441
+ <p class="mb-1.5 text-xs font-medium text-ink-500">Presets</p>
442
+ <div class="flex flex-wrap gap-1.5">
443
+ <For each={props.predefined!}>
444
+ {(hex) => (
445
+ <button
446
+ type="button"
447
+ class="h-7 w-7 shrink-0 rounded-md border border-surface-border shadow-sm transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary-500"
448
+ style={{ 'background-color': hex }}
449
+ title={hex}
450
+ aria-label={`Set color to ${hex}`}
451
+ onClick={() => { const n = normalizeHex(hex); if (n) props.onApply(n) }}
452
+ />
453
+ )}
454
+ </For>
455
+ </div>
456
+ </div>
457
+ </Show>
458
+
459
+ <div class="flex justify-end gap-2">
460
+ <Button variant="outlined" size="sm" onClick={props.onCancel}>
461
+ Cancel
462
+ </Button>
463
+ <Button variant="primary" size="sm" onClick={() => props.onApply(hex())} aria-label="Apply selected color">
464
+ Apply
465
+ </Button>
466
+ </div>
467
+ </div>
468
+ )
469
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Hex parsing/formatting for color picker value (e.g. #3b82f6).
3
+ * Kobalte color components use @internationalized/color; we normalize to hex for the public API.
4
+ */
5
+
6
+ export function normalizeHex(value: string): string {
7
+ const v = value.trim().replace(/^(#|0x)/, '')
8
+ if (/^[0-9a-fA-F]{6}$/.test(v)) return '#' + v.toLowerCase()
9
+ if (/^[0-9a-fA-F]{8}$/.test(v)) return '#' + v.toLowerCase()
10
+ // 3-digit hex
11
+ if (/^[0-9a-fA-F]{3}$/.test(v)) {
12
+ const r = v[0]! + v[0]
13
+ const g = v[1]! + v[1]
14
+ const b = v[2]! + v[2]
15
+ return ('#' + r + g + b).toLowerCase()
16
+ }
17
+ return ''
18
+ }
19
+
20
+ /** Expects 3, 6, or 8-digit hex (with or without # or 0x). Returns {0,0,0,1} for invalid input. */
21
+ export function hexToRgba(hex: string): { r: number; g: number; b: number; a: number } {
22
+ let h = hex.trim().replace(/^(#|0x)/, '')
23
+ if (h.length === 3 && /^[0-9a-fA-F]{3}$/.test(h)) h = h[0]! + h[0] + h[1]! + h[1] + h[2]! + h[2]
24
+ if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(h)) {
25
+ if (import.meta.env.DEV) console.warn(`hexToRgba: invalid hex "${hex}"`)
26
+ return { r: 0, g: 0, b: 0, a: 1 }
27
+ }
28
+ const r = parseInt(h.slice(0, 2), 16)
29
+ const g = parseInt(h.slice(2, 4), 16)
30
+ const b = parseInt(h.slice(4, 6), 16)
31
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1
32
+ return { r, g, b, a }
33
+ }
34
+
35
+ export function rgbaToHex(r: number, g: number, b: number, a: number): string {
36
+ const toHex = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0')
37
+ const rr = toHex(r)
38
+ const gg = toHex(g)
39
+ const bb = toHex(b)
40
+ if (a >= 1) return '#' + rr + gg + bb
41
+ return '#' + rr + gg + bb + toHex(a * 255)
42
+ }
43
+
44
+ /** Convert hex to HSL string (CSS Color Level 4 modern syntax, e.g. "hsl(200 50% 50%)" or "hsl(200 50% 50% / 0.5)"). */
45
+ export function hexToHslString(hex: string): string {
46
+ const { r, g, b, a } = hexToRgba(hex)
47
+ const rn = r / 255
48
+ const gn = g / 255
49
+ const bn = b / 255
50
+ const max = Math.max(rn, gn, bn)
51
+ const min = Math.min(rn, gn, bn)
52
+ let h = 0
53
+ let s = 0
54
+ const l = (max + min) / 2
55
+ if (max !== min) {
56
+ const d = max - min
57
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
58
+ switch (max) {
59
+ case rn:
60
+ h = (gn - bn) / d + (gn < bn ? 6 : 0)
61
+ break
62
+ case gn:
63
+ h = (bn - rn) / d + 2
64
+ break
65
+ default:
66
+ h = (rn - gn) / d + 4
67
+ }
68
+ h /= 6
69
+ }
70
+ const hDeg = Math.round(h * 360)
71
+ const sPct = Math.round(s * 100)
72
+ const lPct = Math.round(l * 100)
73
+ if (a >= 1) return `hsl(${hDeg} ${sPct}% ${lPct}%)`
74
+ return `hsl(${hDeg} ${sPct}% ${lPct}% / ${Math.round(a * 100) / 100})`
75
+ }
@@ -0,0 +1,2 @@
1
+ export { ColorPicker } from './ColorPicker'
2
+ export type { ColorPickerProps, ColorFormat } from './ColorPicker'