Add react color picker, show tailwind colors all the time

This commit is contained in:
2025-02-10 10:29:00 -05:00
parent db5fb7ca61
commit 1d03a56c70
5 changed files with 393 additions and 72 deletions

11
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.6" "tailwindcss": "^4.0.6"
}, },
@@ -3250,6 +3251,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.0.0", "version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.6" "tailwindcss": "^4.0.6"
}, },

View File

@@ -1,4 +1,7 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { HexColorPicker } from 'react-colorful'
import { hexToRgb, rgbToHex, rgbToHsl, hslToRgb, rgbToHsb, hsbToRgb, findClosestTailwindColor } from '../utils/colors'
import { TailwindColors } from './TailwindColors'
type RGB = { type RGB = {
r: number r: number
@@ -23,52 +26,134 @@ type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb' | 'tailwind'
export function ColorPicker() { export function ColorPicker() {
const [color, setColor] = useState('#6366F1') // Default to indigo-500 const [color, setColor] = useState('#6366F1') // Default to indigo-500
const [format, setFormat] = useState<ColorFormat>('hex') const [format, setFormat] = useState<ColorFormat>('hex')
const [displayValue, setDisplayValue] = useState(color)
const [closestTailwind, setClosestTailwind] = useState<{ name: string; hex: string }>({ name: 'indigo-500', hex: '#6366F1' })
useEffect(() => {
const rgb = hexToRgb(color)
if (!rgb) return
// Always update closest Tailwind color
const closest = findClosestTailwindColor(color)
setClosestTailwind(closest)
switch (format) {
case 'hex':
setDisplayValue(color)
break
case 'rgb':
setDisplayValue(`rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`)
break
case 'hsl': {
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b)
setDisplayValue(`hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`)
break
}
case 'hsb': {
const hsb = rgbToHsb(rgb.r, rgb.g, rgb.b)
setDisplayValue(`hsb(${hsb.h}, ${hsb.s}%, ${hsb.b}%)`)
break
}
case 'tailwind':
setDisplayValue(closest.name)
break
}
}, [color, format])
const handleInputChange = (value: string) => {
let newHex = color
if (format === 'hex' && /^#[0-9A-Fa-f]{6}$/.test(value)) {
newHex = value
} else if (format === 'rgb') {
const match = value.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
if (match) {
const [_, r, g, b] = match.map(Number)
if (r <= 255 && g <= 255 && b <= 255) {
newHex = rgbToHex(r, g, b)
}
}
} else if (format === 'hsl') {
const match = value.match(/^hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)$/)
if (match) {
const [_, h, s, l] = match.map(Number)
if (h <= 360 && s <= 100 && l <= 100) {
const rgb = hslToRgb(h, s, l)
newHex = rgbToHex(rgb.r, rgb.g, rgb.b)
}
}
} else if (format === 'hsb') {
const match = value.match(/^hsb\((\d+),\s*(\d+)%,\s*(\d+)%\)$/)
if (match) {
const [_, h, s, b] = match.map(Number)
if (h <= 360 && s <= 100 && b <= 100) {
const rgb = hsbToRgb(h, s, b)
newHex = rgbToHex(rgb.r, rgb.g, rgb.b)
}
}
}
setColor(newHex)
setDisplayValue(value)
}
return ( return (
<div className="w-full max-w-2xl mx-auto p-6 space-y-6"> <div className="w-full max-w-7xl mx-auto p-6">
<div className="flex flex-col items-center gap-6"> <h1 className="text-3xl font-bold text-center mb-8">Color Picker</h1>
<h1 className="text-3xl font-bold">Color Picker</h1>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-8">
{/* Color Preview */} {/* Left Column: Color Picker and Input */}
<div <div className="space-y-6">
className="w-32 h-32 rounded-lg shadow-lg border border-zinc-200" <div className="flex flex-col items-center gap-4 p-6 bg-white rounded-xl shadow-sm">
style={{ backgroundColor: color }} {/* Color Preview */}
/> <div
className="w-32 h-32 rounded-lg shadow-lg border border-zinc-200"
{/* Color Input */} style={{ backgroundColor: color }}
<div className="w-full max-w-md">
<div className="flex gap-2 mb-4">
{(['hex', 'rgb', 'hsl', 'hsb', 'tailwind'] as const).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${format === f
? 'bg-indigo-500 text-white'
: 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200'
}`}
>
{f.toUpperCase()}
</button>
))}
</div>
<div className="relative">
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-full px-4 py-2 rounded-md border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Enter color value..."
/>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 p-0 border-0 rounded-md cursor-pointer"
/> />
<div className="text-sm text-zinc-600">
Closest Tailwind: <span className="font-medium">{closestTailwind.name}</span>
</div>
{/* Color Picker */}
<div className="w-full max-w-[200px]">
<HexColorPicker color={color} onChange={setColor} />
</div>
{/* Format Buttons */}
<div className="flex flex-wrap gap-2 justify-center w-full">
{(['hex', 'rgb', 'hsl', 'hsb', 'tailwind'] as const).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${format === f
? 'bg-indigo-500 text-white'
: 'bg-zinc-100 text-zinc-700 hover:bg-zinc-200'
}`}
>
{f.toUpperCase()}
</button>
))}
</div>
{/* Color Input */}
<div className="w-full">
<input
type="text"
value={displayValue}
onChange={(e) => handleInputChange(e.target.value)}
className="w-full px-4 py-2 rounded-md border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Enter color value..."
/>
</div>
</div> </div>
</div> </div>
{/* Right Column: Tailwind Colors */}
<div className="bg-white rounded-xl shadow-sm p-4">
<TailwindColors onColorSelect={setColor} selectedColor={color} />
</div>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react'
import { TAILWIND_COLORS } from '../utils/colors'
interface TailwindColorsProps {
onColorSelect: (color: string) => void
selectedColor?: string
}
export function TailwindColors({ onColorSelect, selectedColor }: TailwindColorsProps) {
const colorEntries = useMemo(() => Object.entries(TAILWIND_COLORS), [])
return (
<div className="w-full overflow-x-auto">
<div className="grid grid-cols-[repeat(auto-fit,minmax(100px,1fr))] gap-4">
{colorEntries.map(([colorName, shades]) => (
<div key={colorName} className="space-y-2">
<div className="text-sm font-medium text-zinc-600 capitalize">{colorName}</div>
<div className="grid grid-rows-[repeat(11,1fr)] gap-1">
{Object.entries(shades).map(([shade, hex]) => (
<button
key={`${colorName}-${shade}`}
onClick={() => onColorSelect(hex)}
className={`w-full h-7 rounded transition-all hover:scale-105 hover:shadow-lg group relative
${selectedColor === hex ? 'ring-2 ring-offset-2 ring-indigo-500' : ''}`}
style={{ backgroundColor: hex }}
title={`${colorName}-${shade}: ${hex}`}
>
<span className={`absolute left-2 text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity
${Number(shade) > 500 ? 'text-white' : 'text-black'}`}>
{shade}
</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -182,41 +182,225 @@ export function hsbToRgb(h: number, s: number, v: number): { r: number; g: numbe
} }
} }
// Function to find the closest Tailwind color // Function to find the closest Tailwind color using CIELAB color space
export function findClosestTailwindColor(hex: string): string { export function findClosestTailwindColor(hex: string): { name: string; hex: string } {
// This is a simplified version - we'll expand this with actual Tailwind colors const rgb1 = hexToRgb(hex)
const tailwindColors = { if (!rgb1) return { name: 'Invalid color', hex }
'red-500': '#EF4444',
'blue-500': '#3B82F6',
'green-500': '#22C55E',
'yellow-500': '#EAB308',
'purple-500': '#A855F7',
'pink-500': '#EC4899',
'indigo-500': '#6366F1',
// Add more colors as needed
}
const targetRgb = hexToRgb(hex)
if (!targetRgb) return 'Invalid color'
let closestColor = '' let closestColor = ''
let closestHex = ''
let minDistance = Infinity let minDistance = Infinity
Object.entries(tailwindColors).forEach(([name, colorHex]) => { // Convert RGB to Lab
const currentRgb = hexToRgb(colorHex) const lab1 = rgbToLab(rgb1.r, rgb1.g, rgb1.b)
if (!currentRgb) return
const distance = Math.sqrt( Object.entries(TAILWIND_COLORS).forEach(([colorName, shades]) => {
Math.pow(targetRgb.r - currentRgb.r, 2) + Object.entries(shades).forEach(([shade, colorHex]) => {
Math.pow(targetRgb.g - currentRgb.g, 2) + const rgb2 = hexToRgb(colorHex)
Math.pow(targetRgb.b - currentRgb.b, 2) if (!rgb2) return
)
if (distance < minDistance) { const lab2 = rgbToLab(rgb2.r, rgb2.g, rgb2.b)
minDistance = distance const distance = deltaE(lab1, lab2)
closestColor = name
} if (distance < minDistance) {
minDistance = distance
closestColor = `${colorName}-${shade}`
closestHex = colorHex
}
})
}) })
return closestColor return { name: closestColor, hex: closestHex }
} }
// CIELAB color space conversion functions
function rgbToLab(r: number, g: number, b: number) {
let x, y, z
r = r / 255
g = g / 255
b = b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047
y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000
z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883
x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + 16/116
y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + 16/116
z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + 16/116
return {
l: (116 * y) - 16,
a: 500 * (x - y),
b: 200 * (y - z)
}
}
// Calculate color difference using CIEDE2000
function deltaE(lab1: { l: number; a: number; b: number }, lab2: { l: number; a: number; b: number }) {
const deltaL = lab2.l - lab1.l
const deltaA = lab2.a - lab1.a
const deltaB = lab2.b - lab1.b
const c1 = Math.sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
const c2 = Math.sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
const deltaC = c2 - c1
let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC
deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH)
const sc = 1.0 + 0.045 * c1
const sh = 1.0 + 0.015 * c1
const deltaLKlsl = deltaL / (1.0)
const deltaCkcsc = deltaC / (sc)
const deltaHkhsh = deltaH / (sh)
return Math.sqrt(deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh)
}
// Export Tailwind colors for use in other components
export const TAILWIND_COLORS = {
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
950: '#09090b',
},
red: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
950: '#450a0a',
},
orange: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
950: '#431407',
},
yellow: {
50: '#fefce8',
100: '#fef9c3',
200: '#fef08a',
300: '#fde047',
400: '#facc15',
500: '#eab308',
600: '#ca8a04',
700: '#a16207',
800: '#854d0e',
900: '#713f12',
950: '#422006',
},
green: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
blue: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
indigo: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
purple: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
950: '#3b0764',
},
pink: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
950: '#500724',
},
} as const