{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-to-delete",
  "title": "Swipe to Delete",
  "description": "An iOS-style swipeable list-item wrapper that reveals a delete action on drag, pops the icon at the commit threshold, and collapses the row before firing onDelete",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/swipe-to-delete/swipe-to-delete.tsx",
      "content": "/**\n * Spectrum UI — SwipeToDelete\n *\n * A swipeable list-item wrapper that reveals a delete action, iOS style. Drag\n * the row left to uncover a rose action zone; the trash icon pops the moment\n * the drag passes the commit threshold, and releasing past it (or flinging)\n * snaps the row fully open, collapses the item and fires onDelete. Non-touch\n * users get a hover/focus delete button plus Delete/Backspace on the focused\n * row, and reduced motion keeps the drag but collapses instantly.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <SwipeToDelete label=\"email from Ava\" onDelete={() => removeEmail(id)}>\n *   <EmailRow />\n * </SwipeToDelete>\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  motion,\n  useAnimationControls,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n} from \"framer-motion\"\nimport type { PanInfo } from \"framer-motion\"\nimport { Trash2 } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type { ReactNode } from \"react\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface SwipeToDeleteProps {\n  /** Fires once the collapse animation finishes; remove the item here */\n  onDelete: () => void\n  /** Row content rendered on the draggable surface */\n  children: ReactNode\n  /** Accessible name of the row; also used for the delete button label. Default \"item\" */\n  label?: string\n  /** Width in pixels of the revealed delete zone. Default 96 */\n  actionWidth?: number\n  /** Fraction of actionWidth the drag must pass to commit. Default 0.6 */\n  threshold?: number\n  /** Show a fallback delete button on row hover/focus. Default true */\n  showButtonOnHover?: boolean\n  /** Disables dragging, the delete button and keyboard deletion */\n  disabled?: boolean\n  /** Additional classes merged with the default wrapper styles */\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Leftward release velocity (px/s) that commits regardless of distance */\nconst FLING_VELOCITY = -500\nconst COLLAPSE_DURATION = 0.25\n/** Reveal/collapse ease shared with the docs motion language */\nconst COLLAPSE_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]\n/** Row offset (px) under which the row counts as fully back at rest */\nconst REST_EPSILON = 0.5\n/** How long the live-region announcement stays mounted before onDelete */\nconst ANNOUNCE_HOLD_MS = 300\n/** Firm resistance past fully open; no rightward overdrag at all */\nconst DRAG_ELASTIC = { left: 0.15, right: 0 } as const\n\nconst SNAP_OPEN_SPRING = { type: \"spring\", stiffness: 550, damping: 42 } as const\nconst SNAP_BACK_SPRING = { type: \"spring\", stiffness: 500, damping: 38 } as const\nconst ICON_POP_SPRING = { type: \"spring\", stiffness: 520, damping: 18 } as const\nconst ICON_REST_SPRING = { type: \"spring\", stiffness: 400, damping: 30 } as const\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function SwipeToDelete({\n  onDelete,\n  children,\n  label = \"item\",\n  actionWidth = 96,\n  threshold = 0.6,\n  showButtonOnHover = true,\n  disabled = false,\n  className,\n}: SwipeToDeleteProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const outerRef = useRef<HTMLDivElement>(null)\n  const committedRef = useRef(false)\n  const x = useMotionValue(0)\n  const rowControls = useAnimationControls()\n  const outerControls = useAnimationControls()\n  const [isDeleting, setIsDeleting] = useState(false)\n  const [pastThreshold, setPastThreshold] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const [isResting, setIsResting] = useState(true)\n  const holdTimeoutRef = useRef<number | null>(null)\n\n  // Pop the trash icon exactly when the drag crosses the commit point (and\n  // un-pop when dragged back), and track whether the row is offset so the\n  // hover delete button stays hidden during a drag and while snapping back\n  useMotionValueEvent(x, \"change\", (latest) => {\n    setPastThreshold(latest <= -actionWidth * threshold)\n    setIsResting(latest > -REST_EPSILON)\n  })\n\n  // Clear the announcement hold if the row unmounts mid-delete\n  useEffect(\n    () => () => {\n      if (holdTimeoutRef.current !== null) {\n        window.clearTimeout(holdTimeoutRef.current)\n      }\n    },\n    [],\n  )\n\n  const commit = useCallback(async () => {\n    if (disabled || committedRef.current) return\n    committedRef.current = true\n    setIsDeleting(true)\n\n    const node = outerRef.current\n    const height = node?.offsetHeight ?? 0\n    const marginBottom = node ? getComputedStyle(node).marginBottom : 0\n\n    // Snap fully open so the action zone reads before the row leaves\n    if (!shouldReduceMotion) {\n      await rowControls.start({ x: -actionWidth, transition: SNAP_OPEN_SPRING })\n    }\n\n    outerControls.set({ height, marginBottom })\n    await outerControls.start({\n      height: 0,\n      opacity: 0,\n      marginBottom: 0,\n      transition: shouldReduceMotion\n        ? { duration: 0 }\n        : { duration: COLLAPSE_DURATION, ease: COLLAPSE_EASE },\n    })\n    // The row is already invisible here, so this hold is imperceptible — it\n    // keeps the live-region announcement mounted long enough for screen\n    // readers to read it before onDelete unmounts the component\n    await new Promise<void>((resolve) => {\n      holdTimeoutRef.current = window.setTimeout(resolve, ANNOUNCE_HOLD_MS)\n    })\n    onDelete()\n  }, [\n    disabled,\n    shouldReduceMotion,\n    actionWidth,\n    rowControls,\n    outerControls,\n    onDelete,\n  ])\n\n  const handleDragEnd = useCallback(\n    (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {\n      setIsDragging(false)\n      if (committedRef.current) return\n      const commitPoint = -actionWidth * threshold\n      if (x.get() <= commitPoint || info.velocity.x < FLING_VELOCITY) {\n        void commit()\n      } else {\n        void rowControls.start({ x: 0, transition: SNAP_BACK_SPRING })\n      }\n    },\n    [actionWidth, threshold, x, commit, rowControls],\n  )\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      // Only when the row wrapper itself is focused, never inside its content\n      if (disabled || event.target !== event.currentTarget) return\n      if (event.key === \"Delete\" || event.key === \"Backspace\") {\n        event.preventDefault()\n        void commit()\n      }\n    },\n    [disabled, commit],\n  )\n\n  return (\n    <motion.div\n      ref={outerRef}\n      role=\"group\"\n      aria-label={label}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={handleKeyDown}\n      initial={false}\n      animate={outerControls}\n      className={cn(\n        \"group/swipe relative w-full rounded-xl\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n        isDeleting && \"overflow-hidden\",\n        className,\n      )}\n    >\n      <div className=\"relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800\">\n        {/* Revealed action zone */}\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-y-0 right-0 flex items-center justify-center bg-rose-500 text-white\"\n          style={{ width: actionWidth }}\n        >\n          <motion.span\n            className=\"flex\"\n            initial={false}\n            animate={{ scale: shouldReduceMotion || pastThreshold ? 1 : 0.7 }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : pastThreshold\n                  ? ICON_POP_SPRING\n                  : ICON_REST_SPRING\n            }\n          >\n            <Trash2 size={18} aria-hidden=\"true\" />\n          </motion.span>\n        </div>\n\n        {/* Draggable row */}\n        <motion.div\n          drag={disabled || isDeleting ? false : \"x\"}\n          dragConstraints={{ left: -actionWidth, right: 0 }}\n          dragElastic={DRAG_ELASTIC}\n          dragMomentum={false}\n          onDragStart={() => setIsDragging(true)}\n          onDragEnd={handleDragEnd}\n          initial={false}\n          animate={rowControls}\n          style={{ x }}\n          className={cn(\n            // touch-pan-y leaves vertical scroll to the page; drag=\"x\" only\n            // captures the pointer once a horizontal gesture wins\n            \"relative w-full touch-pan-y select-none bg-white dark:bg-neutral-900\",\n            !disabled && !isDeleting && \"cursor-grab active:cursor-grabbing\",\n          )}\n        >\n          {children}\n\n          {/* Fallback affordance for non-touch and keyboard users */}\n          {showButtonOnHover && !disabled && (\n            <button\n              type=\"button\"\n              aria-label={`Delete ${label}`}\n              onClick={() => void commit()}\n              onPointerDown={(event) => event.stopPropagation()}\n              className={cn(\n                \"absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 touch-manipulation select-none items-center justify-center rounded-md\",\n                \"text-neutral-500 hover:bg-rose-50 hover:text-rose-600 active:bg-rose-100 dark:text-neutral-400 dark:hover:bg-rose-500/10 dark:hover:text-rose-400 dark:active:bg-rose-500/20\",\n                \"opacity-0 transition-opacity duration-150\",\n                // Never surface the button mid-drag or while the row is offset\n                isDragging || !isResting || isDeleting\n                  ? \"pointer-events-none\"\n                  : \"group-hover/swipe:opacity-100 group-focus-within/swipe:opacity-100 focus-visible:opacity-100\",\n                \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n              )}\n            >\n              <Trash2 size={14} aria-hidden=\"true\" />\n            </button>\n          )}\n        </motion.div>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {isDeleting ? `${label} deleted` : \"\"}\n      </span>\n    </motion.div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/swipe-to-delete.tsx"
    }
  ],
  "type": "registry:component"
}
