feat: add color swatch + picker for property values

Add inline color swatch preview next to hex/CSS color property values in
the Properties panel. Clicking the swatch opens the native OS color
picker. Add 'color' property display mode with auto-detection for hex
values and color-related key names (background, primary, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 13:35:10 +01:00
parent 96bc4e935a
commit adf45a51b5
6 changed files with 445 additions and 3 deletions

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ColorSwatch, ColorEditableValue } from './ColorInput'
describe('ColorSwatch', () => {
it('renders a swatch button with the given color', () => {
render(<ColorSwatch color="#3b82f6" />)
const swatch = screen.getByTestId('color-swatch')
expect(swatch).toBeTruthy()
// Browser normalizes hex to rgb() in computed style
expect(swatch.style.background).toBeTruthy()
})
it('contains a hidden color input', () => {
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
expect(input.type).toBe('color')
expect(input.value).toBe('#ff0000')
})
it('calls onChange when color is picked', () => {
const onChange = vi.fn()
render(<ColorSwatch color="#ff0000" onChange={onChange} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onChange).toHaveBeenCalledWith('#00ff00')
})
it('opens color input on Enter key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: 'Enter' })
expect(clickSpy).toHaveBeenCalled()
})
it('opens color input on Space key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: ' ' })
expect(clickSpy).toHaveBeenCalled()
})
})
describe('ColorEditableValue', () => {
it('shows swatch when value is a valid hex color', () => {
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
})
it('does not show swatch when value is not a color', () => {
render(<ColorEditableValue value="hello world" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('does not show swatch for empty string', () => {
render(<ColorEditableValue value="" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('shows swatch in edit mode for valid color', () => {
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
expect(screen.getByTestId('color-text-input')).toBeTruthy()
})
it('updates value when color is picked', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={false} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onSave).toHaveBeenCalledWith('#00ff00')
})
it('text field works independently in edit mode', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.change(textInput, { target: { value: '#0000ff' } })
fireEvent.keyDown(textInput, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('#0000ff')
})
it('Escape cancels edit', () => {
const onCancel = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={onCancel} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.keyDown(textInput, { key: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,113 @@
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 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 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
{value || '\u2014'}
</span>
</span>
)
}

View File

@@ -10,8 +10,9 @@ import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { isValidCssColor } from '../utils/colorUtils'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
@@ -24,6 +25,7 @@ import {
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -234,6 +236,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
{ value: 'color', label: 'Color' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -312,7 +315,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
@@ -546,6 +549,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'string' && isUrlValue(value)) return 'url'
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
return 'text'
}
@@ -572,6 +576,8 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest'
import { isValidCssColor, expandShortHex, toHexColor, isColorKeyName } from './colorUtils'
describe('isValidCssColor', () => {
it('recognizes 6-digit hex colors', () => {
expect(isValidCssColor('#3b82f6')).toBe(true)
expect(isValidCssColor('#FFFFFF')).toBe(true)
expect(isValidCssColor('#000000')).toBe(true)
expect(isValidCssColor('#abcdef')).toBe(true)
})
it('recognizes 3-digit hex colors', () => {
expect(isValidCssColor('#fff')).toBe(true)
expect(isValidCssColor('#000')).toBe(true)
expect(isValidCssColor('#abc')).toBe(true)
expect(isValidCssColor('#F0A')).toBe(true)
})
it('recognizes 8-digit hex colors (with alpha)', () => {
expect(isValidCssColor('#3b82f6ff')).toBe(true)
expect(isValidCssColor('#00000080')).toBe(true)
})
it('recognizes rgb() colors', () => {
expect(isValidCssColor('rgb(255, 0, 0)')).toBe(true)
expect(isValidCssColor('rgb(0, 128, 255)')).toBe(true)
expect(isValidCssColor('rgb( 255 , 255 , 255 )')).toBe(true)
})
it('recognizes rgba() colors', () => {
expect(isValidCssColor('rgba(255, 0, 0, 0.5)')).toBe(true)
expect(isValidCssColor('rgba(0, 128, 255, 1)')).toBe(true)
})
it('recognizes hsl() colors', () => {
expect(isValidCssColor('hsl(120, 50%, 50%)')).toBe(true)
expect(isValidCssColor('hsl(0, 100%, 50%)')).toBe(true)
})
it('recognizes hsla() colors', () => {
expect(isValidCssColor('hsla(120, 50%, 50%, 0.5)')).toBe(true)
})
it('recognizes named CSS colors', () => {
expect(isValidCssColor('red')).toBe(true)
expect(isValidCssColor('blue')).toBe(true)
expect(isValidCssColor('cornflowerblue')).toBe(true)
expect(isValidCssColor('rebeccapurple')).toBe(true)
expect(isValidCssColor('White')).toBe(true)
})
it('rejects invalid color strings', () => {
expect(isValidCssColor('#zzzzzz')).toBe(false)
expect(isValidCssColor('not-a-color')).toBe(false)
expect(isValidCssColor('')).toBe(false)
expect(isValidCssColor('hello')).toBe(false)
expect(isValidCssColor('#12')).toBe(false)
expect(isValidCssColor('#1234')).toBe(false)
expect(isValidCssColor('#12345')).toBe(false)
expect(isValidCssColor('#1234567')).toBe(false)
})
it('rejects null/undefined/non-string', () => {
expect(isValidCssColor(null as never)).toBe(false)
expect(isValidCssColor(undefined as never)).toBe(false)
expect(isValidCssColor(123 as never)).toBe(false)
})
it('handles whitespace-padded values', () => {
expect(isValidCssColor(' #fff ')).toBe(true)
expect(isValidCssColor(' red ')).toBe(true)
})
})
describe('expandShortHex', () => {
it('expands #rgb to #rrggbb', () => {
expect(expandShortHex('#fff')).toBe('#ffffff')
expect(expandShortHex('#abc')).toBe('#aabbcc')
expect(expandShortHex('#F0A')).toBe('#FF00AA')
})
it('returns non-short-hex values unchanged', () => {
expect(expandShortHex('#abcdef')).toBe('#abcdef')
expect(expandShortHex('red')).toBe('red')
})
})
describe('toHexColor', () => {
it('converts 6-digit hex (pass-through, lowercased)', () => {
expect(toHexColor('#3b82f6')).toBe('#3b82f6')
expect(toHexColor('#FFFFFF')).toBe('#ffffff')
})
it('expands 3-digit hex to 6-digit', () => {
expect(toHexColor('#fff')).toBe('#ffffff')
expect(toHexColor('#abc')).toBe('#aabbcc')
})
it('strips alpha from 8-digit hex', () => {
expect(toHexColor('#3b82f6ff')).toBe('#3b82f6')
expect(toHexColor('#00000080')).toBe('#000000')
})
it('returns null for rgb/hsl/named when OffscreenCanvas unavailable', () => {
expect(toHexColor('rgb(255, 0, 0)')).toBeNull()
expect(toHexColor('red')).toBeNull()
expect(toHexColor('hsl(120, 50%, 50%)')).toBeNull()
})
it('handles whitespace around hex values', () => {
expect(toHexColor(' #ffffff ')).toBe('#ffffff')
})
})
describe('isColorKeyName', () => {
it('recognizes color-related key names', () => {
expect(isColorKeyName('color')).toBe(true)
expect(isColorKeyName('background')).toBe(true)
expect(isColorKeyName('foreground')).toBe(true)
expect(isColorKeyName('primary')).toBe(true)
expect(isColorKeyName('border')).toBe(true)
expect(isColorKeyName('muted')).toBe(true)
expect(isColorKeyName('accent-color')).toBe(true)
expect(isColorKeyName('fill')).toBe(true)
expect(isColorKeyName('stroke')).toBe(true)
})
it('rejects non-color key names', () => {
expect(isColorKeyName('name')).toBe(false)
expect(isColorKeyName('status')).toBe(false)
expect(isColorKeyName('deadline')).toBe(false)
expect(isColorKeyName('description')).toBe(false)
})
})

91
src/utils/colorUtils.ts Normal file
View File

@@ -0,0 +1,91 @@
/** Regex patterns for common CSS color formats. */
const HEX3_RE = /^#[0-9a-f]{3}$/i
const HEX6_RE = /^#[0-9a-f]{6}$/i
const HEX8_RE = /^#[0-9a-f]{8}$/i
const RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
const HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/
/** CSS named colors (lowercase). */
const NAMED_COLORS = new Set([
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse',
'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan',
'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta',
'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen',
'darkslateblue', 'darkslategray', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink',
'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen',
'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow',
'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender',
'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan',
'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon',
'lightseagreen', 'lightskyblue', 'lightslategray', 'lightslategrey', 'lightsteelblue',
'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine',
'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue',
'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream',
'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange',
'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred',
'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple',
'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell',
'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen',
'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white',
'whitesmoke', 'yellow', 'yellowgreen',
])
/** Check if a string is a valid CSS color value. */
export function isValidCssColor(value: string): boolean {
if (!value || typeof value !== 'string') return false
const trimmed = value.trim()
if (!trimmed) return false
return HEX3_RE.test(trimmed) || HEX6_RE.test(trimmed) || HEX8_RE.test(trimmed)
|| RGB_RE.test(trimmed) || HSL_RE.test(trimmed) || NAMED_COLORS.has(trimmed.toLowerCase())
}
/** Expand #rgb to #rrggbb. */
export function expandShortHex(hex: string): string {
if (!HEX3_RE.test(hex)) return hex
const r = hex[1], g = hex[2], b = hex[3]
return `#${r}${r}${g}${g}${b}${b}`
}
/**
* Convert a valid CSS color to #rrggbb format for use with <input type="color">.
* Returns null if conversion is not possible without DOM.
*/
export function toHexColor(value: string): string | null {
const trimmed = value.trim()
if (HEX6_RE.test(trimmed)) return trimmed.toLowerCase()
if (HEX3_RE.test(trimmed)) return expandShortHex(trimmed).toLowerCase()
if (HEX8_RE.test(trimmed)) return trimmed.slice(0, 7).toLowerCase()
// For rgb/hsl/named, use an offscreen canvas if available
if (typeof OffscreenCanvas !== 'undefined') {
return canvasColorToHex(trimmed)
}
return null
}
function canvasColorToHex(color: string): string | null {
try {
const canvas = new OffscreenCanvas(1, 1)
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.fillStyle = '#000000'
ctx.fillStyle = color
const result = ctx.fillStyle
if (typeof result === 'string' && result.startsWith('#')) {
return result.toLowerCase()
}
return null
} catch {
return null
}
}
/** Check if a property key name suggests a color value. */
export function isColorKeyName(key: string): boolean {
const lower = key.toLowerCase()
return lower === 'color' || lower.endsWith('-color') || lower.endsWith('_color')
|| lower.includes('colour') || lower.startsWith('accent')
|| lower === 'background' || lower === 'foreground' || lower === 'border'
|| lower === 'primary' || lower === 'secondary' || lower === 'muted'
|| lower === 'fill' || lower === 'stroke' || lower === 'tint'
}

View File

@@ -1,6 +1,7 @@
import type { FrontmatterValue } from '../components/Inspector'
import { isValidCssColor, isColorKeyName } from './colorUtils'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
@@ -28,6 +29,7 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isDateString(strValue)) return 'date'
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
return 'text'
}