initial: standalone repo from monorepo split
This commit is contained in:
178
src/components/ui/action-icon.tsx
Normal file
178
src/components/ui/action-icon.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user