Spaces:
Paused
Paused
File size: 4,608 Bytes
d530f14 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/utils/cn";
import "./button.css";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "tertiary" | "playground" | "destructive";
size?: "default" | "large";
disabled?: boolean;
loadingLabel?: string;
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, Props>(
(
{
variant = "primary",
size = "default",
disabled,
isLoading = false,
loadingLabel = "Loading…",
...attrs
},
ref,
) => {
const isNonInteractive = Boolean(disabled || isLoading);
// Focus ring adapts to light/dark variants
const focusRing =
variant === "primary" || variant === "destructive"
? "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white"
: "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-black";
return (
<button
{...attrs}
ref={ref}
type={attrs.type ?? "button"}
aria-disabled={isNonInteractive || undefined}
aria-busy={isLoading || undefined}
aria-live={isLoading ? "polite" : undefined}
data-state={
isLoading ? "loading" : isNonInteractive ? "disabled" : "idle"
}
className={cn(
attrs.className,
"flex items-center justify-center button relative [&>*]:relative",
"text-label-medium lg-max:[&_svg]:size-24",
`button-${variant} group/button`,
focusRing,
// Shared non-interactive styles
"disabled:cursor-not-allowed",
isNonInteractive && "cursor-not-allowed",
// Size
size === "default" && "rounded-8 px-10 py-6 gap-4",
size === "large" && "rounded-10 px-12 py-8 gap-6",
// Variant + interactive nuances
variant === "primary" && [
"text-accent-white",
// Hover/active only when interactive
!isNonInteractive &&
"hover:bg-[color:var(--heat-90)] active:[scale:0.995]",
// Disabled: dim a bit, no hover, dim overlay bg layer if present
"disabled:opacity-80",
"disabled:[&_.button-background]:opacity-70",
],
["secondary", "tertiary", "playground"].includes(variant) && [
"text-accent-black",
!isNonInteractive && "active:[scale:0.99] active:bg-black-alpha-7",
],
variant === "secondary" && [
"bg-black-alpha-4",
!isNonInteractive && "hover:bg-black-alpha-6",
// Disabled: lighter fill + muted text, no hover
"disabled:bg-black-alpha-3",
"disabled:text-black-alpha-48",
"disabled:hover:bg-black-alpha-3",
],
variant === "tertiary" && [
!isNonInteractive && "hover:bg-black-alpha-4",
// Disabled: no hover background, text muted
"disabled:text-black-alpha-48",
"disabled:hover:bg-transparent",
],
variant === "destructive" && [
"bg-red-600 text-accent-white",
!isNonInteractive && "hover:bg-red-700 active:scale-[0.98]",
// Disabled: keep red but softer; soften text slightly
"disabled:bg-red-600/70",
"disabled:text-white-alpha-72",
"disabled:hover:bg-red-600/70",
],
variant === "playground" && [
"inside-border before:border-black-alpha-4",
isNonInteractive
? "before:opacity-0 bg-black-alpha-4 text-black-alpha-24"
: "hover:bg-black-alpha-4 hover:before:opacity-0 active:before:opacity-0",
],
)}
disabled={isNonInteractive}
>
{variant === "primary" && (
<div className="overlay button-background !absolute" />
)}
{/* loading state (spinner) */}
{isLoading && (
<div
className={cn(
"w-16 h-16 border-2 rounded-full animate-spin",
variant === "primary" || variant === "destructive"
? "border-white/30 border-t-white"
: "border-black/30 border-t-black",
)}
aria-hidden
/>
)}
{/* Screen reader-only loading label */}
{isLoading && <span className="sr-only">{loadingLabel}</span>}
{attrs.children}
</button>
);
},
);
Button.displayName = "Button";
export default Button;
|