121 lines
3.2 KiB
TypeScript
121 lines
3.2 KiB
TypeScript
import { Slot, Slottable } from "@radix-ui/react-slot";
|
|
import { forwardRef, useState } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
|
|
import { buttonVariants } from "./button.variants";
|
|
import { cn } from "../lib/cn";
|
|
import type { VariantProps } from "../lib/cva";
|
|
import type { ButtonLikeElementProps } from "../lib/contracts";
|
|
import { createDataAttributes, createSlot } from "../lib/contracts";
|
|
|
|
export type ButtonProps = Omit<
|
|
ButtonLikeElementProps,
|
|
"onDrag" | "onDragEnd" | "onDragStart"
|
|
> &
|
|
VariantProps<typeof buttonVariants>;
|
|
|
|
function Spinner() {
|
|
return (
|
|
<motion.span
|
|
{...createSlot("icon")}
|
|
aria-hidden="true"
|
|
animate={{ opacity: 1, rotate: 0, scale: 1 }}
|
|
className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin"
|
|
exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
|
|
initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
|
|
transition={{
|
|
duration: 0.18,
|
|
ease: [0.22, 1, 0.36, 1]
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{
|
|
asChild = false,
|
|
children,
|
|
className,
|
|
disabled,
|
|
loading = false,
|
|
onMouseEnter,
|
|
onMouseLeave,
|
|
size,
|
|
type,
|
|
variant,
|
|
...props
|
|
},
|
|
ref
|
|
) {
|
|
const prefersReducedMotion = useReducedMotion();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const isDisabled = disabled || loading;
|
|
const Component = asChild ? Slot : "button";
|
|
const baseClassName = cn(buttonVariants({ loading, size, variant }), className);
|
|
const label = asChild ? (
|
|
<Slottable>{children}</Slottable>
|
|
) : (
|
|
<motion.span
|
|
{...createSlot("label")}
|
|
animate={{
|
|
opacity: loading ? 0.94 : 1,
|
|
x: loading ? 1.5 : 0
|
|
}}
|
|
transition={{
|
|
duration: prefersReducedMotion ? 0.01 : 0.18,
|
|
ease: [0.22, 1, 0.36, 1]
|
|
}}
|
|
>
|
|
{children}
|
|
</motion.span>
|
|
);
|
|
|
|
const sheen = !asChild ? (
|
|
<motion.span
|
|
animate={
|
|
prefersReducedMotion || isDisabled
|
|
? { opacity: 0, x: "-120%" }
|
|
: isHovered
|
|
? { opacity: 0.75, x: "115%" }
|
|
: { opacity: 0, x: "-120%" }
|
|
}
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.32)_45%,transparent_100%)] mix-blend-screen"
|
|
initial={false}
|
|
transition={{
|
|
duration: prefersReducedMotion ? 0.01 : 0.55,
|
|
ease: [0.16, 1, 0.3, 1]
|
|
}}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<Component
|
|
{...props}
|
|
{...createSlot("root")}
|
|
{...createDataAttributes({
|
|
disabled: isDisabled,
|
|
loading,
|
|
size,
|
|
variant
|
|
})}
|
|
className={baseClassName}
|
|
disabled={asChild ? undefined : isDisabled}
|
|
onMouseEnter={(event) => {
|
|
setIsHovered(true);
|
|
onMouseEnter?.(event as never);
|
|
}}
|
|
onMouseLeave={(event) => {
|
|
setIsHovered(false);
|
|
onMouseLeave?.(event as never);
|
|
}}
|
|
ref={ref}
|
|
type={asChild ? undefined : type ?? "button"}
|
|
>
|
|
{sheen}
|
|
<AnimatePresence initial={false}>{loading ? <Spinner /> : null}</AnimatePresence>
|
|
{label}
|
|
</Component>
|
|
);
|
|
});
|