| import { type ReactNode, cloneElement, isValidElement } from "react"; | |
| import cn from "../../utils/classnames.ts"; | |
| import { Loader } from "../index.ts"; | |
| type ButtonColor = "primary" | "secondary" | "mono" | "danger"; | |
| type ButtonVariant = "solid" | "outline" | "ghost"; | |
| type ButtonSize = "xs" | "sm" | "md" | "lg"; | |
| interface BaseButtonProps { | |
| children?: ReactNode; | |
| className?: string; | |
| color?: ButtonColor; | |
| variant?: ButtonVariant; | |
| size?: ButtonSize; | |
| iconLeft?: ReactNode; | |
| iconRight?: ReactNode; | |
| disabled?: boolean; | |
| loading?: boolean; | |
| notDisabledWhileLoading?: boolean; | |
| shiny?: boolean; | |
| } | |
| interface ButtonAsButton extends BaseButtonProps { | |
| onClick?: () => void; | |
| type?: "button" | "submit" | "reset"; | |
| } | |
| interface ButtonAsLink extends BaseButtonProps { | |
| href: string; | |
| onClick?: never; | |
| to?: never; | |
| target?: string; | |
| rel?: string; | |
| } | |
| export type ButtonProps = ButtonAsButton | ButtonAsLink; | |
| const sizeClasses: Record<ButtonSize, string> = { | |
| xs: "px-2 py-1.5 text-xs", | |
| sm: "px-3 py-1.5 text-sm", | |
| md: "px-4 py-2 text-base", | |
| lg: "px-6 py-3 text-lg", | |
| }; | |
| const iconOnlySizeClasses: Record<ButtonSize, string> = { | |
| xs: "p-1.5", | |
| sm: "p-1.5", | |
| md: "p-2", | |
| lg: "p-3", | |
| }; | |
| const iconSizeClasses: Record<ButtonSize, string> = { | |
| xs: "h-3 w-3", | |
| sm: "h-4 w-4", | |
| md: "h-5 w-5", | |
| lg: "h-6 w-6", | |
| }; | |
| const colorVariantClasses: Record< | |
| ButtonColor, | |
| Record<ButtonVariant, string> | |
| > = { | |
| primary: { | |
| solid: | |
| "bg-yellow-500 text-gray-900 hover:bg-yellow-600 dark:bg-yellow-400 dark:text-gray-900 dark:hover:bg-yellow-500", | |
| outline: | |
| "border-1 border-yellow-500 text-yellow-600 hover:bg-yellow-50 dark:border-yellow-400 dark:text-yellow-400 dark:hover:bg-yellow-950", | |
| ghost: | |
| "text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-950", | |
| }, | |
| secondary: { | |
| solid: | |
| "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600", | |
| outline: | |
| "border-1 border-blue-600 text-blue-600 hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-950", | |
| ghost: | |
| "text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950", | |
| }, | |
| mono: { | |
| solid: | |
| "bg-gray-900 text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100", | |
| outline: | |
| "border-1 border-gray-900 text-gray-900 hover:bg-gray-50 dark:border-white dark:text-white dark:hover:bg-white/10", | |
| ghost: | |
| "text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10", | |
| }, | |
| danger: { | |
| solid: | |
| "bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600", | |
| outline: | |
| "border-1 border-red-600 text-red-600 hover:bg-red-50 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-950", | |
| ghost: | |
| "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950", | |
| }, | |
| }; | |
| export default function Button({ | |
| children, | |
| className = "", | |
| color = "primary", | |
| variant = "solid", | |
| size = "md", | |
| iconLeft, | |
| iconRight, | |
| disabled = false, | |
| loading = false, | |
| notDisabledWhileLoading = false, | |
| shiny = false, | |
| ...props | |
| }: ButtonProps) { | |
| const isIconOnly = !children && (iconLeft || iconRight || loading); | |
| const baseClasses = cn( | |
| "inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900", | |
| isIconOnly ? iconOnlySizeClasses[size] : sizeClasses[size], | |
| colorVariantClasses[color][variant], | |
| { | |
| "cursor-not-allowed opacity-50": disabled || loading, | |
| }, | |
| className | |
| ); | |
| const isDisabled = disabled || (loading && !notDisabledWhileLoading); | |
| const renderIcon = (icon: ReactNode) => { | |
| if (isValidElement(icon)) { | |
| return cloneElement(icon as any, { | |
| className: cn((icon.props as any)?.className, iconSizeClasses[size]), | |
| }); | |
| } | |
| return icon; | |
| }; | |
| const content = ( | |
| <> | |
| {loading && <Loader size={size} />} | |
| {!loading && iconLeft && renderIcon(iconLeft)} | |
| {children && <span>{children}</span>} | |
| {!loading && iconRight && renderIcon(iconRight)} | |
| </> | |
| ); | |
| if ("href" in props && props.href) { | |
| return ( | |
| <a | |
| href={props.href} | |
| className={baseClasses} | |
| target={props.target} | |
| rel={props.rel} | |
| aria-disabled={isDisabled} | |
| > | |
| {content} | |
| </a> | |
| ); | |
| } | |
| return ( | |
| <button | |
| type={(props as ButtonAsButton).type || "button"} | |
| onClick={(props as ButtonAsButton).onClick} | |
| disabled={isDisabled} | |
| className={baseClasses} | |
| > | |
| {content} | |
| </button> | |
| ); | |
| } | |