refactor(ColorPicker): redesign layout with big custom swatch and extracted Checkmark component
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user