1"use client";2
3import React, { useEffect, useId, useMemo, useRef, useState } from "react";4import { Plus } from "lucide-react";5import { cn } from "@/lib/utils";6
7export type SpeedDialAction = {8 id: string;9 label: string;10 icon: React.ReactNode;11 onSelect?: () => void;12 href?: string;13};14
15export type SpeedDialProps = {16 actions: SpeedDialAction[];17 className?: string;18 label?: string;19};20
21const FAB = 48;22const ROW = 40;23const PAD_Y = 12;24const PAD_X = 12;25const PLUS_RESERVE = 52;26
27export function SpeedDial({28 actions,29 className,30 label = "Actions",31}: SpeedDialProps) {32 const [open, setOpen] = useState(false);33 const rootRef = useRef<HTMLDivElement>(null);34 const listId = useId();35
36 const openSize = useMemo(() => {37 const h = Math.max(38 FAB,39 actions.length * ROW + PAD_Y * 2 + PLUS_RESERVE,40 );41 const w = 196;42 return { w, h };43 }, [actions.length]);44
45 useEffect(() => {46 if (!open) return;47 const onKey = (e: KeyboardEvent) => {48 if (e.key === "Escape") setOpen(false);49 };50 const onPointer = (e: PointerEvent) => {51 if (!rootRef.current?.contains(e.target as Node)) setOpen(false);52 };53 window.addEventListener("keydown", onKey);54 window.addEventListener("pointerdown", onPointer);55 return () => {56 window.removeEventListener("keydown", onKey);57 window.removeEventListener("pointerdown", onPointer);58 };59 }, [open]);60
61 const activate = (action: SpeedDialAction) => {62 action.onSelect?.();63 if (action.href) window.open(action.href, "_blank", "noopener,noreferrer");64 setOpen(false);65 };66
67 return (68 <div69 ref={rootRef}70 className={cn("relative flex justify-end", className)}71 style={{72 73 width: openSize.w,74 height: openSize.h,75 }}76 >77 <div78 className={cn(79 "t-morph absolute right-0 bottom-0",80 open81 ? "border border-(--color-rule) shadow-[0_10px_32px_rgba(0,0,0,0.16)]"82 : "border border-transparent shadow-[0_6px_20px_rgba(0,0,0,0.18)]",83 )}84 data-open={open ? "true" : "false"}85 style={86 {87 88 "--morph-w-closed": `${FAB}px`,89 "--morph-h-closed": `${FAB}px`,90 "--morph-w-open": `${openSize.w}px`,91 "--morph-h-open": `${openSize.h}px`,92 "--morph-r-closed": "999px",93 "--morph-r-open": "16px",94 "--morph-slide": "18px",95 "--morph-blur": "2px",96 "--morph-scale": "0.97",97 "--morph-rotate": "45deg",98 background: open99 ? "var(--color-paper)"100 : "var(--color-ink)",101 } as React.CSSProperties102 }103 >104 <div105 id={listId}106 role="menu"107 aria-hidden={!open}108 className="t-morph-menu flex flex-col justify-start"109 style={{110 padding: `${PAD_Y}px ${PAD_X}px ${PLUS_RESERVE}px`,111 gap: 2,112 }}113 >114 {actions.map((action, i) => (115 <button116 key={action.id}117 type="button"118 role="menuitem"119 id={`${listId}-${action.id}`}120 aria-label={action.label}121 tabIndex={open ? 0 : -1}122 onClick={() => activate(action)}123 className={cn(124 "inline-flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left",125 "outline-none select-none",126 "text-(--color-ink)",127 "transition-[background-color,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",128 "hover:bg-(--color-paper-2) active:scale-[0.97]",129 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",130 )}131 style={132 open133 ? {134 transitionDelay: `${Math.min(i * 30, 120)}ms`,135 }136 : undefined137 }138 >139 <span140 className={cn(141 "flex size-8 shrink-0 items-center justify-center rounded-full",142 "border border-(--color-rule) bg-(--color-paper-2)",143 "text-(--color-ink)",144 "[&_svg]:size-[44%] [&_svg]:stroke-[1.5]",145 )}146 >147 {action.icon}148 </span>149 <span className="truncate text-[13px] font-medium leading-none">150 {action.label}151 </span>152 </button>153 ))}154 </div>155
156 <button157 type="button"158 className={cn(159 "t-morph-plus z-2",160 "text-(--color-paper)",161 "outline-none",162 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",163 "active:scale-[0.97]",164 )}165 aria-label={label}166 aria-expanded={open}167 aria-controls={listId}168 aria-haspopup="menu"169 onClick={(e) => {170 e.stopPropagation();171 setOpen((o) => !o);172 }}173 >174 <Plus className="size-5 stroke-[1.5]" aria-hidden />175 </button>176 </div>177 </div>178 );179}