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'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
const PRESET_COLORS = [ const ROW1 = ['#1abc9c', '#2ecc71', '#3498db', '#9b59b6', '#e91e63', '#f1c40f', '#e67e22', '#e74c3c', '#95a5a6'];
'#1abc9c', '#2ecc71', '#3498db', '#9b59b6', '#e91e63', const ROW2 = ['#11806a', '#1f8b4c', '#206694', '#71368a', '#ad1457', '#c27c0e', '#a84300', '#992d22', '#546e7a'];
'#f1c40f', '#e67e22', '#e74c3c', '#95a5a6', '#607d8b', const PRESET_COLORS = [...ROW1, ...ROW2];
'#11806a', '#1f8b4c', '#206694', '#71368a', '#ad1457',
'#c27c0e', '#a84300', '#992d22', '#979c9f', '#546e7a',
];
const isValidHex = (hex) => /^#[0-9a-fA-F]{6}$/.test(hex); 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 = ({ const ColorPicker = ({
value, value,
onChange, onChange,
@@ -19,10 +22,15 @@ const ColorPicker = ({
required = false, required = false,
disabled = false, disabled = false,
}) => { }) => {
const [hexInput, setHexInput] = useState(value || '#6b7280');
const nativeRef = useRef(null); const nativeRef = useRef(null);
const selected = isValidHex(value) ? value.toLowerCase() : '#6b7280'; 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) => { const handleSwatchClick = (color) => {
if (disabled) return; if (disabled) return;
@@ -34,9 +42,7 @@ const ColorPicker = ({
const raw = e.target.value; const raw = e.target.value;
setHexInput(raw); setHexInput(raw);
const normalized = raw.startsWith('#') ? raw : `#${raw}`; const normalized = raw.startsWith('#') ? raw : `#${raw}`;
if (isValidHex(normalized)) { if (isValidHex(normalized)) onChange?.(normalized.toLowerCase());
onChange?.(normalized.toLowerCase());
}
}; };
const handleHexBlur = () => { const handleHexBlur = () => {
@@ -50,11 +56,6 @@ const ColorPicker = ({
} }
}; };
const handleCustomClick = () => {
if (disabled) return;
nativeRef.current?.click();
};
const handleNativeChange = (e) => { const handleNativeChange = (e) => {
const color = e.target.value.toLowerCase(); const color = e.target.value.toLowerCase();
setHexInput(color); setHexInput(color);
@@ -73,24 +74,18 @@ const ColorPicker = ({
<p className="text-xs text-neutral-500 dark:text-neutral-400">{description}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400">{description}</p>
)} )}
<div className="flex flex-col gap-3"> <div className="flex items-start gap-3">
<div className="flex flex-wrap gap-1.5"> {/* Left: big custom swatch + hex input */}
{/* Custom color swatch */} <div className="flex flex-col gap-2 w-20 flex-shrink-0">
<button <button
type="button" type="button"
title="Couleur personnalisée" title="Couleur personnalisée"
onClick={handleCustomClick} onClick={() => !disabled && nativeRef.current?.click()}
disabled={disabled} 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 }} style={{ backgroundColor: selected }}
> >
{!PRESET_COLORS.map(c => c.toLowerCase()).includes(selected) && ( {isCustom && <Checkmark />}
<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>
)}
<input <input
ref={nativeRef} ref={nativeRef}
type="color" type="color"
@@ -101,36 +96,6 @@ const ColorPicker = ({
tabIndex={-1} tabIndex={-1}
/> />
</button> </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 <input
type="text" type="text"
value={hexInput} value={hexInput}
@@ -139,9 +104,33 @@ const ColorPicker = ({
disabled={disabled} disabled={disabled}
maxLength={7} maxLength={7}
placeholder="#6b7280" 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> </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>
</div> </div>
); );