refactor(ColorPicker): redesign layout with big custom swatch and extracted Checkmark component

This commit is contained in:
2026-04-22 16:34:10 -04:00
parent dbadd30837
commit 2c02890216
+49 -60
View File
@@ -1,16 +1,19 @@
'use client';
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
const PRESET_COLORS = [
'#1abc9c', '#2ecc71', '#3498db', '#9b59b6', '#e91e63',
'#f1c40f', '#e67e22', '#e74c3c', '#95a5a6', '#607d8b',
'#11806a', '#1f8b4c', '#206694', '#71368a', '#ad1457',
'#c27c0e', '#a84300', '#992d22', '#979c9f', '#546e7a',
];
const ROW1 = ['#1abc9c', '#2ecc71', '#3498db', '#9b59b6', '#e91e63', '#f1c40f', '#e67e22', '#e74c3c', '#95a5a6'];
const ROW2 = ['#11806a', '#1f8b4c', '#206694', '#71368a', '#ad1457', '#c27c0e', '#a84300', '#992d22', '#546e7a'];
const PRESET_COLORS = [...ROW1, ...ROW2];
const isValidHex = (hex) => /^#[0-9a-fA-F]{6}$/.test(hex);
const Checkmark = () => (
<svg className="w-4 h-4 text-white drop-shadow-sm" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
const ColorPicker = ({
value,
onChange,
@@ -19,10 +22,15 @@ const ColorPicker = ({
required = false,
disabled = false,
}) => {
const [hexInput, setHexInput] = useState(value || '#6b7280');
const nativeRef = useRef(null);
const selected = isValidHex(value) ? value.toLowerCase() : '#6b7280';
const [hexInput, setHexInput] = useState(selected);
useEffect(() => {
if (isValidHex(value)) setHexInput(value.toLowerCase());
}, [value]);
const isCustom = !PRESET_COLORS.map(c => c.toLowerCase()).includes(selected);
const handleSwatchClick = (color) => {
if (disabled) return;
@@ -34,9 +42,7 @@ const ColorPicker = ({
const raw = e.target.value;
setHexInput(raw);
const normalized = raw.startsWith('#') ? raw : `#${raw}`;
if (isValidHex(normalized)) {
onChange?.(normalized.toLowerCase());
}
if (isValidHex(normalized)) onChange?.(normalized.toLowerCase());
};
const handleHexBlur = () => {
@@ -50,11 +56,6 @@ const ColorPicker = ({
}
};
const handleCustomClick = () => {
if (disabled) return;
nativeRef.current?.click();
};
const handleNativeChange = (e) => {
const color = e.target.value.toLowerCase();
setHexInput(color);
@@ -73,24 +74,18 @@ const ColorPicker = ({
<p className="text-xs text-neutral-500 dark:text-neutral-400">{description}</p>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{/* Custom color swatch */}
<div className="flex items-start gap-3">
{/* Left: big custom swatch + hex input */}
<div className="flex flex-col gap-2 w-20 flex-shrink-0">
<button
type="button"
title="Couleur personnalisée"
onClick={handleCustomClick}
onClick={() => !disabled && nativeRef.current?.click()}
disabled={disabled}
className="relative w-8 h-8 rounded-md border-2 border-neutral-300 dark:border-neutral-600 overflow-hidden flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer hover:border-neutral-400 dark:hover:border-neutral-500 transition-colors"
className="relative w-full h-14 rounded-xl flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-neutral-400"
style={{ backgroundColor: selected }}
>
{!PRESET_COLORS.map(c => c.toLowerCase()).includes(selected) && (
<span className="absolute inset-0 flex items-center justify-center">
<svg className="w-4 h-4 text-white drop-shadow" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
)}
{isCustom && <Checkmark />}
<input
ref={nativeRef}
type="color"
@@ -101,36 +96,6 @@ const ColorPicker = ({
tabIndex={-1}
/>
</button>
{/* Preset swatches */}
{PRESET_COLORS.map((color) => {
const isSelected = selected === color.toLowerCase();
return (
<button
key={color}
type="button"
title={color}
onClick={() => handleSwatchClick(color)}
disabled={disabled}
className="relative w-8 h-8 rounded-md flex-shrink-0 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-neutral-400"
style={{ backgroundColor: color }}
>
{isSelected && (
<svg className="w-4 h-4 text-white drop-shadow" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
);
})}
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-md border border-neutral-300 dark:border-neutral-700 flex-shrink-0"
style={{ backgroundColor: selected }}
/>
<input
type="text"
value={hexInput}
@@ -139,9 +104,33 @@ const ColorPicker = ({
disabled={disabled}
maxLength={7}
placeholder="#6b7280"
className="w-28 px-[10px] py-[7px] rounded-lg text-[13px] focus:outline-none transition-all duration-[120ms] ease-out disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900/60 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20 font-mono"
className="w-full px-2 py-[7px] rounded-lg text-[12px] text-center focus:outline-none transition-all duration-[120ms] ease-out disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900/60 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20 font-mono uppercase"
/>
</div>
{/* Right: 9×2 preset grid */}
<div className="flex flex-col gap-1.5">
{[ROW1, ROW2].map((row, rowIdx) => (
<div key={rowIdx} className="flex gap-1.5">
{row.map((color) => {
const isSelected = selected === color.toLowerCase();
return (
<button
key={color}
type="button"
title={color}
onClick={() => handleSwatchClick(color)}
disabled={disabled}
className="w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-neutral-400"
style={{ backgroundColor: color }}
>
{isSelected && <Checkmark />}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
);