+176
-145
@@ -1,175 +1,206 @@
|
||||
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import Error from './Error'
|
||||
import Input from './Input'
|
||||
import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
|
||||
import { clx } from "../helpers/utils";
|
||||
import Error from "./Error";
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
className?: string
|
||||
error_text?: string
|
||||
isNotRequired?: boolean
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
opacity?: number
|
||||
onOpacityChange?: (value: number) => void
|
||||
opacityLabel?: string
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>
|
||||
label?: string;
|
||||
className?: string;
|
||||
error_text?: string;
|
||||
isNotRequired?: boolean;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
opacity?: number;
|
||||
onOpacityChange?: (value: number) => void;
|
||||
opacityLabel?: string;
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
|
||||
|
||||
const ColorPicker: FC<Props> = (props: Props) => {
|
||||
const [color, setColor] = useState<string>(props.value || '#000000')
|
||||
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`)
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
const pickerOpenGuardRef = useRef(false)
|
||||
const showOpacity = props.onOpacityChange !== undefined
|
||||
const [color, setColor] = useState<string>(props.value || "#000000");
|
||||
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const showOpacity = props.onOpacityChange !== undefined;
|
||||
|
||||
const inputClass = clx(
|
||||
'w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description',
|
||||
)
|
||||
const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
|
||||
|
||||
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = event.target.value
|
||||
setColor(newColor)
|
||||
props.onChange?.(newColor)
|
||||
const normalizeHex = (value: string): string => {
|
||||
if (!value) return "#000000";
|
||||
const withHash = value.startsWith("#") ? value : `#${value}`;
|
||||
const shortMatch = /^#([0-9A-Fa-f]{3})$/.exec(withHash);
|
||||
if (shortMatch) {
|
||||
const [r, g, b] = shortMatch[1]!.split("");
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
|
||||
const finalColor = value || '#000000'
|
||||
setColor(finalColor)
|
||||
props.onChange?.(finalColor)
|
||||
}
|
||||
const longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
|
||||
if (longMatch) {
|
||||
return `#${longMatch[1]}`.toLowerCase();
|
||||
}
|
||||
return "#000000";
|
||||
};
|
||||
|
||||
const openNativeColorPicker = () => {
|
||||
if (props.readOnly) return
|
||||
if (pickerOpenGuardRef.current) return
|
||||
pickerOpenGuardRef.current = true
|
||||
colorInputRef.current?.click()
|
||||
window.setTimeout(() => {
|
||||
pickerOpenGuardRef.current = false
|
||||
}, 400)
|
||||
const toHexAlpha = (hex: string, opacity: number): string => {
|
||||
const normalizedHex = normalizeHex(hex);
|
||||
const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${normalizedHex}${alpha}`;
|
||||
};
|
||||
|
||||
const fromHexAlpha = (hexAlpha: string): { hex: string; opacity: number } => {
|
||||
const match = /^#([0-9A-Fa-f]{8})$/.exec(hexAlpha);
|
||||
if (!match) {
|
||||
return {
|
||||
hex: normalizeHex(hexAlpha),
|
||||
opacity: 100,
|
||||
};
|
||||
}
|
||||
const rgbaHex = match[1]!.toLowerCase();
|
||||
const rgb = `#${rgbaHex.slice(0, 6)}`;
|
||||
const alphaHex = rgbaHex.slice(6, 8);
|
||||
const opacity = Math.round((parseInt(alphaHex, 16) / 255) * 100);
|
||||
return { hex: rgb, opacity: clampOpacity(opacity) };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setColor(props.value)
|
||||
}
|
||||
}, [props.value])
|
||||
const inputClass = clx("w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border", props.readOnly && "bg-gray-100 border-0 text-description");
|
||||
|
||||
useEffect(() => {
|
||||
if (props.opacity !== undefined) {
|
||||
setOpacityDisplay(`${props.opacity}`)
|
||||
}
|
||||
}, [props.opacity])
|
||||
const handleColorChange = (newColor: string) => {
|
||||
const normalized = normalizeHex(newColor);
|
||||
setColor(normalized);
|
||||
props.onChange?.(normalized);
|
||||
};
|
||||
|
||||
const handleOpacityChange = (rawValue: string) => {
|
||||
setOpacityDisplay(rawValue)
|
||||
const numValue = parseInt(rawValue, 10)
|
||||
if (!Number.isNaN(numValue) && numValue >= 0 && numValue <= 100) {
|
||||
props.onOpacityChange?.(numValue)
|
||||
}
|
||||
const togglePicker = () => {
|
||||
if (props.readOnly) return;
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setColor(normalizeHex(props.value));
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleOpacityBlur = (rawValue: string) => {
|
||||
const numValue = parseInt(rawValue, 10) || 0
|
||||
const clampedValue = Math.min(100, Math.max(0, numValue))
|
||||
setOpacityDisplay(`${clampedValue}`)
|
||||
props.onOpacityChange?.(clampedValue)
|
||||
useEffect(() => {
|
||||
if (props.opacity !== undefined) {
|
||||
setOpacityDisplay(`${props.opacity}`);
|
||||
}
|
||||
}, [props.opacity]);
|
||||
|
||||
const colorField = (
|
||||
<div
|
||||
className={clx('w-full', showOpacity && 'flex-1')}
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDownOutside = (event: MouseEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handlePointerDownOutside);
|
||||
return () => window.removeEventListener("mousedown", handlePointerDownOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOpacityChange = (rawValue: string) => {
|
||||
setOpacityDisplay(rawValue);
|
||||
const numValue = parseInt(rawValue, 10);
|
||||
if (!Number.isNaN(numValue)) {
|
||||
props.onOpacityChange?.(clampOpacity(numValue));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = (rawValue: string) => {
|
||||
const numValue = parseInt(rawValue, 10) || 0;
|
||||
const clampedValue = clampOpacity(numValue);
|
||||
setOpacityDisplay(`${clampedValue}`);
|
||||
props.onOpacityChange?.(clampedValue);
|
||||
};
|
||||
|
||||
const handleAlphaPickerChange = (hexAlpha: string) => {
|
||||
const { hex, opacity } = fromHexAlpha(hexAlpha);
|
||||
setColor(hex);
|
||||
setOpacityDisplay(`${opacity}`);
|
||||
props.onChange?.(hex);
|
||||
props.onOpacityChange?.(opacity);
|
||||
};
|
||||
|
||||
const colorField = (
|
||||
<div className={clx("w-full", showOpacity && "flex-1")}>
|
||||
<label className="text-xs">{props.label}</label>
|
||||
|
||||
<div className={clx("w-full relative mt-1 rounded-xl", !props.readOnly && "cursor-pointer")}>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(event) => handleColorChange(event.target.value)}
|
||||
placeholder="#000000"
|
||||
readOnly={props.readOnly}
|
||||
title={!props.readOnly ? "کلیک برای باز کردن انتخابگر رنگ" : undefined}
|
||||
onClick={togglePicker}
|
||||
className={`${inputClass} ${props.readOnly ? "" : "cursor-pointer"}`}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx("absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
|
||||
>
|
||||
<label className='text-xs'>
|
||||
{props.label}
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={clx(
|
||||
'w-full relative mt-1 rounded-xl',
|
||||
!props.readOnly && 'cursor-pointer'
|
||||
)}
|
||||
onClick={!props.readOnly ? openNativeColorPicker : undefined}
|
||||
>
|
||||
<input
|
||||
type='text'
|
||||
value={color}
|
||||
onChange={handleTextChange}
|
||||
placeholder='#000000'
|
||||
readOnly={props.readOnly}
|
||||
title={
|
||||
!props.readOnly
|
||||
? 'کلیک: پالت رنگ — با Tab وارد فیلد شوید تا کد را تایپ کنید'
|
||||
: undefined
|
||||
}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx(
|
||||
'absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none',
|
||||
props.readOnly && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* <img
|
||||
{/* <img
|
||||
src={ColorfilterIcon}
|
||||
alt=''
|
||||
className='size-7 select-none'
|
||||
/> */}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx(
|
||||
'absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none',
|
||||
props.readOnly && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className='block w-5 h-5 rounded-full border border-border shadow-sm'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx("absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
|
||||
>
|
||||
<span className="block w-5 h-5 rounded-full border border-border shadow-sm" style={{ backgroundColor: color }} />
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type='color'
|
||||
value={color}
|
||||
onChange={handleColorChange}
|
||||
className='absolute opacity-0 pointer-events-none w-0 h-0'
|
||||
/>
|
||||
{isOpen && !props.readOnly && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-full rounded-xl border border-border bg-white p-3 shadow-lg space-y-3">
|
||||
<div className="space-y-1">
|
||||
{showOpacity ? (
|
||||
<HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
|
||||
) : (
|
||||
<HexColorPicker color={color} onChange={handleColorChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={clx('w-full', showOpacity && 'flex gap-3', props.className)}>
|
||||
{colorField}
|
||||
{showOpacity && (
|
||||
<div className='w-28 shrink-0'>
|
||||
<Input
|
||||
type='number'
|
||||
label={props.opacityLabel ?? 'شفافیت'}
|
||||
className='px-3'
|
||||
value={opacityDisplay}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onBlur={(e) => handleOpacityBlur(e.target.value)}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
|
||||
{showOpacity && (
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacityDisplay}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onBlur={(e) => handleOpacityBlur(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-border px-2 text-xs"
|
||||
title={props.opacityLabel ?? "شفافیت"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showOpacity && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{props.opacityLabel ?? "شفافیت"}: {opacityDisplay}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{props.error_text && (
|
||||
<Error errorText={props.error_text} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div ref={wrapperRef} className={clx("w-full", props.className)}>
|
||||
{colorField}
|
||||
|
||||
export default ColorPicker
|
||||
{props.error_text && <Error errorText={props.error_text} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
|
||||
Reference in New Issue
Block a user