{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "long-press",
  "type": "registry:ui",
  "title": "Long Press",
  "description": "Press and hold with a filling ring, right click and Shift F10 included.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/long-press.tsx",
      "content": "\"use client\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type LongPressPointer = \"mouse\" | \"touch\" | \"pen\" | \"keyboard\";\n\nexport type LongPressEvent = {\n  /** Where the press happened, relative to the element. The element's center for keyboard. */\n  x: number;\n  y: number;\n  clientX: number;\n  clientY: number;\n  pointerType: LongPressPointer;\n  /** True when a right click or the context menu key asked for it directly, with no hold. */\n  immediate: boolean;\n  target: HTMLElement;\n};\n\nexport type LongPressState = \"idle\" | \"pressing\" | \"fired\";\n\nexport type UseLongPressOptions = {\n  onLongPress: (event: LongPressEvent) => void;\n  /** Milliseconds the press must be held. */\n  duration?: number;\n  /** Milliseconds before any feedback shows, so a tap never flashes a ring. */\n  delay?: number;\n  /** Pixels the pointer may wander before the press counts as a scroll or drag and cancels. */\n  tolerance?: number;\n  /** A right click, the context menu key or Shift+F10 also fire it, instantly. */\n  contextMenu?: boolean;\n  onStart?: () => void;\n  onCancel?: () => void;\n  disabled?: boolean;\n};\n\ntype Bind = {\n  onPointerDown: (e: React.PointerEvent<HTMLElement>) => void;\n  onPointerMove: (e: React.PointerEvent<HTMLElement>) => void;\n  onPointerUp: (e: React.PointerEvent<HTMLElement>) => void;\n  onPointerCancel: (e: React.PointerEvent<HTMLElement>) => void;\n  onContextMenu: (e: React.MouseEvent<HTMLElement>) => void;\n  onClickCapture: (e: React.MouseEvent<HTMLElement>) => void;\n  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;\n};\n\n/**\n * Press-and-hold on anything. Returns handlers to spread on the element, the press state,\n * a 0–1 progress value to draw from, and where the press began.\n */\nexport function useLongPress({\n  onLongPress,\n  duration = 500,\n  delay = 120,\n  tolerance = 10,\n  contextMenu = true,\n  onStart,\n  onCancel,\n  disabled = false,\n}: UseLongPressOptions) {\n  const [state, setState] = useState<LongPressState>(\"idle\");\n  const [origin, setOrigin] = useState<{ x: number; y: number; pointerType: LongPressPointer } | null>(null);\n  const progress = useMotionValue(0);\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const p = useRef({ id: -1, x0: 0, y0: 0, type: \"mouse\" as LongPressPointer, fired: false, target: null as HTMLElement | null });\n  const swallowClick = useRef(false);\n\n  useEffect(() => () => anim.current?.stop(), []);\n\n  const fire = (target: HTMLElement, clientX: number, clientY: number, pointerType: LongPressPointer, immediate: boolean) => {\n    const r = target.getBoundingClientRect();\n    onLongPress({ x: clientX - r.left, y: clientY - r.top, clientX, clientY, pointerType, immediate, target });\n  };\n\n  const reset = (drain: boolean) => {\n    anim.current?.stop();\n    // Drains faster than it fills, from wherever it got to.\n    if (drain) anim.current = animate(progress, 0, { duration: Math.max(0.12, progress.get() * 0.25), ease: \"easeOut\" });\n    else progress.set(0);\n  };\n\n  const cancel = () => {\n    if (p.current.id === -1) return;\n    p.current.id = -1;\n    if (!p.current.fired) {\n      reset(true);\n      setState(\"idle\");\n      onCancel?.();\n    }\n  };\n\n  const bind: Bind = {\n    onPointerDown(e) {\n      if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n      const target = e.currentTarget;\n      const r = target.getBoundingClientRect();\n      const type = e.pointerType as LongPressPointer;\n      p.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type, fired: false, target };\n      swallowClick.current = false;\n      setOrigin({ x: e.clientX - r.left, y: e.clientY - r.top, pointerType: type });\n      setState(\"pressing\");\n      onStart?.();\n      anim.current?.stop();\n      progress.set(0);\n      anim.current = animate(progress, 1, {\n        duration: duration / 1000,\n        ease: \"linear\",\n        onComplete: () => {\n          if (p.current.id !== e.pointerId) return;\n          p.current.fired = true;\n          swallowClick.current = true;\n          setState(\"fired\");\n          navigator.vibrate?.(10);\n          fire(target, p.current.x0, p.current.y0, type, false);\n        },\n      });\n    },\n    onPointerMove(e) {\n      if (p.current.id !== e.pointerId || p.current.fired) return;\n      if (Math.hypot(e.clientX - p.current.x0, e.clientY - p.current.y0) > tolerance) cancel();\n    },\n    onPointerUp(e) {\n      if (p.current.id !== e.pointerId) return;\n      if (p.current.fired) {\n        p.current.id = -1;\n        reset(false);\n        setState(\"idle\");\n      } else cancel();\n    },\n    onPointerCancel(e) {\n      if (p.current.id === e.pointerId) cancel();\n    },\n    onContextMenu(e) {\n      // Touch and pen raise their own context menu mid-hold; the timer owns that gesture.\n      if (p.current.id !== -1 && p.current.type !== \"mouse\") {\n        e.preventDefault();\n        return;\n      }\n      if (!contextMenu || disabled || e.defaultPrevented) return;\n      e.preventDefault();\n      fire(e.currentTarget, e.clientX, e.clientY, \"mouse\", true);\n    },\n    onClickCapture(e) {\n      // The release that ends a long press is not also a tap.\n      if (!swallowClick.current) return;\n      swallowClick.current = false;\n      e.preventDefault();\n      e.stopPropagation();\n    },\n    onKeyDown(e) {\n      if (!contextMenu || disabled) return;\n      if (e.key === \"ContextMenu\" || (e.shiftKey && e.key === \"F10\")) {\n        e.preventDefault();\n        const r = e.currentTarget.getBoundingClientRect();\n        fire(e.currentTarget, r.left + r.width / 2, r.top + r.height / 2, \"keyboard\", true);\n      }\n    },\n  };\n\n  return { bind, state, progress, origin, cancel };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The component: the hook plus a ring that fills under the press\n * -----------------------------------------------------------------------------------------------*/\n\ntype A11yProps = { \"aria-describedby\": string; \"aria-keyshortcuts\"?: string };\n\n// The press handlers belong to the gesture; reach for useLongPress to compose your own.\nexport type LongPressProps = Omit<\n  React.ComponentProps<\"div\">,\n  | \"children\"\n  | \"contextMenu\"\n  | \"onContextMenu\"\n  | \"onPointerDown\"\n  | \"onPointerMove\"\n  | \"onPointerUp\"\n  | \"onPointerCancel\"\n  | \"onClickCapture\"\n  | \"onKeyDown\"\n  // Motion owns these on the root.\n  | \"onDrag\"\n  | \"onDragStart\"\n  | \"onDragEnd\"\n  | \"onAnimationStart\"\n  | \"onAnimationEnd\"\n> &\n  Omit<UseLongPressOptions, \"disabled\"> & {\n    /** The pressable thing. As a function it receives the hint's id and shortcut to put on your focusable element. */\n    children: React.ReactNode | ((a11y: A11yProps) => React.ReactNode);\n    /** Read by screen readers after the element's name. */\n    hint?: string;\n    /** The element sinks slightly while held, and springs back when it fires. */\n    sink?: boolean;\n    disabled?: boolean;\n  };\n\nexport function LongPress({\n  children,\n  onLongPress,\n  duration = 500,\n  delay = 120,\n  tolerance,\n  contextMenu = true,\n  onStart,\n  onCancel,\n  hint = \"Press and hold for more options\",\n  sink = true,\n  disabled = false,\n  className,\n  style,\n  ...rest\n}: LongPressProps) {\n  const reduce = useReducedMotion();\n  const hintId = useId();\n  const { bind, state, progress, origin } = useLongPress({ onLongPress, duration, delay, tolerance, contextMenu, onStart, onCancel, disabled });\n  const pop = useMotionValue(0);\n\n  // Nothing shows until the delay has passed; then the ring grows in and fills with the time left.\n  const lead = Math.min(0.6, delay / duration);\n  const shown = useTransform(() => Math.min(1, Math.max(0, (progress.get() - lead) / 0.12)));\n  const fill = useTransform(() => Math.max(0, (progress.get() - lead) / (1 - lead)));\n  const depth = useMotionValue(1);\n\n  useEffect(() => {\n    const moves: AnimationPlaybackControls[] = [];\n    if (state === \"fired\") {\n      if (reduce) pop.set(1);\n      else {\n        // The completed ring flares out once, and the element springs back up to meet what opened.\n        pop.set(0);\n        moves.push(animate(pop, 1, { duration: 0.32, ease: ease.out }));\n      }\n    }\n    if (!reduce && sink) {\n      // Held, it sinks for the length of the hold; let go early and it rises straight back.\n      if (state === \"pressing\") moves.push(animate(depth, 0.97, { duration: duration / 1000, ease: ease.outQuart }));\n      else moves.push(animate(depth, 1, state === \"fired\" ? spring.pop : { duration: 0.15, ease: ease.out }));\n    }\n    return () => moves.forEach((m) => m.stop());\n  }, [state, pop, depth, reduce, sink, duration]);\n\n  const a11y: A11yProps = { \"aria-describedby\": hintId, ...(contextMenu ? { \"aria-keyshortcuts\": \"Shift+F10\" } : {}) };\n  const touch = origin?.pointerType === \"touch\";\n\n  return (\n    <motion.div\n      data-state={state}\n      data-disabled={disabled || undefined}\n      {...bind}\n      style={{ ...style, scale: depth }}\n      className={cn(\n        \"relative select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]\",\n        className,\n      )}\n      {...rest}\n    >\n      {typeof children === \"function\" ? children(a11y) : children}\n      <span id={hintId} className=\"sr-only\">\n        {hint}\n        {contextMenu ? \", or press Shift F10\" : \"\"}\n      </span>\n      {origin && (\n        <Ring\n          key={`${origin.x},${origin.y}`}\n          x={origin.x}\n          y={origin.y}\n          // A fingertip covers a small ring, so touch draws one wide enough to show around it.\n          size={touch ? 72 : 40}\n          shown={shown}\n          fill={fill}\n          pop={pop}\n          fired={state === \"fired\"}\n          reduce={!!reduce}\n        />\n      )}\n    </motion.div>\n  );\n}\n\nfunction Ring({\n  x,\n  y,\n  size,\n  shown,\n  fill,\n  pop,\n  fired,\n  reduce,\n}: {\n  x: number;\n  y: number;\n  size: number;\n  shown: MotionValue<number>;\n  fill: MotionValue<number>;\n  pop: MotionValue<number>;\n  fired: boolean;\n  reduce: boolean;\n}) {\n  const stroke = size > 50 ? 3 : 2.5;\n  const r = size / 2 - stroke;\n  const scale = useTransform(() => (reduce ? 1 : fired ? 1 + 0.25 * pop.get() : 0.6 + 0.4 * shown.get()));\n  const opacity = useTransform(() => (fired ? 1 - pop.get() : shown.get()));\n  return (\n    <motion.span\n      aria-hidden\n      style={{ left: x, top: y, width: size, height: size, marginLeft: -size / 2, marginTop: -size / 2, scale, opacity }}\n      className=\"pointer-events-none absolute z-10 grid place-items-center\"\n    >\n      <span className=\"absolute inset-0 rounded-full bg-raised/90 shadow-pop\" />\n      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className=\"relative -rotate-90\">\n        <circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" strokeWidth={stroke} className=\"stroke-fg/20\" />\n        <motion.circle cx={size / 2} cy={size / 2} r={r} fill=\"none\" strokeWidth={stroke} strokeLinecap=\"round\" className=\"stroke-fg\" style={{ pathLength: fill }} />\n      </svg>\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/long-press"
}
