179 lines
6.4 KiB
TypeScript
179 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import { ButtonHTMLAttributes, useState, useRef, useEffect } from 'react';
|
|
|
|
export type IconName =
|
|
| 'eye'
|
|
| 'credit-card'
|
|
| 'x-circle'
|
|
| 'check'
|
|
| 'x'
|
|
| 'edit'
|
|
| 'copy'
|
|
| 'pause'
|
|
| 'play'
|
|
| 'user-x'
|
|
| 'user-check'
|
|
| 'external-link'
|
|
| 'more'
|
|
| 'trash';
|
|
|
|
interface ActionIconProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
|
icon: IconName;
|
|
variant?: 'primary' | 'ghost' | 'danger';
|
|
size?: 'sm' | 'md' | 'lg';
|
|
label: string;
|
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
}
|
|
|
|
const ICONS: Record<IconName, (size: number) => React.ReactNode> = {
|
|
eye: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
|
|
<circle cx="8" cy="8" r="2" />
|
|
</svg>
|
|
),
|
|
'credit-card': (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="1.5" y="3" width="13" height="10" rx="1.5" />
|
|
<path d="M1.5 6.5h13M4.5 10h2" />
|
|
</svg>
|
|
),
|
|
'x-circle': (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="8" cy="8" r="6" />
|
|
<path d="M10 6l-4 4M6 6l4 4" />
|
|
</svg>
|
|
),
|
|
check: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 8.5l3.5 3.5 6.5-7" />
|
|
</svg>
|
|
),
|
|
x: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
<path d="M4 4l8 8M12 4l-8 8" />
|
|
</svg>
|
|
),
|
|
edit: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M11.5 2.5l2 2-8.5 8.5H3v-2l8.5-8.5z" />
|
|
</svg>
|
|
),
|
|
copy: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="5" y="5" width="8" height="8" rx="1" />
|
|
<path d="M3 11V3.5A1.5 1.5 0 014.5 2H11" />
|
|
</svg>
|
|
),
|
|
pause: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="4" y="3" width="2.5" height="10" rx="0.5" />
|
|
<rect x="9.5" y="3" width="2.5" height="10" rx="0.5" />
|
|
</svg>
|
|
),
|
|
play: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4.5 2.5l9 5.5-9 5.5V2.5z" />
|
|
</svg>
|
|
),
|
|
'user-x': (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="6.5" cy="5" r="2.5" />
|
|
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
|
|
<path d="M12 5l3 3M15 5l-3 3" />
|
|
</svg>
|
|
),
|
|
'user-check': (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="6.5" cy="5" r="2.5" />
|
|
<path d="M2 14c0-2.5 2-4.5 4.5-4.5S11 11.5 11 14" />
|
|
<path d="M12 6l2 2 3.5-3.5" />
|
|
</svg>
|
|
),
|
|
'external-link': (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M8 2h6v6M14 2L7 9" />
|
|
<path d="M6 3H3v10h10v-3" />
|
|
</svg>
|
|
),
|
|
more: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="currentColor">
|
|
<circle cx="8" cy="3.5" r="1.5" />
|
|
<circle cx="8" cy="8" r="1.5" />
|
|
<circle cx="8" cy="12.5" r="1.5" />
|
|
</svg>
|
|
),
|
|
trash: (s) => (
|
|
<svg width={s} height={s} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 4.5h10M6.5 4.5V3a1 1 0 011-1h1a1 1 0 011 1v1.5M5 4.5l.5 8.5h5l.5-8.5" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
const VARIANT_STYLES: Record<string, string> = {
|
|
primary:
|
|
'text-primary-600 hover:bg-primary-50 hover:text-primary-700 focus-visible:ring-primary-500/30',
|
|
ghost:
|
|
'text-surface-400 hover:bg-surface-100 hover:text-surface-700 focus-visible:ring-surface-500/30',
|
|
danger:
|
|
'text-surface-400 hover:bg-red-50 hover:text-red-600 focus-visible:ring-red-500/30',
|
|
};
|
|
|
|
const SIZE_MAP = { sm: 32, md: 36, lg: 40 } as const;
|
|
const ICON_SIZE_MAP = { sm: 15, md: 16, lg: 18 } as const;
|
|
|
|
export function ActionIcon({
|
|
icon,
|
|
variant = 'ghost',
|
|
size = 'sm',
|
|
label,
|
|
onClick,
|
|
className = '',
|
|
disabled,
|
|
...rest
|
|
}: ActionIconProps) {
|
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
const btnRef = useRef<HTMLButtonElement>(null);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const btnSize = SIZE_MAP[size];
|
|
const iconSize = ICON_SIZE_MAP[size];
|
|
|
|
return (
|
|
<button
|
|
ref={btnRef}
|
|
type="button"
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`relative inline-flex items-center justify-center rounded-lg transition-all duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 disabled:opacity-40 disabled:cursor-not-allowed ${VARIANT_STYLES[variant]} ${className}`}
|
|
style={{ width: btnSize, height: btnSize }}
|
|
onMouseEnter={() => setShowTooltip(true)}
|
|
onMouseLeave={() => {
|
|
timeoutRef.current = setTimeout(() => setShowTooltip(false), 100);
|
|
}}
|
|
onFocus={() => setShowTooltip(true)}
|
|
onBlur={() => setShowTooltip(false)}
|
|
{...rest}
|
|
>
|
|
{ICONS[icon](iconSize)}
|
|
{showTooltip && (
|
|
<span
|
|
role="tooltip"
|
|
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 px-2 py-1 text-[11px] font-medium text-white bg-surface-800 rounded-md whitespace-nowrap pointer-events-none z-50 shadow-sm"
|
|
>
|
|
{label}
|
|
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|