diff --git a/src/components/ColorInput.test.tsx b/src/components/ColorInput.test.tsx new file mode 100644 index 00000000..9b6ee7be --- /dev/null +++ b/src/components/ColorInput.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + expect(screen.getByTestId('color-swatch')).toBeTruthy() + }) + + it('does not show swatch when value is not a color', () => { + render() + expect(screen.queryByTestId('color-swatch')).toBeNull() + }) + + it('does not show swatch for empty string', () => { + render() + expect(screen.queryByTestId('color-swatch')).toBeNull() + }) + + it('shows swatch in edit mode for valid color', () => { + render() + 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() + 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() + 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() + const textInput = screen.getByTestId('color-text-input') as HTMLInputElement + fireEvent.keyDown(textInput, { key: 'Escape' }) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/src/components/ColorInput.tsx b/src/components/ColorInput.tsx new file mode 100644 index 00000000..9f04e8e2 --- /dev/null +++ b/src/components/ColorInput.tsx @@ -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(null) + + const handleClick = useCallback(() => { + inputRef.current?.click() + }, []) + + const handleChange = useCallback((e: React.ChangeEvent) => { + onChange?.(e.target.value) + }, [onChange]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + inputRef.current?.click() + } + }, []) + + return ( + + + + ) +} + +/** + * 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 ( + + {showSwatch && } + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onSave(editValue)} + autoFocus + data-testid="color-text-input" + /> + + ) + } + + return ( + + {showSwatch && } + + {value || '\u2014'} + + + ) +} diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index a2406a19..7456061a 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -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 = { - 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 + case 'color': + return default: return } diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts new file mode 100644 index 00000000..bbd8b4ef --- /dev/null +++ b/src/utils/colorUtils.test.ts @@ -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) + }) +}) diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts new file mode 100644 index 00000000..8f03b903 --- /dev/null +++ b/src/utils/colorUtils.ts @@ -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 . + * 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' +} diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 88a3e2a3..c20a3955 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -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' }