feat(ui): add ColorPicker component and replace native color input in RoleEditModal
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user