"use client"; import { AnimatePresence, type MotionValue, motion, useAnimationControls, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface DockApp { id: string; name: string; icon: ReactNode; tile?: string; running?: boolean; badge?: number; } export interface MagnifyDockProps { apps: DockApp[]; trailing?: DockApp[]; size?: number; magnification?: number; reach?: number; onOpen?: (app: DockApp) => void; className?: string; } const LAUNCH_MS = 1100; export function MagnifyDock({ apps, trailing = [], size = 40, magnification = 64, reach = 120, onOpen, className, }: MagnifyDockProps) { const reduce = useReducedMotion(); const pointerX = useMotionValue(Number.POSITIVE_INFINITY); const [running, setRunning] = useState(() => new Set([...apps, ...trailing].filter((a) => a.running).map((a) => a.id))); const [launching, setLaunching] = useState(null); const [label, setLabel] = useState(null); const [focusIndex, setFocusIndex] = useState(0); const warm = useRef(false); const labelTimer = useRef(undefined); const launchTimer = useRef(undefined); const refs = useRef<(HTMLButtonElement | null)[]>([]); useEffect( () => () => { clearTimeout(labelTimer.current); clearTimeout(launchTimer.current); }, [], ); const all = [...apps, ...trailing]; const showLabel = (id: string) => { clearTimeout(labelTimer.current); if (warm.current) return setLabel(id); labelTimer.current = window.setTimeout(() => { warm.current = true; setLabel(id); }, 220); }; const hideLabels = () => { clearTimeout(labelTimer.current); warm.current = false; setLabel(null); }; const open = (app: DockApp) => { if (running.has(app.id) || reduce) { setRunning((s) => new Set(s).add(app.id)); onOpen?.(app); return; } if (launching) return; setLaunching(app.id); clearTimeout(launchTimer.current); launchTimer.current = window.setTimeout(() => { setLaunching(null); setRunning((s) => new Set(s).add(app.id)); onOpen?.(app); }, LAUNCH_MS); }; const magnifyAround = (index: number) => { const el = refs.current[index]; if (!el) return; const r = el.getBoundingClientRect(); pointerX.set(r.left + r.width / 2); }; const onKeyDown = (event: KeyboardEvent) => { const n = all.length; let next = -1; if (event.key === "ArrowRight") next = (focusIndex + 1) % n; else if (event.key === "ArrowLeft") next = (focusIndex - 1 + n) % n; else if (event.key === "Home") next = 0; else if (event.key === "End") next = n - 1; if (next < 0) return; event.preventDefault(); setFocusIndex(next); refs.current[next]?.focus(); }; const tile = (app: DockApp, index: number) => ( { pointerX.set(Number.POSITIVE_INFINITY); hideLabels(); }} onFocus={(event) => { setFocusIndex(index); if (event.currentTarget.matches(":focus-visible")) { magnifyAround(index); warm.current = true; setLabel(app.id); } }} onHover={() => showLabel(app.id)} onOpen={() => open(app)} pointerX={pointerX} reach={reach} ref={(el) => { refs.current[index] = el; }} reduce={!!reduce} running={running.has(app.id)} size={size} /> ); return (
{ pointerX.set(Number.POSITIVE_INFINITY); hideLabels(); }} onPointerMove={(event) => { if (event.pointerType !== "touch") pointerX.set(event.clientX); }} role="toolbar" style={{ height: size + 18 }} > {apps.map((app, i) => tile(app, i))} {trailing.length > 0 &&
} {trailing.map((app, i) => tile(app, apps.length + i))}
); } interface DockTileProps { app: DockApp; pointerX: MotionValue; size: number; magnification: number; reach: number; running: boolean; launching: boolean; label: boolean; labelAnimated: boolean; focusable: boolean; reduce: boolean; onOpen: () => void; onHover: () => void; onFocus: (event: React.FocusEvent) => void; onBlur: () => void; ref: (el: HTMLButtonElement | null) => void; } function DockTile({ app, pointerX, size, magnification, reach, running, launching, label, labelAnimated, focusable, reduce, onOpen, onHover, onFocus, onBlur, ref, }: DockTileProps) { const local = useRef(null); const bounce = useAnimationControls(); const distance = useTransform(pointerX, (x) => { const r = local.current?.getBoundingClientRect(); if (!r || !Number.isFinite(x)) return reach * 2; return x - (r.left + r.width / 2); }); const target = useTransform(distance, [-reach, 0, reach], [size, magnification, size], { clamp: true }); const width = useSpring(target, { stiffness: 380, damping: 32, mass: 0.6 }); useEffect(() => { if (!launching) return; bounce.start({ y: [0, -size * 0.55, 0, -size * 0.3, 0], transition: { duration: LAUNCH_MS / 1000, times: [0, 0.25, 0.5, 0.72, 0.92], ease: ["easeOut", "easeIn", "easeOut", "easeIn"] }, }); }, [launching, bounce, size]); return ( {label && !launching && ( {app.name} )} event.pointerType !== "touch" && onHover()} ref={(el) => { local.current = el; ref(el); }} tabIndex={focusable ? 0 : -1} type="button" whileTap={reduce ? undefined : { scale: 0.97 }} > {app.icon} {app.badge ? ( {app.badge} ) : null} ); }