refactor: switch to ripple button

This commit is contained in:
zhom
2025-08-08 10:46:00 +04:00
parent 5c58b5c644
commit 1cb9ffa249
25 changed files with 344 additions and 82 deletions
+3 -2
View File
@@ -18,6 +18,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { RippleButton } from "./ripple";
interface ComboboxOption {
value: string;
@@ -47,7 +48,7 @@ export function Combobox({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
<RippleButton
variant="outline"
role="combobox"
aria-expanded={open}
@@ -57,7 +58,7 @@ export function Combobox({
? options.find((option) => option.value === value)?.label
: placeholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</RippleButton>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { type HTMLMotionProps, motion, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-11 px-8 has-[>svg]:px-6",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", {
variants: {
variant: {
default: "bg-primary-foreground",
destructive: "bg-destructive",
outline: "bg-input",
secondary: "bg-secondary",
ghost: "bg-accent",
},
},
defaultVariants: {
variant: "default",
},
});
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonProps = HTMLMotionProps<"button"> & {
children: React.ReactNode;
rippleClassName?: string;
scale?: number;
transition?: Transition;
} & VariantProps<typeof buttonVariants>;
function RippleButton({
ref,
children,
onClick,
className,
rippleClassName,
variant,
size,
scale = 10,
transition = { duration: 0.6, ease: "easeOut" },
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
return (
<motion.button
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
>
{children}
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
className={cn(
rippleVariants({ variant, className: rippleClassName }),
)}
style={{
top: ripple.y - 10,
left: ripple.x - 10,
}}
/>
))}
</motion.button>
);
}
export { RippleButton, type RippleButtonProps };