'use client'; import React, { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Surface, cn, FieldShell, focusRing, inputBase, sizeHeight, surfaceClasses, useEffectiveSurface, } from '../common'; import { PixelPopover } from '../overlay-foundation/PixelPopover'; import { useControllableState } from '../hooks/useControllableState'; type ColorFormat = 'hex' | 'rgb' | 'hsl'; type ColorSize = 'sm' | 'md' | 'lg'; export interface PixelColorInputProps { value?: string; defaultValue?: string; onChange?: (next: string) => void; format?: ColorFormat; presets?: string[]; surface?: Surface; size?: ColorSize; label?: string; hint?: string; error?: string; name?: string; id?: string; } const DEFAULT_PRESETS = [ '#000000', '#1a1a1a', '#404040', '#737373', '#a3a3a3', '#d4d4d4', '#f5f5f5', '#ffffff', '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7', '#ec4899', ]; function clampByte(n: number): number { if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(255, Math.round(n))); } function normalizeHex(input: string): string | null { if (!input) return null; let v = input.trim().toLowerCase(); if (!v.startsWith('#')) v = `#${v}`; // Expand 3-char shorthand if (/^#[0-9a-f]{3}$/.test(v)) { v = '#' + v.slice(1).split('').map((c) => c + c).join(''); } if (!/^#[0-9a-f]{6}$/.test(v)) return null; return v; } function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const norm = normalizeHex(hex); if (!norm) return null; return { r: parseInt(norm.slice(1, 3), 16), g: parseInt(norm.slice(3, 5), 16), b: parseInt(norm.slice(5, 7), 16), }; } function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const l = (max + min) / 2; let h = 0; let s = 0; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break; case gn: h = (bn - rn) / d + 2; break; case bn: h = (rn - gn) / d + 4; break; } h /= 6; } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100), }; } function formatValue(hex: string, format: ColorFormat): string { const norm = normalizeHex(hex); if (!norm) return hex; if (format === 'hex') return norm; const rgb = hexToRgb(norm)!; if (format === 'rgb') return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; } function toHexForSwatch(value: string): string { // Try parse hex straight up const direct = normalizeHex(value); if (direct) return direct; // Try rgb(r, g, b) const rgbMatch = value.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); if (rgbMatch) { const r = clampByte(Number(rgbMatch[1])); const g = clampByte(Number(rgbMatch[2])); const b = clampByte(Number(rgbMatch[3])); return '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join(''); } return '#000000'; } export const PixelColorInput = forwardRef( function PixelColorInput( { value: controlledValue, defaultValue, onChange, format = 'hex', presets, surface: surfaceProp, size = 'md', label, hint, error, name, id, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const reactId = useId(); const inputId = id ?? `pxl-color-${reactId}`; const hexInputId = `${reactId}-hex`; const [value, setValue] = useControllableState({ value: controlledValue, defaultValue: defaultValue ?? '', onChange, }); const [open, setOpen] = useState(false); const palette = useMemo(() => presets ?? DEFAULT_PRESETS, [presets]); const swatchHex = useMemo(() => (value ? toHexForSwatch(value) : '#ffffff'), [value]); // Local draft for the hex text input so partial keystrokes don't leak // through onChange as garbage values. const [draftHex, setDraftHex] = useState(value ?? ''); useEffect(() => { // Re-sync draft when committed value changes from outside (controlled). setDraftHex(value ?? ''); }, [value]); const commit = (hex: string) => { const norm = normalizeHex(hex); if (!norm) { setValue(hex); return; } setValue(formatValue(norm, format)); }; const handleHexChange = (e: React.ChangeEvent) => { const raw = e.target.value; setDraftHex(raw); const norm = normalizeHex(raw); if (norm) { // Commit only when normalisation succeeds — partials stay local. commit(norm); } }; const handleHexBlur = () => { // On blur, reset draft to last valid value to discard partial input. setDraftHex(value ?? ''); }; // Roving tabindex + 2-D arrow nav over the palette grid (8 cols). const [focusedSwatchIdx, setFocusedSwatchIdx] = useState(0); const swatchRefs = useRef>(new Map()); const setSwatchRef = useCallback((idx: number, el: HTMLButtonElement | null) => { if (el) swatchRefs.current.set(idx, el); else swatchRefs.current.delete(idx); }, []); const moveSwatchFocus = useCallback((nextIdx: number) => { const clamped = Math.max(0, Math.min(palette.length - 1, nextIdx)); setFocusedSwatchIdx(clamped); swatchRefs.current.get(clamped)?.focus(); }, [palette.length]); const handleSwatchKeyDown = (e: React.KeyboardEvent, currentIdx: number) => { switch (e.key) { case 'ArrowRight': e.preventDefault(); moveSwatchFocus(currentIdx + 1); return; case 'ArrowLeft': e.preventDefault(); moveSwatchFocus(currentIdx - 1); return; case 'ArrowDown': e.preventDefault(); moveSwatchFocus(currentIdx + 8); return; case 'ArrowUp': e.preventDefault(); moveSwatchFocus(currentIdx - 8); return; case 'Home': e.preventDefault(); moveSwatchFocus(0); return; case 'End': e.preventDefault(); moveSwatchFocus(palette.length - 1); return; case 'Enter': case ' ': e.preventDefault(); commit(palette[currentIdx]); return; default: return; } }; return (
commit(e.target.value)} className={cn( 'h-8 w-10 cursor-pointer bg-transparent p-0', s.border, s.radius, 'border-retro-border-strong', )} />
{palette.map((hex, idx) => { const norm = normalizeHex(hex) ?? hex; const isSelected = swatchHex.toLowerCase() === norm.toLowerCase(); const isFocused = focusedSwatchIdx === idx; return (
{name && ( )}
); }, ); PixelColorInput.displayName = 'PixelColorInput';