190 lines
6.5 KiB
TypeScript
190 lines
6.5 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { XIcon } from './icons';
|
|
import { CustomButton } from './custom/CustomButton';
|
|
|
|
interface PopoverProps {
|
|
trigger: (open: boolean) => React.ReactNode;
|
|
children: React.ReactNode;
|
|
align?: 'left' | 'right';
|
|
width?: number;
|
|
}
|
|
export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'left', width }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
|
document.addEventListener('mousedown', onDown);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDown);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open]);
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<div onClick={() => setOpen((o) => !o)}>{trigger(open)}</div>
|
|
{open && (
|
|
<div
|
|
style={{ width }}
|
|
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
|
align === 'right' ? 'right-0' : 'left-0'
|
|
}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ColorSwatchesProps {
|
|
value: string;
|
|
onChange: (color: string) => void;
|
|
palette?: string[];
|
|
}
|
|
const DEFAULT_PALETTE = ['#facc15', '#fb923c', '#f87171', '#f472b6', '#a78bfa', '#60a5fa', '#34d399', '#1f2937', '#ffffff'];
|
|
export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, palette = DEFAULT_PALETTE }) => (
|
|
<div className="flex items-center gap-1.5">
|
|
{palette.map((c) => (
|
|
<CustomButton
|
|
variant="unstyled"
|
|
key={c}
|
|
title={c}
|
|
onClick={() => onChange(c)}
|
|
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
|
|
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
|
|
}`}
|
|
style={{ background: c }}
|
|
/>
|
|
))}
|
|
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
|
|
title="Custom color"
|
|
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
|
|
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
|
|
</label>
|
|
</div>
|
|
);
|
|
|
|
interface SliderProps {
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step?: number;
|
|
onChange: (v: number) => void;
|
|
label?: string;
|
|
suffix?: string;
|
|
width?: number;
|
|
}
|
|
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
|
|
<div className="flex items-center gap-2">
|
|
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
|
style={{ width }}
|
|
className="accent-[#2563eb]"
|
|
/>
|
|
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
|
|
</div>
|
|
);
|
|
|
|
interface EmptyStateProps {
|
|
icon?: React.ReactNode;
|
|
title: string;
|
|
hint?: string;
|
|
badge?: string;
|
|
}
|
|
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
|
|
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
|
|
{icon && (
|
|
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
|
|
{icon}
|
|
</div>
|
|
)}
|
|
{badge && (
|
|
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
|
|
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
|
|
</div>
|
|
);
|
|
|
|
interface ModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
width?: number;
|
|
footer?: React.ReactNode;
|
|
}
|
|
export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, width = 460, footer }) => {
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [open, onClose]);
|
|
if (!open) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
|
|
<div
|
|
style={{ width }}
|
|
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
|
|
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
|
|
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
|
|
</div>
|
|
<div className="overflow-y-auto p-6">{children}</div>
|
|
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export interface ConfirmOptions {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel?: string;
|
|
danger?: boolean;
|
|
}
|
|
interface ConfirmDialogProps {
|
|
state: (ConfirmOptions & { onConfirm: () => void }) | null;
|
|
onClose: () => void;
|
|
}
|
|
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose }) => (
|
|
<Modal
|
|
open={!!state}
|
|
onClose={onClose}
|
|
title={state?.title ?? ''}
|
|
width={420}
|
|
footer={
|
|
state && (
|
|
<>
|
|
<CustomButton variant="outline" onClick={onClose}>Cancel</CustomButton>
|
|
<CustomButton
|
|
variant={state.danger ? 'danger' : 'primary'}
|
|
onClick={() => { state.onConfirm(); onClose(); }}
|
|
>
|
|
{state.confirmLabel ?? 'Confirm'}
|
|
</CustomButton>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
|
|
</Modal>
|
|
);
|
|
|