feat(ui): add ColorPicker component and replace native color input in RoleEditModal

This commit is contained in:
2026-04-22 16:30:41 -04:00
parent 3035d70d59
commit 866da94f06
3 changed files with 160 additions and 14 deletions
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, Textarea, Switch, Modal } from '@zen/core/shared/components';
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { getPermissionGroups } from '@zen/core/users/constants';
@@ -136,18 +136,13 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
rows={2}
placeholder="Description optionnelle..."
/>
<div className="flex items-center gap-3">
<label className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
Couleur
</label>
<input
type="color"
<ColorPicker
label="Couleur du rôle"
description="Les membres utilisent la couleur du rôle le plus élevé qu'ils possèdent."
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-neutral-200 dark:border-neutral-700"
onChange={setColor}
required
/>
<span className="text-xs text-neutral-500">{color}</span>
</div>
</div>
<div className="flex flex-col gap-3">
@@ -155,7 +150,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => (
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
<p className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
{group}
</p>
</div>
+150
View File
@@ -0,0 +1,150 @@
'use client';
import { useState, useRef } 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 isValidHex = (hex) => /^#[0-9a-fA-F]{6}$/.test(hex);
const ColorPicker = ({
value,
onChange,
label,
description,
required = false,
disabled = false,
}) => {
const [hexInput, setHexInput] = useState(value || '#6b7280');
const nativeRef = useRef(null);
const selected = isValidHex(value) ? value.toLowerCase() : '#6b7280';
const handleSwatchClick = (color) => {
if (disabled) return;
setHexInput(color);
onChange?.(color);
};
const handleHexChange = (e) => {
const raw = e.target.value;
setHexInput(raw);
const normalized = raw.startsWith('#') ? raw : `#${raw}`;
if (isValidHex(normalized)) {
onChange?.(normalized.toLowerCase());
}
};
const handleHexBlur = () => {
const normalized = hexInput.startsWith('#') ? hexInput : `#${hexInput}`;
if (isValidHex(normalized)) {
const lower = normalized.toLowerCase();
setHexInput(lower);
onChange?.(lower);
} else {
setHexInput(selected);
}
};
const handleCustomClick = () => {
if (disabled) return;
nativeRef.current?.click();
};
const handleNativeChange = (e) => {
const color = e.target.value.toLowerCase();
setHexInput(color);
onChange?.(color);
};
return (
<div className="flex flex-col gap-2">
{label && (
<label className="block text-xs font-medium text-neutral-700 dark:text-white">
{label}
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
</label>
)}
{description && (
<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 */}
<button
type="button"
title="Couleur personnalisée"
onClick={handleCustomClick}
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"
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>
)}
<input
ref={nativeRef}
type="color"
value={selected}
onChange={handleNativeChange}
disabled={disabled}
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
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}
onChange={handleHexChange}
onBlur={handleHexBlur}
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"
/>
</div>
</div>
</div>
);
};
export default ColorPicker;
+1
View File
@@ -27,3 +27,4 @@ export { default as Breadcrumb } from './Breadcrumb';
export { default as Switch } from './Switch';
export { default as TagInput } from './TagInput';
export { default as UserAvatar } from './UserAvatar';
export { default as ColorPicker } from './ColorPicker.client';