114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
import { useState, useRef, useCallback } from 'react'
|
|
import { isValidCssColor, toHexColor } from '../utils/colorUtils'
|
|
|
|
/**
|
|
* Inline color swatch button that opens a native color picker.
|
|
* Shows nothing if the value is not a valid CSS color.
|
|
*/
|
|
export function ColorSwatch({ color, onChange }: {
|
|
color: string
|
|
onChange?: (hex: string) => void
|
|
}) {
|
|
const hex = toHexColor(color) ?? '#000000'
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleClick = useCallback(() => {
|
|
inputRef.current?.click()
|
|
}, [])
|
|
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
onChange?.(e.target.value)
|
|
}, [onChange])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault()
|
|
inputRef.current?.click()
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<span className="inline-flex shrink-0 items-center">
|
|
<button
|
|
type="button"
|
|
className="relative size-4 shrink-0 cursor-pointer rounded-[3px] border border-border p-0 transition-shadow hover:ring-1 hover:ring-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
|
style={{ background: color }}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
title="Open color picker"
|
|
aria-label={`Color: ${color}. Click to open color picker`}
|
|
data-testid="color-swatch"
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="color"
|
|
value={hex}
|
|
onChange={handleChange}
|
|
className="pointer-events-none absolute inset-0 opacity-0"
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
data-testid="color-picker-input"
|
|
/>
|
|
</button>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Editable text field with an inline color swatch.
|
|
* The swatch only appears when the value is a valid CSS color.
|
|
*/
|
|
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
|
|
value: string
|
|
isEditing: boolean
|
|
onStartEdit: () => void
|
|
onSave: (newValue: string) => void
|
|
onCancel: () => void
|
|
}) {
|
|
const [editValue, setEditValue] = useState(value)
|
|
const showSwatch = isValidCssColor(isEditing ? editValue : value)
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') onSave(editValue)
|
|
else if (e.key === 'Escape') { setEditValue(value); onCancel() }
|
|
}
|
|
|
|
const handlePickerChange = useCallback((hex: string) => {
|
|
if (isEditing) {
|
|
setEditValue(hex)
|
|
}
|
|
onSave(hex)
|
|
}, [isEditing, onSave])
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<span className="flex w-full items-center gap-1.5">
|
|
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />}
|
|
<input
|
|
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
|
|
type="text"
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={() => onSave(editValue)}
|
|
autoFocus
|
|
data-testid="color-text-input"
|
|
/>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
|
|
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
|
|
<span
|
|
className="min-w-0 cursor-pointer truncate rounded px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
|
onClick={onStartEdit}
|
|
title={value || 'Click to edit'}
|
|
>
|
|
{value || '\u2014'}
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|