171 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|