{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-actions",
  "title": "Swipe Actions",
  "description": "List rows that swipe aside to reveal what can be done with them, the way a mail inbox does. Every action is also in a menu.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/swipe-actions/swipe-actions.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type SwipeActionTone = \"neutral\" | \"primary\" | \"destructive\"\n\nexport type SwipeAction = {\n  label: string\n  icon?: React.ReactNode\n  tone?: SwipeActionTone\n  onSelect: () => void\n}\n\nexport interface SwipeActionsProps extends React.HTMLAttributes<HTMLUListElement> {\n  /** The accessible name of the list. */\n  label: string\n}\n\nexport interface SwipeActionsItemProps extends Omit<\n  React.HTMLAttributes<HTMLLIElement>,\n  \"children\"\n> {\n  /** Revealed by swiping right. The first one sits at the edge. */\n  leading?: readonly SwipeAction[]\n  /** Revealed by swiping left. The first one sits at the edge. */\n  trailing?: readonly SwipeAction[]\n  /** A swipe most of the way across runs the edge action. Defaults to true. */\n  fullSwipe?: boolean\n  /** Names the row in the More actions button. */\n  label: string\n  moreLabel?: string\n  children: React.ReactNode\n}\n\ntype Side = \"leading\" | \"trailing\"\n\nconst ACTION_WIDTH = 76\nconst SLOP = 6\nconst RESISTANCE = 0.3\nconst SETTLE_MS = 220\n\ntype ListContextValue = {\n  open: { id: string; side: Side } | null\n  setOpen: (open: { id: string; side: Side } | null) => void\n}\n\nconst ListContext = React.createContext<ListContextValue | null>(null)\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReduced(onChange: () => void) {\n  const query = window.matchMedia(reducedQuery)\n  query.addEventListener(\"change\", onChange)\n  return () => query.removeEventListener(\"change\", onChange)\n}\n\nfunction useReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false\n  )\n}\n\nconst tones: Record<SwipeActionTone, string> = {\n  neutral: \"bg-secondary text-secondary-foreground\",\n  primary: \"bg-primary text-primary-foreground\",\n  destructive: \"bg-destructive text-white\",\n}\n\n/**\n * A list whose rows swipe aside to reveal what can be done with them, the way\n * a mail inbox does. Only one row is open at a time, and every action is also\n * in a menu, because a swipe is invisible to a keyboard and to a screen reader.\n */\nexport function SwipeActions({\n  label,\n  className,\n  children,\n  ...rootProps\n}: SwipeActionsProps) {\n  const [open, setOpen] = React.useState<ListContextValue[\"open\"]>(null)\n  const value = React.useMemo(() => ({ open, setOpen }), [open])\n\n  return (\n    <ListContext.Provider value={value}>\n      <ul\n        aria-label={label}\n        data-slot=\"swipe-actions\"\n        className={cn(\n          \"border-border bg-background divide-border divide-y overflow-hidden rounded-[var(--radius)] border\",\n          className\n        )}\n        {...rootProps}\n      >\n        {children}\n      </ul>\n    </ListContext.Provider>\n  )\n}\n\nfunction rubber(distance: number, limit: number, reduced: boolean) {\n  const size = Math.abs(distance)\n  if (size <= limit) return distance\n  if (reduced) return Math.sign(distance) * limit\n  return Math.sign(distance) * (limit + (size - limit) * RESISTANCE)\n}\n\nexport function SwipeActionsItem({\n  leading = [],\n  trailing = [],\n  fullSwipe = true,\n  label,\n  moreLabel = \"More actions\",\n  className,\n  children,\n  ...itemProps\n}: SwipeActionsItemProps) {\n  const list = React.useContext(ListContext)\n  const id = React.useId()\n  const reduced = useReducedMotion()\n  const rowRef = React.useRef<HTMLLIElement>(null)\n  const contentRef = React.useRef<HTMLDivElement>(null)\n  const moreRef = React.useRef<HTMLButtonElement>(null)\n  const menuRef = React.useRef<HTMLDivElement>(null)\n  const gesture = React.useRef<{\n    pointerId: number\n    x: number\n    y: number\n    base: number\n    axis: \"x\" | \"y\" | null\n    lastX: number\n    lastT: number\n    velocity: number\n  } | null>(null)\n  const swallowClick = React.useRef(false)\n  const snapTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const commitTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const [localOpen, setLocalOpen] = React.useState<Side | null>(null)\n  const [drag, setDrag] = React.useState<number | null>(null)\n  const [armed, setArmed] = React.useState<Side | null>(null)\n  const [snapping, setSnapping] = React.useState(false)\n  const [committing, setCommitting] = React.useState<{\n    side: Side\n    distance: number\n  } | null>(null)\n  const [menu, setMenu] = React.useState<React.CSSProperties | null>(null)\n  const menuOpen = menu !== null\n\n  const openSide = list\n    ? list.open?.id === id\n      ? list.open.side\n      : null\n    : localOpen\n  const setOpenSide = React.useCallback(\n    (side: Side | null) => {\n      if (list) list.setOpen(side ? { id, side } : null)\n      else setLocalOpen(side)\n    },\n    [id, list]\n  )\n\n  React.useEffect(\n    () => () => {\n      if (snapTimer.current) clearTimeout(snapTimer.current)\n      if (commitTimer.current) clearTimeout(commitTimer.current)\n    },\n    []\n  )\n\n  React.useEffect(() => {\n    if (!openSide) return\n    function closeOutside(event: PointerEvent) {\n      if (!rowRef.current?.contains(event.target as Node)) setOpenSide(null)\n    }\n    document.addEventListener(\"pointerdown\", closeOutside)\n    return () => document.removeEventListener(\"pointerdown\", closeOutside)\n  }, [openSide, setOpenSide])\n\n  React.useEffect(() => {\n    if (!menuOpen) return\n    function closeOutside(event: Event) {\n      const target = event.target as Node\n      if (\n        !menuRef.current?.contains(target) &&\n        !moreRef.current?.contains(target)\n      ) {\n        setMenu(null)\n      }\n    }\n    function close() {\n      setMenu(null)\n    }\n    document.addEventListener(\"pointerdown\", closeOutside)\n    window.addEventListener(\"resize\", close)\n    window.addEventListener(\"scroll\", closeOutside, true)\n    menuRef.current?.querySelector<HTMLElement>(\"[role='menuitem']\")?.focus()\n    return () => {\n      document.removeEventListener(\"pointerdown\", closeOutside)\n      window.removeEventListener(\"resize\", close)\n      window.removeEventListener(\"scroll\", closeOutside, true)\n    }\n  }, [menuOpen])\n\n  const actionsFor = (side: Side) => (side === \"leading\" ? leading : trailing)\n  const extentOf = (side: Side) => actionsFor(side).length * ACTION_WIDTH\n  const sideOf = (distance: number): Side =>\n    distance > 0 ? \"leading\" : \"trailing\"\n  const widthOf = () => contentRef.current?.offsetWidth ?? 0\n  const thresholdOf = (side: Side) =>\n    Math.max(widthOf() * 0.6, extentOf(side) + 72)\n\n  const run = (action: SwipeAction | undefined) => {\n    setOpenSide(null)\n    action?.onSelect()\n  }\n\n  const commit = (side: Side) => {\n    const action = actionsFor(side)[0]\n    setDrag(null)\n    setArmed(null)\n    if (reduced) {\n      run(action)\n      return\n    }\n    setCommitting({\n      side,\n      distance: Math.max(contentRef.current?.offsetWidth ?? 0, 480),\n    })\n    commitTimer.current = setTimeout(() => {\n      setCommitting(null)\n      run(action)\n    }, SETTLE_MS)\n  }\n\n  const restingOffset = (side: Side | null) =>\n    side === \"leading\"\n      ? extentOf(\"leading\")\n      : side === \"trailing\"\n        ? -extentOf(\"trailing\")\n        : 0\n\n  const offset =\n    drag ??\n    (committing\n      ? (committing.side === \"leading\" ? 1 : -1) * committing.distance\n      : restingOffset(openSide))\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0) return\n    if (\n      (event.target as HTMLElement).closest(\"[data-slot='swipe-actions-more']\")\n    )\n      return\n    if (committing) return\n    swallowClick.current = false\n    if (list && list.open && list.open.id !== id) list.setOpen(null)\n    gesture.current = {\n      pointerId: event.pointerId,\n      x: event.clientX,\n      y: event.clientY,\n      base: restingOffset(openSide),\n      axis: null,\n      lastX: event.clientX,\n      lastT: event.timeStamp,\n      velocity: 0,\n    }\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const current = gesture.current\n    if (!current || current.pointerId !== event.pointerId) return\n    const dx = event.clientX - current.x\n    const dy = event.clientY - current.y\n\n    if (current.axis === null) {\n      if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return\n      if (Math.abs(dy) > Math.abs(dx)) {\n        gesture.current = null\n        return\n      }\n      current.axis = \"x\"\n      event.currentTarget.setPointerCapture(event.pointerId)\n    }\n\n    const elapsed = event.timeStamp - current.lastT\n    if (elapsed > 0)\n      current.velocity = (event.clientX - current.lastX) / elapsed\n    current.lastX = event.clientX\n    current.lastT = event.timeStamp\n\n    const raw = current.base + dx\n    const side = sideOf(raw)\n    const extent = extentOf(side)\n    const canArm = fullSwipe && extent > 0\n    const nextArmed = canArm && Math.abs(raw) >= thresholdOf(side) ? side : null\n\n    if (nextArmed !== armed) {\n      setArmed(nextArmed)\n      if (!reduced) {\n        setSnapping(true)\n        if (snapTimer.current) clearTimeout(snapTimer.current)\n        snapTimer.current = setTimeout(() => setSnapping(false), 160)\n      }\n      if (nextArmed) navigator.vibrate?.(8)\n    }\n\n    setDrag(nextArmed ? raw : rubber(raw, extent, reduced))\n  }\n\n  const handlePointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {\n    const current = gesture.current\n    gesture.current = null\n    if (!current || current.axis !== \"x\" || drag === null) return\n    swallowClick.current = event.type === \"pointerup\"\n    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    }\n\n    if (armed && event.type === \"pointerup\") {\n      commit(armed)\n      return\n    }\n\n    setArmed(null)\n    setDrag(null)\n    const side = sideOf(drag)\n    const extent = extentOf(side)\n    const flick =\n      Math.abs(current.velocity) > 0.5 &&\n      Math.sign(current.velocity) === Math.sign(drag)\n    const opened = extent > 0 && (Math.abs(drag) > extent / 2 || flick)\n    setOpenSide(opened && event.type === \"pointerup\" ? side : null)\n  }\n\n  const handleClickCapture = (event: React.MouseEvent) => {\n    if (\n      (event.target as HTMLElement).closest(\"[data-slot='swipe-actions-more']\")\n    )\n      return\n    // A drag ends with a click on whatever was under it. That click belongs to\n    // the swipe, and so does the tap that closes an open row.\n    if (swallowClick.current) {\n      swallowClick.current = false\n      event.preventDefault()\n      event.stopPropagation()\n      return\n    }\n    if (openSide) {\n      event.preventDefault()\n      event.stopPropagation()\n      setOpenSide(null)\n    }\n  }\n\n  const all = [...leading, ...trailing]\n  const menuItems = () =>\n    Array.from(\n      menuRef.current?.querySelectorAll<HTMLElement>(\"[role='menuitem']\") ?? []\n    )\n\n  const closeMenu = (refocus: boolean) => {\n    setMenu(null)\n    if (refocus) moreRef.current?.focus()\n  }\n\n  const openMenu = () => {\n    const rect = moreRef.current?.getBoundingClientRect()\n    if (!rect) return\n    setOpenSide(null)\n    const right = document.documentElement.clientWidth - rect.right\n    const roomBelow = window.innerHeight - rect.bottom\n    setMenu(\n      roomBelow < (leading.length + trailing.length) * 40 + 24\n        ? { bottom: window.innerHeight - rect.top + 4, right }\n        : { top: rect.bottom + 4, right }\n    )\n  }\n\n  const handleMenuKeyDown = (event: React.KeyboardEvent) => {\n    const items = menuItems()\n    const at = items.indexOf(document.activeElement as HTMLElement)\n    const focusAt = (index: number) =>\n      items[(index + items.length) % items.length]?.focus()\n\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        focusAt(at + 1)\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        focusAt(at - 1)\n        break\n      case \"Home\":\n        event.preventDefault()\n        focusAt(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        focusAt(items.length - 1)\n        break\n      case \"Escape\":\n        event.preventDefault()\n        event.stopPropagation()\n        closeMenu(true)\n        break\n      case \"Tab\":\n        closeMenu(false)\n        break\n    }\n  }\n\n  const settling = drag === null || snapping\n  const transition =\n    settling && !reduced\n      ? \"transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.15), width 220ms cubic-bezier(0.2, 0.9, 0.3, 1.15)\"\n      : \"none\"\n  const revealed = Math.abs(offset)\n  const visibleSide = offset > 0 ? \"leading\" : offset < 0 ? \"trailing\" : null\n  const menuId = `${id}-menu`\n\n  const renderPanel = (side: Side) => {\n    const actions = actionsFor(side)\n    if (actions.length === 0) return null\n    const shown = visibleSide === side\n    const ordered = side === \"leading\" ? actions : [...actions].reverse()\n    const edgeIndex = side === \"leading\" ? 0 : ordered.length - 1\n    const takesOver = armed === side || committing?.side === side\n\n    return (\n      <div\n        aria-hidden={openSide !== side || undefined}\n        data-slot=\"swipe-actions-panel\"\n        data-side={side}\n        className={cn(\n          \"absolute inset-y-0 flex overflow-hidden\",\n          side === \"leading\" ? \"left-0\" : \"right-0\"\n        )}\n        style={{ width: shown ? revealed : 0, transition }}\n      >\n        {ordered.map((action, index) => {\n          const edge = index === edgeIndex\n          return (\n            <button\n              key={action.label}\n              type=\"button\"\n              tabIndex={openSide === side ? 0 : -1}\n              data-slot=\"swipe-actions-action\"\n              data-edge={edge ? \"\" : undefined}\n              className={cn(\n                \"focus-visible:ring-ring flex min-w-0 flex-col items-center justify-center gap-1 overflow-hidden px-2 text-xs font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset\",\n                \"transition-[flex-grow] duration-150 motion-reduce:transition-none\",\n                tones[action.tone ?? \"neutral\"],\n                side === \"leading\"\n                  ? edge\n                    ? \"items-start pl-5\"\n                    : \"\"\n                  : edge\n                    ? \"items-end pr-5\"\n                    : \"\"\n              )}\n              style={{ flexGrow: takesOver ? (edge ? 1 : 0) : 1, flexBasis: 0 }}\n              onClick={() => run(action)}\n            >\n              {action.icon ? (\n                <span aria-hidden=\"true\" className=\"[&_svg]:size-5\">\n                  {action.icon}\n                </span>\n              ) : null}\n              <span className=\"truncate\">{action.label}</span>\n            </button>\n          )\n        })}\n      </div>\n    )\n  }\n\n  return (\n    <li\n      ref={rowRef}\n      data-slot=\"swipe-actions-item\"\n      data-state={openSide ? \"open\" : committing ? \"committing\" : \"closed\"}\n      data-side={openSide ?? undefined}\n      className={cn(\"group/swipe relative\", className)}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\" && openSide) {\n          event.preventDefault()\n          setOpenSide(null)\n        }\n      }}\n      {...itemProps}\n    >\n      <div className=\"relative overflow-hidden\">\n        {renderPanel(\"leading\")}\n        {renderPanel(\"trailing\")}\n\n        <div\n          ref={contentRef}\n          data-slot=\"swipe-actions-content\"\n          className=\"bg-background relative flex touch-pan-y items-center select-none\"\n          style={{ transform: `translate3d(${offset}px, 0, 0)`, transition }}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={handlePointerEnd}\n          onPointerCancel={handlePointerEnd}\n          onClickCapture={handleClickCapture}\n          onDragStart={(event) => event.preventDefault()}\n        >\n          <div className=\"min-w-0 flex-1\">{children}</div>\n          {all.length > 0 ? (\n            <button\n              ref={moreRef}\n              type=\"button\"\n              aria-label={`${moreLabel} for ${label}`}\n              aria-haspopup=\"menu\"\n              aria-expanded={menuOpen}\n              aria-controls={menuOpen ? menuId : undefined}\n              data-slot=\"swipe-actions-more\"\n              className={cn(\n                \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring mr-2 inline-flex size-8 shrink-0 items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none\",\n                \"transition-opacity duration-150 motion-reduce:transition-none\",\n                menuOpen\n                  ? \"opacity-100\"\n                  : \"opacity-0 group-hover/swipe:opacity-100 focus-visible:opacity-100\"\n              )}\n              onClick={() => (menuOpen ? closeMenu(false) : openMenu())}\n              onKeyDown={(event) => {\n                if (event.key === \"ArrowDown\" && !menuOpen) {\n                  event.preventDefault()\n                  openMenu()\n                }\n              }}\n            >\n              <MoreHorizontal aria-hidden=\"true\" size={16} />\n            </button>\n          ) : null}\n        </div>\n      </div>\n\n      {menu\n        ? createPortal(\n            <div\n              ref={menuRef}\n              id={menuId}\n              role=\"menu\"\n              aria-label={`${moreLabel} for ${label}`}\n              data-slot=\"swipe-actions-menu\"\n              className=\"bg-popover text-popover-foreground border-border fixed z-50 grid min-w-40 gap-0.5 rounded-md border p-1 shadow-lg\"\n              style={menu}\n              onKeyDown={handleMenuKeyDown}\n            >\n              {all.map((action) => (\n                <button\n                  key={action.label}\n                  type=\"button\"\n                  role=\"menuitem\"\n                  tabIndex={-1}\n                  className={cn(\n                    \"hover:bg-muted focus-visible:bg-muted flex min-h-9 items-center gap-2 rounded-sm px-2 text-left text-sm focus-visible:outline-none [&_svg]:size-4\",\n                    action.tone === \"destructive\" && \"text-destructive\"\n                  )}\n                  onClick={() => {\n                    closeMenu(true)\n                    action.onSelect()\n                  }}\n                >\n                  {action.icon ? (\n                    <span aria-hidden=\"true\">{action.icon}</span>\n                  ) : null}\n                  {action.label}\n                </button>\n              ))}\n            </div>,\n            document.body\n          )\n        : null}\n    </li>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "list",
    "drag",
    "control",
    "motion"
  ],
  "type": "registry:ui"
}