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