2026-01-13 14:00:05 +01:00

171 lines
5.4 KiB
TypeScript

import * as React from 'react'
type Variant = 'primary' | 'secondary' | 'soft'
type Size = 'xs' | 'sm' | 'md' | 'lg'
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
export type ButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> & {
children: React.ReactNode
variant?: Variant
size?: Size
color?: Color
rounded?: 'sm' | 'md' | 'full'
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
isLoading?: boolean
className?: string
}
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
const base =
'inline-flex items-center justify-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const roundedMap = {
sm: 'rounded-sm',
md: 'rounded-md',
full: 'rounded-full',
} as const
const sizeMap: Record<Size, string> = {
xs: 'px-2 py-1 text-xs',
sm: 'px-2.5 py-1.5 text-sm',
md: 'px-3 py-2 text-sm',
lg: 'px-3.5 py-2.5 text-sm',
}
const colorMap: Record<Color, Record<Variant, string>> = {
indigo: {
primary:
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
},
blue: {
primary:
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30',
},
emerald: {
primary:
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30',
},
red: {
primary:
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30',
},
amber: {
primary:
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30',
},
}
function Spinner() {
return (
<svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true">
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.25"
/>
<path
d="M22 12a10 10 0 0 1-10 10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.9"
/>
</svg>
)
}
export default function Button({
children,
variant = 'primary',
color = 'indigo',
size = 'md',
rounded = 'md',
leadingIcon,
trailingIcon,
isLoading = false,
disabled,
className,
type = 'button',
...props
}: ButtonProps) {
const iconGap = leadingIcon || trailingIcon || isLoading ? 'gap-x-1.5' : ''
return (
<button
type={type}
disabled={disabled || isLoading}
className={cn(
base,
roundedMap[rounded],
sizeMap[size],
colorMap[color][variant],
iconGap,
className
)}
{...props}
>
{isLoading ? (
<span className="-ml-0.5">
<Spinner />
</span>
) : (
leadingIcon && <span className="-ml-0.5">{leadingIcon}</span>
)}
<span>{children}</span>
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
</button>
)
}