{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-long-press",
  "title": "useLongPress",
  "description": "A press-and-hold gesture hook returning spreadable pointer/keyboard handlers plus isPressing and 0–1 progress — pointer-captured, cancels past a move tolerance, works with held Space/Enter.",
  "files": [
    {
      "path": "src/registry/hooks/use-long-press.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nconst DEFAULT_THRESHOLD = 500\nconst DEFAULT_MOVE_TOLERANCE = 10\n/** Threshold floor: `threshold <= 0` turns `elapsed / threshold` into Infinity/NaN and makes every tap a long press. */\nconst MIN_THRESHOLD = 1\n/** \"Holding\" on a keyboard means holding Space or Enter — the same keys that activate a native button. */\nconst ACTIVATION_KEYS = new Set([\" \", \"Enter\"])\n\nexport type LongPressSource = \"pointer\" | \"keyboard\"\n\n/**\n * Why a press **failed** to reach `onLongPress`:\n * - `release` — released early, i.e. a plain tap/click; consumers usually put their click action here;\n * - `move` — the pointer drifted past `moveTolerance`; on touch that almost always means \"the user wants to scroll\";\n * - `leave` — the pointer left the element (only reachable where pointer capture is unavailable);\n * - `cancel` — the browser/OS took the gesture away (`pointercancel`: scroll, zoom, system gestures), or the element lost focus mid keyboard hold;\n * - `disabled` — `disabled` flipped to `true` while the press was running.\n */\nexport type LongPressCancelReason = \"release\" | \"move\" | \"leave\" | \"cancel\" | \"disabled\"\n\nexport interface LongPressMeta {\n  /** Whether this press started from a pointer or from the keyboard. */\n  source: LongPressSource\n  /** `PointerEvent.pointerType` (\"mouse\" | \"touch\" | \"pen\"); `null` for keyboard presses. */\n  pointerType: string | null\n}\n\nexport interface UseLongPressOptions {\n  /** Fires once the press has lasted `threshold`; at most once per press. */\n  onLongPress: (meta: LongPressMeta) => void\n  /** Fires the instant the press starts (before the threshold) — good for haptics or a highlight. */\n  onStart?: (meta: LongPressMeta) => void\n  /** Fires when the press ends before the threshold; `reason` says why. `reason === \"release\"` is a plain tap. */\n  onCancel?: (reason: LongPressCancelReason, meta: LongPressMeta) => void\n  /** Fires when a press that already fired `onLongPress` ends — release and cancel both count. */\n  onFinish?: (meta: LongPressMeta) => void\n  /** Milliseconds to hold before it counts as a long press. Defaults to 500; below 1 or non-finite gets clamped. */\n  threshold?: number\n  /** Largest drift allowed after the press (px, straight-line). Beyond it the gesture reads as scroll/drag and cancels. Defaults to 10. */\n  moveTolerance?: number\n  /** Turns the gesture off: presses short-circuit and `progress` stays 0; flipping it true mid-press cancels on the next frame. */\n  disabled?: boolean\n  /** Calls `preventDefault()` + `stopPropagation()` on the events this hook actually consumes, taking the gesture back from an ancestor's click/drag. Defaults to false. */\n  captureEvent?: boolean\n}\n\n/**\n * React event props to spread straight onto the target: `<button {...handlers} />`.\n * To add your own logic to one of them, **spread first, then wrap and call this\n * hook's handler** (`onPointerMove={e => { handlers.onPointerMove(e); mine(e) }}`);\n * overriding one outright deletes that whole branch of the gesture (override\n * `onPointerMove` and drift cancellation is gone).\n */\nexport interface LongPressHandlers<T extends HTMLElement = HTMLElement> {\n  onPointerDown: (event: React.PointerEvent<T>) => void\n  onPointerMove: (event: React.PointerEvent<T>) => void\n  onPointerUp: (event: React.PointerEvent<T>) => void\n  onPointerLeave: (event: React.PointerEvent<T>) => void\n  onPointerCancel: (event: React.PointerEvent<T>) => void\n  onContextMenu: (event: React.MouseEvent<T>) => void\n  onKeyDown: (event: React.KeyboardEvent<T>) => void\n  onKeyUp: (event: React.KeyboardEvent<T>) => void\n  onBlur: (event: React.FocusEvent<T>) => void\n}\n\nexport interface UseLongPressResult<T extends HTMLElement = HTMLElement> {\n  /** Event props to spread onto the target (stable identity, so memoised children don't re-render for nothing). */\n  handlers: LongPressHandlers<T>\n  /** Whether a press is running right now (still true after `onLongPress` fired, until release). */\n  isPressing: boolean\n  /** 0–1 charge progress for a ring or bar; 0 while idle, pinned at 1 once it fires. */\n  progress: number\n}\n\ninterface ActivePress {\n  source: LongPressSource\n  pointerType: string | null\n  pointerId: number | null\n  originX: number\n  originY: number\n  startedAt: number\n  /** Config snapshot taken at press time: changing threshold mid-press can't desync the timer from the bar. */\n  threshold: number\n  moveTolerance: number\n  /** Whether onLongPress already fired — decides whether the end goes to onFinish or onCancel. */\n  fired: boolean\n  /** The element that actually took pointer capture; null when it failed (only then does pointerleave serve as the fallback cancel). */\n  captureTarget: Element | null\n}\n\n/** Only the pointer that started a press may end it — a stray second finger must not interrupt a legitimate hold. */\nfunction isOwnPointer(active: ActivePress | null, pointerId: number) {\n  return active !== null && active.source === \"pointer\" && active.pointerId === pointerId\n}\n\nfunction resolveOptions(options: UseLongPressOptions) {\n  const rawThreshold = options.threshold ?? DEFAULT_THRESHOLD\n  const rawTolerance = options.moveTolerance ?? DEFAULT_MOVE_TOLERANCE\n  return {\n    threshold: Number.isFinite(rawThreshold)\n      ? Math.max(MIN_THRESHOLD, rawThreshold)\n      : DEFAULT_THRESHOLD,\n    moveTolerance: Number.isFinite(rawTolerance)\n      ? Math.max(0, rawTolerance)\n      : DEFAULT_MOVE_TOLERANCE,\n    disabled: options.disabled === true,\n    captureEvent: options.captureEvent === true,\n  }\n}\n\n/**\n * The **headless behaviour layer** for a press-and-hold gesture: it renders\n * nothing, just hands back a set of event props plus `isPressing` / `progress`,\n * and the consumer decides what a long press looks like (a ring on a delete\n * button, a hold-to-open menu, hold-to-multi-select…).\n *\n * Division of labour with `hold-to-confirm` (**don't rebuild it here**):\n * `src/registry/ui/hold-to-confirm.tsx` is a **finished button** — track fill,\n * contrast-flipping label, a confirmed end state; drop it in and barely touch it.\n * It paints progress straight onto its own DOM (bypassing React state) and so\n * does **not** expose progress at all. This hook goes the other way: the same\n * gesture semantics (hold timing, cancel on early release, keyboard reachable)\n * on **any** element, with progress handed back as a number. Want a ready-made\n * confirm button → use the component; want a long press on your own list rows,\n * cards or icon buttons → use this hook.\n *\n * Implementation notes:\n * - **Pointer Events cover all three inputs** (mouse / touch / pen) — no parallel\n *   touch + mouse paths. Mouse only counts the primary button (`button === 0`);\n *   right/middle click never starts the gesture.\n * - **`setPointerCapture`**: capturing the pointer on press keeps `pointerup`\n *   coming even once the finger/mouse leaves the element's bounds — otherwise\n *   \"hold, slide off, release\" leaves the press running forever. Where capture\n *   fails (jsdom, old WebViews) it degrades to cancelling on `pointerleave`.\n * - **Drift cancels**: drifting past `moveTolerance` fires `onCancel(\"move\")`\n *   immediately. The one that matters most on mobile — a finger reaching to\n *   scroll a list always drifts, and without this it fires a long press instead.\n * - **`onContextMenu` calls `preventDefault()` only while this hook owns a\n *   press**: a touch long press pops the system menu/selection handles and\n *   steals the gesture, so it has to be blocked; a desktop right click never\n *   starts a press here, so the element's own context menu still works. On touch\n *   the consumer also needs CSS: `touch-action` (`touch-none` when the element\n *   itself doesn't scroll) and `select-none`, or iOS raises the selection loupe.\n * - **Keyboard reachable**: holding Space / Enter long-presses too. Auto-repeat\n *   keydowns (`event.repeat`) are dropped — otherwise the timer restarts every\n *   30ms and never reaches the threshold — and Space's default scroll is blocked.\n *   Losing focus mid-hold (window switch, Tab away) delivers `blur` and never\n *   `keyup`, so that path ends as `cancel` instead of leaving a ghost press.\n * - **Clamp + snapshot**: a `threshold` under 1ms or non-finite is clamped or\n *   reset to the default (or progress divides by zero and any tap counts as a\n *   hold), `moveTolerance` is clamped to >= 0; both are snapshotted into the\n *   press at pointerdown, so editing config mid-press can't desync timer from bar.\n * - **latest-ref**: every callback and option lives in one ref and enters no\n *   dependency array, so an inline arrow function never rebuilds the timer and\n *   `handlers` keeps a stable identity.\n * - **Cleanup**: `clearTimeout` + `cancelAnimationFrame` run on all three exits —\n *   cancel, finish, unmount. Unmount frees resources only; it doesn't call back\n *   into a consumer that no longer exists.\n * - `prefers-reduced-motion` doesn't change behaviour: `progress` is\n *   **functional** (how much longer to hold), not decoration, so it keeps\n *   advancing; whether to transition it is the consumer's call.\n */\nexport function useLongPress<T extends HTMLElement = HTMLElement>(\n  options: UseLongPressOptions,\n): UseLongPressResult<T> {\n  const [isPressing, setIsPressing] = React.useState(false)\n  const [progress, setProgress] = React.useState(0)\n\n  // latest-ref: sync the newest options after every render (writing ref.current\n  // renders nothing). Every handler below reads from here, so no callback or\n  // option ever has to appear in a dependency array.\n  const optionsRef = React.useRef(options)\n  React.useEffect(() => {\n    optionsRef.current = options\n  })\n\n  const activeRef = React.useRef<ActivePress | null>(null)\n  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const rafRef = React.useRef(0)\n\n  const clearTimers = React.useCallback(() => {\n    if (timerRef.current !== null) {\n      clearTimeout(timerRef.current)\n      timerRef.current = null\n    }\n    if (rafRef.current) {\n      cancelAnimationFrame(rafRef.current)\n      rafRef.current = 0\n    }\n  }, [])\n\n  const end = React.useCallback(\n    (reason: LongPressCancelReason) => {\n      const active = activeRef.current\n      if (!active) return\n\n      clearTimers()\n      activeRef.current = null\n\n      // On a drift cancel the pointer is still down, so capture won't release\n      // itself — hand it back, or later hover/boundary events stay locked here.\n      if (active.captureTarget && active.pointerId !== null) {\n        try {\n          if (active.captureTarget.hasPointerCapture(active.pointerId)) {\n            active.captureTarget.releasePointerCapture(active.pointerId)\n          }\n        } catch {\n          // pointer already lifted or taken by the system: capture is long gone, not an error.\n        }\n      }\n\n      setIsPressing(false)\n      setProgress(0)\n\n      const meta: LongPressMeta = { source: active.source, pointerType: active.pointerType }\n      if (active.fired) optionsRef.current.onFinish?.(meta)\n      else optionsRef.current.onCancel?.(reason, meta)\n    },\n    [clearTimers],\n  )\n\n  const fire = React.useCallback(() => {\n    const active = activeRef.current\n    if (!active) return\n    timerRef.current = null\n\n    if (resolveOptions(optionsRef.current).disabled) {\n      end(\"disabled\")\n      return\n    }\n\n    if (rafRef.current) {\n      cancelAnimationFrame(rafRef.current)\n      rafRef.current = 0\n    }\n\n    active.fired = true\n    // the timer and rAF can be a frame apart: pin progress to 1 as it fires, so the ring never stops at 98%.\n    setProgress(1)\n    optionsRef.current.onLongPress({ source: active.source, pointerType: active.pointerType })\n  }, [end])\n\n  const start = React.useCallback(\n    (init: Omit<ActivePress, \"startedAt\" | \"threshold\" | \"moveTolerance\" | \"fired\">) => {\n      const config = resolveOptions(optionsRef.current)\n      const active: ActivePress = {\n        ...init,\n        startedAt: performance.now(),\n        threshold: config.threshold,\n        moveTolerance: config.moveTolerance,\n        fired: false,\n      }\n      activeRef.current = active\n\n      setIsPressing(true)\n      setProgress(0)\n\n      // rAF only advances progress; setTimeout owns the actual fire, so nothing\n      // fires twice. The loop checks it still owns this press (`current !== active`),\n      // so a leftover frame from the previous one can never paint onto the new one.\n      function step() {\n        const current = activeRef.current\n        if (current !== active) return\n        if (resolveOptions(optionsRef.current).disabled) {\n          end(\"disabled\")\n          return\n        }\n\n        const ratio = Math.min((performance.now() - active.startedAt) / active.threshold, 1)\n        setProgress(ratio)\n\n        if (ratio >= 1) {\n          rafRef.current = 0\n          return\n        }\n        rafRef.current = requestAnimationFrame(step)\n      }\n\n      timerRef.current = setTimeout(fire, config.threshold)\n      rafRef.current = requestAnimationFrame(step)\n\n      optionsRef.current.onStart?.({ source: active.source, pointerType: active.pointerType })\n    },\n    [end, fire],\n  )\n\n  const capture = React.useCallback((event: React.SyntheticEvent) => {\n    if (!resolveOptions(optionsRef.current).captureEvent) return\n    event.preventDefault()\n    event.stopPropagation()\n  }, [])\n\n  const onPointerDown = React.useCallback(\n    (event: React.PointerEvent<T>) => {\n      if (resolveOptions(optionsRef.current).disabled) return\n      if (event.pointerType === \"mouse\" && event.button !== 0) return\n      if (activeRef.current) return // a press is already running\n\n      capture(event)\n\n      const target = event.currentTarget\n      let captureTarget: Element | null = null\n      try {\n        target.setPointerCapture(event.pointerId)\n        captureTarget = target\n      } catch {\n        // no pointer capture (jsdom / old WebViews): fall back to cancelling on pointerleave.\n      }\n\n      start({\n        source: \"pointer\",\n        pointerType: event.pointerType,\n        pointerId: event.pointerId,\n        originX: event.clientX,\n        originY: event.clientY,\n        captureTarget,\n      })\n    },\n    [capture, start],\n  )\n\n  const onPointerMove = React.useCallback(\n    (event: React.PointerEvent<T>) => {\n      const active = activeRef.current\n      if (!isOwnPointer(active, event.pointerId) || !active) return\n      // once the long press has been recognised, further movement is the consumer's business (dragging on, say) — no cancel.\n      if (active.fired) return\n\n      const distance = Math.hypot(event.clientX - active.originX, event.clientY - active.originY)\n      if (distance > active.moveTolerance) end(\"move\")\n    },\n    [end],\n  )\n\n  const onPointerUp = React.useCallback(\n    (event: React.PointerEvent<T>) => {\n      if (!isOwnPointer(activeRef.current, event.pointerId)) return\n      capture(event)\n      end(\"release\")\n    },\n    [capture, end],\n  )\n\n  const onPointerLeave = React.useCallback(\n    (event: React.PointerEvent<T>) => {\n      const active = activeRef.current\n      if (!isOwnPointer(active, event.pointerId) || !active) return\n      // With pointer capture held, leaving the bounds shouldn't end the gesture\n      // (per spec pointerleave barely fires then anyway); this is only the\n      // fallback for the path where capture failed.\n      if (active.captureTarget) return\n      end(\"leave\")\n    },\n    [end],\n  )\n\n  const onPointerCancel = React.useCallback(\n    (event: React.PointerEvent<T>) => {\n      if (!isOwnPointer(activeRef.current, event.pointerId)) return\n      end(\"cancel\")\n    },\n    [end],\n  )\n\n  const onContextMenu = React.useCallback((event: React.MouseEvent<T>) => {\n    // A touch long press pops the system menu and steals the gesture — block it\n    // only while this hook actually owns a pointer press. A desktop right click\n    // never starts one, so the element's own context menu still works.\n    const active = activeRef.current\n    if (active && active.source === \"pointer\") event.preventDefault()\n  }, [])\n\n  const onKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<T>) => {\n      if (!ACTIVATION_KEYS.has(event.key)) return\n      // Space scrolls the page by default — block it for the whole hold, auto-repeat\n      // included, hence before the repeat check.\n      if (event.key === \" \") event.preventDefault()\n      if (event.repeat) return // only the first keydown of a held key counts\n      if (resolveOptions(optionsRef.current).disabled) return\n      if (activeRef.current) return\n\n      capture(event)\n      start({\n        source: \"keyboard\",\n        pointerType: null,\n        pointerId: null,\n        originX: 0,\n        originY: 0,\n        captureTarget: null,\n      })\n    },\n    [capture, start],\n  )\n\n  const onKeyUp = React.useCallback(\n    (event: React.KeyboardEvent<T>) => {\n      const active = activeRef.current\n      if (!active || active.source !== \"keyboard\") return\n      if (!ACTIVATION_KEYS.has(event.key)) return\n      capture(event)\n      end(\"release\")\n    },\n    [capture, end],\n  )\n\n  const onBlur = React.useCallback(() => {\n    // Switch windows or Tab away mid keyboard hold and `keyup` never arrives —\n    // without ending it here the press stays stuck \"down\" forever. Pointer\n    // presses don't depend on focus, so blur leaves them alone.\n    const active = activeRef.current\n    if (active && active.source === \"keyboard\") end(\"cancel\")\n  }, [end])\n\n  // Unmount: free resources only, no callbacks — the component is gone, so an onCancel here shouts into the void.\n  React.useEffect(() => clearTimers, [clearTimers])\n\n  const handlers = React.useMemo<LongPressHandlers<T>>(\n    () => ({\n      onPointerDown,\n      onPointerMove,\n      onPointerUp,\n      onPointerLeave,\n      onPointerCancel,\n      onContextMenu,\n      onKeyDown,\n      onKeyUp,\n      onBlur,\n    }),\n    [\n      onPointerDown,\n      onPointerMove,\n      onPointerUp,\n      onPointerLeave,\n      onPointerCancel,\n      onContextMenu,\n      onKeyDown,\n      onKeyUp,\n      onBlur,\n    ],\n  )\n\n  return { handlers, isPressing, progress }\n}\n\nexport default useLongPress\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}