{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "speed-dial", "title": "Speed Dial", "description": "FAB that expands into a radial menu of actions. Compound component: Root, Trigger, Item. Supports spread angle and direction. | FABを押すと放射状にアクションボタンが広がるメニュー。Root/Trigger/Itemの複合コンポーネント。", "dependencies": [ "motion", "lucide-react" ], "files": [ { "path": "components/navigation/speed-dial.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n cloneElement,\n Children,\n isValidElement,\n type ReactNode,\n} from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { Menu, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/** 0° = right, 90° = down, 180° = left, 270° = up (CSS/design convention) */\nfunction angleToPosition(angleDeg: number, radius: number) {\n const rad = (angleDeg * Math.PI) / 180;\n const x = Math.cos(rad) * radius;\n const y = Math.sin(rad) * radius;\n return { x, y };\n}\n\nconst SPRING_SHOOTOUT = { stiffness: 800, damping: 24 };\n\n// ---------------------------------------------------------------------------\n// Item size map (px) - sm=32, default=40, lg=48; items 錨點為 bottom-right,需 offset 使中心落在 radial 位置上\n// ---------------------------------------------------------------------------\nconst ITEM_SIZE_MAP = { sm: 32, default: 40, lg: 48 } as const;\nexport type SpeedDialItemSize = keyof typeof ITEM_SIZE_MAP;\n\nconst speedDialItemVariants = cva(\n \"absolute bottom-0 right-0 flex items-center justify-center rounded-full shadow-lg transition-transform will-change-transform hover:scale-105 hover:shadow-xl cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n {\n variants: {\n size: {\n sm: \"size-8\",\n default: \"size-10\",\n lg: \"size-12\",\n },\n },\n defaultVariants: {\n size: \"default\",\n },\n },\n);\n\n// ---------------------------------------------------------------------------\n// Context\n// ---------------------------------------------------------------------------\ninterface SpeedDialContextValue {\n open: boolean;\n setOpen: React.Dispatch>;\n radius: number;\n spreadRangeAngle: number;\n directionAngle: number;\n itemCount: number;\n menuId: string;\n triggerRef: React.RefObject;\n defaultItemSize: SpeedDialItemSize;\n}\n\nconst SpeedDialContext = createContext(null);\n\nfunction useSpeedDial(component: string) {\n const ctx = useContext(SpeedDialContext);\n if (!ctx) {\n throw new Error(`${component} must be used within SpeedDial.Root`);\n }\n return ctx;\n}\n\n// ---------------------------------------------------------------------------\n// Trigger variants (cva)\n// ---------------------------------------------------------------------------\nconst speedDialTriggerVariants = cva(\n \"relative inline-flex items-center justify-center rounded-full shadow-lg transition-transform outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer z-10\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n outline:\n \"border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n },\n size: {\n default: \"size-16\",\n sm: \"size-12\",\n lg: \"size-20\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n },\n);\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\nexport interface SpeedDialRootProps {\n children: ReactNode;\n /** Total angle (degrees) the items spread over. e.g. 360 full circle, 180 half, 30 narrow. */\n spreadRangeAngle?: number;\n /** Direction of the fan: 0°=right, 90°=down, 180°=left, 270°=up. */\n directionAngle?: number;\n /** Distance from trigger to each item (px). */\n radius?: number;\n /** Default size for all SpeedDial.Item children. */\n itemSize?: SpeedDialItemSize;\n className?: string;\n}\n\nfunction SpeedDialRoot({\n children,\n spreadRangeAngle = 90,\n directionAngle = 90,\n radius = 120,\n itemSize = \"default\",\n className,\n}: SpeedDialRootProps) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef(null);\n const triggerRef = useRef(null);\n\n const childArray = Children.toArray(children);\n const itemCount = childArray.filter(\n (c) => isValidElement(c) && (c.type as unknown) === SpeedDialItem,\n ).length;\n\n const menuId = \"speed-dial-menu\";\n\n useEffect(() => {\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && open) {\n setOpen(false);\n triggerRef.current?.focus();\n }\n };\n document.addEventListener(\"keydown\", handleEscape);\n return () => document.removeEventListener(\"keydown\", handleEscape);\n }, [open]);\n\n useEffect(() => {\n const handleClickOutside = (e: MouseEvent) => {\n const target = e.target as Node;\n if (rootRef.current && !rootRef.current.contains(target) && open) {\n setOpen(false);\n }\n };\n if (open) {\n document.addEventListener(\"click\", handleClickOutside, true);\n return () =>\n document.removeEventListener(\"click\", handleClickOutside, true);\n }\n }, [open]);\n\n useEffect(() => {\n const handleFocusOut = (e: FocusEvent) => {\n if (\n rootRef.current &&\n !rootRef.current.contains(e.relatedTarget as Node) &&\n open\n ) {\n setOpen(false);\n }\n };\n if (open && rootRef.current) {\n rootRef.current.addEventListener(\"focusout\", handleFocusOut);\n return () =>\n rootRef.current?.removeEventListener(\"focusout\", handleFocusOut);\n }\n }, [open]);\n\n const value: SpeedDialContextValue = {\n open,\n setOpen,\n radius,\n spreadRangeAngle,\n directionAngle,\n itemCount,\n menuId,\n triggerRef,\n defaultItemSize: itemSize,\n };\n\n const itemChildren: ReactNode[] = [];\n let triggerChild: ReactNode = null;\n const restChildren: ReactNode[] = [];\n let itemIndex = 0;\n Children.forEach(children, (child) => {\n if (isValidElement(child) && (child.type as unknown) === SpeedDialItem) {\n itemChildren.push(\n cloneElement(child as React.ReactElement<{ index: number }>, {\n index: itemIndex++,\n }),\n );\n } else if (\n isValidElement(child) &&\n (child.type as unknown) === SpeedDialTrigger\n ) {\n triggerChild = child;\n } else {\n restChildren.push(child);\n }\n });\n\n return (\n \n \n {triggerChild}\n \n {open && (\n \n {itemChildren}\n \n )}\n \n {restChildren}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Trigger\n// ---------------------------------------------------------------------------\nexport interface SpeedDialTriggerProps\n extends\n Omit, \"children\">,\n VariantProps {\n children?: ReactNode;\n}\n\nfunction SpeedDialTrigger({\n className,\n variant = \"default\",\n size = \"default\",\n children,\n ...props\n}: SpeedDialTriggerProps) {\n const { open, setOpen, menuId, triggerRef } =\n useSpeedDial(\"SpeedDial.Trigger\");\n\n const handleMouseDown = (e: React.MouseEvent) => {\n e.stopPropagation();\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if ((e.key === \"Enter\" || e.key === \" \") && !open) {\n e.preventDefault();\n setOpen(true);\n }\n };\n\n const triggerAria = {\n \"aria-haspopup\": \"menu\" as const,\n \"aria-expanded\": open ? (\"true\" as const) : (\"false\" as const),\n \"aria-controls\": menuId,\n \"aria-label\": open ? \"Close menu\" : \"Open menu\",\n };\n\n return (\n setOpen((v) => !v)}\n onMouseDown={handleMouseDown}\n onKeyDown={handleKeyDown}\n {...triggerAria}\n {...props}\n >\n \n {open ? (\n \n {children ?? }\n \n ) : (\n \n {children ?? }\n \n )}\n \n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Item (internal component for cloneElement check)\n// ---------------------------------------------------------------------------\nexport interface SpeedDialItemProps\n extends VariantProps {\n children: ReactNode;\n className?: string;\n /** Injected by SpeedDial.Root. */\n index?: number;\n onClick?: () => void;\n}\n\nfunction SpeedDialItemComponent({\n children,\n className,\n size,\n index = 0,\n onClick,\n}: SpeedDialItemProps) {\n const {\n open,\n setOpen,\n radius,\n spreadRangeAngle,\n directionAngle,\n itemCount,\n defaultItemSize,\n } = useSpeedDial(\"SpeedDial.Item\");\n\n const effectiveSize = size ?? defaultItemSize ?? \"default\";\n const itemHalf = ITEM_SIZE_MAP[effectiveSize] / 2;\n\n // 使用 (index + 0.5) / itemCount 讓 n 個 item 均勻分佈、居中,並避免 360° 時首尾重疊\n const angle =\n itemCount <= 1\n ? directionAngle\n : directionAngle -\n spreadRangeAngle / 2 +\n ((index + 0.5) / itemCount) * spreadRangeAngle;\n\n const centerPos = angleToPosition(angle, 0);\n const targetPos = angleToPosition(angle, radius);\n const centerX = centerPos.x + itemHalf;\n const centerY = centerPos.y + itemHalf;\n const targetX = targetPos.x + itemHalf;\n const targetY = targetPos.y + itemHalf;\n\n const close = useCallback(() => {\n setOpen(false);\n }, [setOpen]);\n\n const handleClick = useCallback(\n (e: React.MouseEvent) => {\n onClick?.();\n close();\n },\n [onClick, close],\n );\n\n return (\n {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onClick?.();\n close();\n }\n }}\n tabIndex={0}\n >\n {children}\n \n );\n}\n\nconst SpeedDialItem = SpeedDialItemComponent;\n\n// ---------------------------------------------------------------------------\n// Compound export\n// ---------------------------------------------------------------------------\nexport const SpeedDial = {\n Root: SpeedDialRoot,\n Trigger: SpeedDialTrigger,\n Item: SpeedDialItem,\n};\n\nexport { speedDialTriggerVariants };\n", "type": "registry:ui" } ], "type": "registry:ui" }