1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import {5 AnimatePresence,6 motion,7 useReducedMotion,8} from "motion/react";9import { Plus } from "lucide-react";10import { cn } from "@/lib/utils";11
12export type SpeedDialAction = {13 id: string;14 label: string;15 icon: React.ReactNode;16 onSelect?: () => void;17 href?: string;18};19
20export type SpeedDialProps = {21 actions: SpeedDialAction[];22 className?: string;23 label?: string;24};25
26const ENTER_SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };27
28export function SpeedDial({29 actions,30 className,31 label = "Actions",32}: SpeedDialProps) {33 const reduce = useReducedMotion();34 const [open, setOpen] = useState(false);35 const rootRef = useRef<HTMLDivElement>(null);36 const listId = useId();37
38 useEffect(() => {39 if (!open) return;40 const onKey = (e: KeyboardEvent) => {41 if (e.key === "Escape") setOpen(false);42 };43 const onPointer = (e: PointerEvent) => {44 if (!rootRef.current?.contains(e.target as Node)) setOpen(false);45 };46 window.addEventListener("keydown", onKey);47 window.addEventListener("pointerdown", onPointer);48 return () => {49 window.removeEventListener("keydown", onKey);50 window.removeEventListener("pointerdown", onPointer);51 };52 }, [open]);53
54 const activate = (action: SpeedDialAction) => {55 action.onSelect?.();56 if (action.href) window.open(action.href, "_blank", "noopener,noreferrer");57 setOpen(false);58 };59
60 return (61 <div62 ref={rootRef}63 className={cn(64 "relative flex flex-col items-end gap-2.5",65 className,66 )}67 >68 <div69 id={listId}70 role="menu"71 aria-hidden={!open}72 className="flex flex-col items-end gap-2.5"73 >74 <AnimatePresence initial={false}>75 {open &&76 actions.map((action, i) => {77 78 const stagger = (actions.length - 1 - i) * 0.04;79 return (80 <motion.button81 key={action.id}82 type="button"83 role="menuitem"84 id={`${listId}-${action.id}`}85 aria-label={action.label}86 onClick={() => activate(action)}87 initial={88 reduce89 ? { opacity: 0 }90 : { opacity: 0, y: 12, scale: 0.95 }91 }92 animate={{ opacity: 1, y: 0, scale: 1 }}93 exit={94 reduce95 ? { opacity: 0, transition: { duration: 0.1 } }96 : {97 opacity: 0,98 y: 8,99 scale: 0.95,100 transition: { duration: 0.16 },101 }102 }103 transition={104 reduce105 ? { duration: 0.12 }106 : { ...ENTER_SPRING, delay: stagger }107 }108 whileTap={reduce ? undefined : { scale: 0.96 }}109 className="inline-flex items-center gap-2 outline-none select-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2"110 >111 <span112 className={cn(113 "rounded-full border border-(--color-rule) bg-(--color-paper)",114 "px-3 py-1.5 text-xs font-medium whitespace-nowrap text-(--color-ink)",115 "shadow-[0_4px_14px_rgba(0,0,0,0.08)]",116 )}117 >118 {action.label}119 </span>120 <span121 className={cn(122 "flex size-10 shrink-0 items-center justify-center rounded-full",123 "border border-(--color-rule) bg-(--color-paper)",124 "text-(--color-ink)",125 "shadow-[0_6px_20px_rgba(0,0,0,0.12)]",126 "[&_svg]:size-[45%] [&_svg]:stroke-[1.5]",127 )}128 >129 {action.icon}130 </span>131 </motion.button>132 );133 })}134 </AnimatePresence>135 </div>136
137 <motion.button138 type="button"139 aria-label={label}140 aria-expanded={open}141 aria-controls={listId}142 aria-haspopup="menu"143 onClick={() => setOpen((o) => !o)}144 whileTap={reduce ? undefined : { scale: 0.96 }}145 animate={{ rotate: open ? 45 : 0 }}146 transition={147 reduce148 ? { duration: 0.12 }149 : { type: "spring", bounce: 0, duration: 0.28 }150 }151 className={cn(152 "relative z-2 flex size-14 items-center justify-center rounded-full",153 "bg-(--color-ink) text-(--color-paper) outline-none",154 "shadow-[0_8px_28px_rgba(0,0,0,0.18)]",155 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",156 )}157 >158 <Plus className="size-5 stroke-[1.5]" />159 </motion.button>160 </div>161 );162}