{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-actions",
  "title": "Swipe Actions",
  "description": "A list row that slides sideways under a thumb to reveal what can be done to it — archive, delete, mark read, pin, snooze — with a button on the row that reveals exactly the same actions, because a swipe is not a control a keyboard or a screen reader has. Reach for it wherever a list is worked through rather than read: a mail or message inbox, a chat or conversation list, notifications and an activity feed, a task or to-do list, cart and wishlist rows, saved or favourite items, an orders or deliveries screen, a support or ticket queue, a moderation queue, and any mobile admin list where a row needs an action but not a whole screen of its own. Common asks it answers: \"swipe actions react\", \"swipe to delete react\", \"swipe to reveal actions\", \"swipeable list item react\", \"swipe left to delete list row\", \"ios mail swipe actions web\", \"react-swipeable-list alternative\", \"react-swipe-to-delete-component alternative\", \"swipeable-list-item shadcn\", \"shadcn swipe actions\", \"shadcn swipe to delete\", \"swipe actions without a library\", \"swipe gesture react no dependencies\", \"framer-motion drag row actions\", \"touch swipe row react\", \"detect horizontal swipe vs vertical scroll\", \"swipe breaks vertical scrolling\", \"list wont scroll after adding swipe\", \"Unable to preventDefault inside passive event listener invocation\", \"onTouchMove preventDefault not working in react\", \"touch-action pan-y\", \"swipe actions rtl\", \"swipe direction reversed in arabic\", \"accessible swipe actions\", \"swipe to delete keyboard accessible\", \"screen reader cannot swipe\", \"row click fires after swipe\", \"click fires when i swipe a list item\", \"snap open swipe row\", \"swipe row flick velocity\", \"close other rows when one is swiped\", \"スワイプ 削除 react\", \"リストを横に払って操作\". Official shadcn/ui has nothing here and nothing to build it out of. Fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 211,787 bytes of source, touchstart, touchmove, touchend, touchcancel, onTouchStart, onTouchMove, pointerdown, pointermove, pointerup, setPointerCapture, releasePointerCapture, onSwipe, threshold, TouchEvent, PointerEvent, clientX, deltaX and passive are every one of them a zero hit (measured 2026-09-30): it does not read a drag anywhere. The seven hits for the word swipe are all one Tailwind class string on toast, the Radix data-[swipe=…] variants that flick a transient notification away, and the single translateX is the progress bar's own fill. Distinct from the pulld pieces it sits beside, which each take a different half of the problem: sortable-list drags a row to move it in the order rather than to act on it, pull-to-refresh reads a vertical pull at the top of the same list, bulk-action-bar acts on rows after they have been ticked rather than one at a time, toast has a swipe of its own but it dismisses a notification outright and offers nothing to choose from, inline-edit changes a row's contents in place, and confirm-button and type-to-confirm guard the destructive action this reveals. Seven things separate it from the version built out of an onTouchMove and a clientX subtraction. First, the axis is decided once, after the finger has left an 8px slop circle, and a tie goes to vertical — the opposite of the same tie-break in a pull-to-refresh, and deliberately: this component is the row inside the list, so what it can wreck is the list's scrolling, and a list that stops moving under a thumb reads as broken while a swipe that needs a second try reads as a miss. Thumbs travel in arcs, so a decided axis is never re-decided. Second, touchmove is registered by hand with passive: false, because React registers onTouchMove passively and a passive listener's preventDefault is dropped with a console warning — the row slides sideways and the list scrolls behind it, and no prop changes that. touch-action: pan-y hands vertical scrolling back to the browser while keeping the horizontal direction, which is also what gives the preventDefault something to prevent: with the default auto the browser would have started scrolling and marked the sequence non-cancelable before the axis was decided. Third, the opening direction is derived from the resolved writing direction rather than from a hard-coded minus sign: the panel is positioned with inset-inline-end so the browser places it, and on an RTL page it sits on the left and the finger goes right to open it — a component that only knows how to subtract opens the row when it is swiped shut. That direction is read at event time, never during render, because getComputedStyle does not exist on the server and a transform decided in render would be missing from the server's HTML, which React discards the whole tree over. Fourth, the gestures that are not swipes are each turned away and each leaves the row where it already was, not shut: a second finger, a vertical drag, and a move the browser has already committed to scrolling. Returning an open row to shut is the bug where the row someone just opened vanishes as they scroll to reach its buttons. A drag that begins on an already-open row continues from where it was rather than jumping to zero under the finger. Fifth, a release snaps, and speed is read before distance: a short fast flick opens a row that never travelled half the panel, which is the gesture people actually make and the one a distance-only test refuses, and a fast flick back closes one that is still more than half open. A touchcancel — an incoming call, the system's own edge gesture — puts the row back instead of sharing the release handler and revealing actions nobody released onto. Sixth, the click a touch synthesises when it ends is swallowed in the capture phase after a swipe, so the row's own handler does not open the message that was being swiped. Seventh, the actions are reachable without the gesture: a disclosure button is always on the row carrying aria-expanded and aria-controls, the panel is a labelled group, and while it is shut its buttons are out of the tab order and out of the accessible tree — completely covered by the row, a focusable button is a focus ring vanishing into a list, which is worse than no keyboard support at all. Escape closes and hands focus back to that button, and so does running an action, so nobody loses their place. Deliberately reveal-only: a long swipe does not fire the destructive action by itself, since an accidental delete is the one mistake this shape of UI makes, and an auto-commit only touch users can reach is the same inequity the component exists to fix. The drag also stops exactly where the last action ends rather than rubber-banding past it, because there is nothing further back there to see. Controlled or not: pass actions and children and it manages itself, or add open and onOpenChange to hold \"which row is open\" in the list's own state and get one-at-a-time behaviour. Optional side (start/end), disabled, hideToggle for callers supplying their own control, actionWidth, openRatio, rowClassName and labels to reword or translate. One file of 747 lines behind 34 unit tests, shadcn tokens throughout so it follows light and dark themes and honours prefers-reduced-motion, and lucide-react is the only dependency — no gesture library, no Radix, no react-dom import.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/swipe-actions.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * How far the finger has to move before the gesture commits to an axis, in CSS pixels.\n *\n * Small enough that the row starts following the finger almost at once, large enough that the\n * first two or three samples of a flick — which are noisy, and often perfectly diagonal — do not\n * get to decide.\n */\nexport const AXIS_SLOP = 8\n\n/** Fraction of the panel that has to be showing for a release to snap open (default half). */\nexport const DEFAULT_OPEN_RATIO = 0.5\n\n/**\n * Speed at which a release counts as a flick rather than a drag, in CSS pixels per millisecond.\n *\n * A flick is the gesture people actually make once they know the row swipes: a short, fast throw\n * that never travels half the panel's width. Judged on distance alone it snaps shut, and the row\n * feels like it is refusing to open.\n */\nexport const FLICK_VELOCITY = 0.35\n\n/**\n * Width assumed per action when the panel has not been measured, in CSS pixels.\n *\n * Only ever used before layout — on the server, and on the very first client render — where\n * `offsetWidth` is 0 and a snap point of 0 would mean the row could not open at all.\n */\nexport const DEFAULT_ACTION_WIDTH = 72\n\n/** Which physical side of the row the panel sits on, once the writing direction is resolved. */\nexport function panelSide(side: \"start\" | \"end\", rtl: boolean): \"left\" | \"right\" {\n  // `side` is logical, so \"end\" is the right in an English list and the left in an Arabic one. The\n  // panel is positioned with `inset-inline-*`, which the browser resolves on its own; this exists\n  // because the row is moved with `translateX`, and there is no logical transform to move it with.\n  return (side === \"end\") !== rtl ? \"right\" : \"left\"\n}\n\n/**\n * The sign that turns physical finger travel into \"how far open\", for the configured side.\n *\n * Revealing a panel on the right means dragging left, so a negative `dx` is positive progress.\n * Getting this from a hard-coded `-dx` is the RTL bug: on an Arabic page the panel is on the left,\n * the finger goes right to open it, and a component that only knows how to subtract opens the row\n * when it is swiped shut and refuses when it is swiped open.\n */\nexport function openingSign(side: \"start\" | \"end\", rtl: boolean): 1 | -1 {\n  return panelSide(side, rtl) === \"right\" ? -1 : 1\n}\n\n/** Which way a gesture has turned out to be going. */\nexport type SwipeAxis = \"undecided\" | \"horizontal\" | \"vertical\"\n\n/**\n * Decides the axis once the finger has left the slop circle.\n *\n * Ties go to vertical — the opposite of the same decision in a pull-to-refresh, and for the same\n * reason read from the other side. Here the component *is* the row inside the list, so the thing it\n * can wreck is the list's scrolling: claim a drag that was really a scroll and the list stops moving\n * under the finger, which makes the page feel broken rather than merely unhelpful. Refusing a swipe\n * costs one more try. A drag that is exactly as sideways as it is downward is far more likely to be\n * a thumb starting a scroll — thumbs travel in arcs — than a deliberate sideways pull.\n */\nexport function decideAxis(dx: number, dy: number, slop: number = AXIS_SLOP): SwipeAxis {\n  const ax = Math.abs(dx)\n  const ay = Math.abs(dy)\n  if (ax < slop && ay < slop) return \"undecided\"\n  return ax > ay ? \"horizontal\" : \"vertical\"\n}\n\n/**\n * How far the row can travel: the measured panel width, or an estimate before layout exists.\n *\n * `measured` is 0 on the server and on the first client render, and a component that took that\n * literally would ship a row that cannot be opened until something else happens to re-render it.\n * With no actions the answer is 0 on purpose — there is nothing to reveal, so the gesture is dead\n * rather than dragging the row off to show bare background.\n */\nexport function revealWidth(\n  measured: number,\n  actionCount: number,\n  perAction: number = DEFAULT_ACTION_WIDTH\n): number {\n  if (measured > 0) return measured\n  return Math.max(0, actionCount) * Math.max(0, perAction)\n}\n\n/**\n * Clamps live finger travel to what there is to show.\n *\n * Deliberately a hard stop rather than the tapering overshoot a pull-to-refresh wants. The panel's\n * width *is* the whole of what exists behind the row: a drag past it would slide the row off the\n * top of nothing, exposing the background of the list, and a rubber band that reveals no further\n * information is motion for its own sake. Stopping exactly where the last action ends also tells\n * the person something true — that they have seen all of them.\n */\nexport function clampOffset(raw: number, reveal: number): number {\n  // Written as a pair of comparisons rather than `Math.min(Math.max(...))` so that a NaN — which is\n  // what a `clientX` read from a touch that has already ended gives — collapses to 0 instead of\n  // propagating into `translateX(NaNpx)`, which browsers drop silently along with the whole\n  // transform, leaving the row wherever it happened to be.\n  if (!(raw > 0)) return 0\n  if (raw > reveal) return reveal\n  return raw\n}\n\n/** What to do with the row when the finger lifts. */\nexport function shouldOpen(\n  offset: number,\n  velocity: number,\n  reveal: number,\n  {\n    openRatio = DEFAULT_OPEN_RATIO,\n    flickVelocity = FLICK_VELOCITY,\n  }: { openRatio?: number; flickVelocity?: number } = {}\n): boolean {\n  if (!(reveal > 0)) return false\n  // Speed is asked first, in both directions. A fast throw outward opens a row the finger never\n  // took half way, and a fast throw back closes one that is still more than half open — which is\n  // exactly what someone flicking a row shut is asking for, and what a distance-only test refuses.\n  if (velocity >= flickVelocity) return true\n  if (velocity <= -flickVelocity) return false\n  return offset >= reveal * openRatio\n}\n\n/** One touch sequence, from the finger landing to it lifting. */\nexport interface SwipeGesture {\n  originX: number\n  originY: number\n  /**\n   * How far open the row already was when the finger landed.\n   *\n   * Without it a second drag on a row that is already showing its actions starts from 0 and the row\n   * jumps shut under the finger before it begins to move.\n   */\n  baseOffset: number\n  /** Fixed for the whole gesture: re-reading the writing direction mid-drag could invert it. */\n  sign: 1 | -1\n  axis: SwipeAxis\n  /** Revealed pixels, unsigned, clamped to the panel width. */\n  offset: number\n  /** Pixels per millisecond, positive towards open. */\n  velocity: number\n  lastX: number\n  lastAt: number\n  /** Once true, nothing later in this same touch sequence can swipe. */\n  abandoned: boolean\n}\n\nexport function beginSwipe(\n  x: number,\n  y: number,\n  at: number,\n  baseOffset: number,\n  sign: 1 | -1\n): SwipeGesture {\n  return {\n    originX: x,\n    originY: y,\n    baseOffset,\n    sign,\n    axis: \"undecided\",\n    offset: baseOffset,\n    velocity: 0,\n    lastX: x,\n    lastAt: at,\n    abandoned: false,\n  }\n}\n\n/** What `advanceSwipe` needs from a `touchmove`. */\nexport interface SwipeMove {\n  x: number\n  y: number\n  /** `event.timeStamp` or `Date.now()`; only differences are used. */\n  at: number\n  /** `event.touches.length`. A second finger means a pinch, not a swipe. */\n  touchCount: number\n  /**\n   * `event.cancelable`. False once the browser has committed this sequence to scrolling, after\n   * which `preventDefault` does nothing but log a warning.\n   */\n  cancelable: boolean\n}\n\nexport interface SwipeMoveResult {\n  gesture: SwipeGesture\n  /** True when the caller has to call `preventDefault()` on this event. */\n  claim: boolean\n}\n\n/**\n * Gives the gesture up, leaving the row where it already was.\n *\n * Returning to `baseOffset` rather than to 0 is the point: a row whose actions are showing has to\n * stay showing while the person scrolls the list past it. Snapping it shut on the first vertical\n * drag is the bug where the row someone just opened disappears as they reach for its buttons.\n */\nconst abandon = (gesture: SwipeGesture): SwipeMoveResult => ({\n  gesture: { ...gesture, offset: gesture.baseOffset, abandoned: true },\n  claim: false,\n})\n\n/**\n * Folds one `touchmove` into the gesture, and says whether the event has to be claimed.\n *\n * Every `abandon` here is a gesture this component deliberately declines, and each one is a way the\n * short version misbehaves: a pinch-zoom that drags the row sideways, a scroll that opens every row\n * it passes under, and a drag the browser has already taken charge of.\n */\nexport function advanceSwipe(\n  gesture: SwipeGesture,\n  move: SwipeMove,\n  { reveal, slop = AXIS_SLOP }: { reveal: number; slop?: number }\n): SwipeMoveResult {\n  if (gesture.abandoned) return { gesture, claim: false }\n  if (move.touchCount > 1) return abandon(gesture)\n\n  const dx = move.x - gesture.originX\n  const dy = move.y - gesture.originY\n\n  let axis = gesture.axis\n  if (axis === \"undecided\") {\n    axis = decideAxis(dx, dy, slop)\n    // Still inside the slop: nothing decided, nothing claimed, and nothing abandoned — the same\n    // sequence gets to try again on the next move. Abandoning here would kill every swipe that\n    // starts slowly.\n    if (axis === \"undecided\") return { gesture, claim: false }\n    if (axis === \"vertical\") return abandon(gesture)\n  }\n\n  // The browser decides whether a touch sequence belongs to the page or to us, and once it has\n  // chosen the page this flag is how it says so. Carrying on would slide the row sideways on top of\n  // a scroll that cannot be stopped.\n  if (!move.cancelable) return abandon(gesture)\n\n  // Held over from the previous sample when two moves share a timestamp — which happens on every\n  // engine that coalesces them — because 0/0 would otherwise wipe out a flick's speed on its last\n  // sample, the one release reads.\n  const dt = move.at - gesture.lastAt\n  const velocity = dt > 0 ? (gesture.sign * (move.x - gesture.lastX)) / dt : gesture.velocity\n\n  return {\n    gesture: {\n      ...gesture,\n      axis,\n      offset: clampOffset(gesture.baseOffset + gesture.sign * dx, reveal),\n      velocity,\n      lastX: move.x,\n      lastAt: move.at,\n    },\n    claim: true,\n  }\n}\n\n/** One thing a row can have done to it. */\nexport interface SwipeAction {\n  /** Stable across renders; used as the React key. */\n  id: string\n  /** The visible text, and the accessible name when the action is icon-only. */\n  label: string\n  onSelect: () => void\n  /** Drawn before the label and hidden from assistive technology, since the label is right there. */\n  icon?: React.ReactNode\n  /** `destructive` uses the theme's destructive tokens. */\n  variant?: \"default\" | \"destructive\"\n  /** Keeps the panel open after this action runs, for one that does not remove the row. */\n  keepOpen?: boolean\n  /** Renders the label as an `sr-only` name, for an icon-only button. */\n  iconOnly?: boolean\n}\n\n/** Every string a user reads or hears. Override to translate or to reword. */\nexport interface SwipeActionsLabels {\n  /** Accessible name of the always-present disclosure button. */\n  toggle: string\n  /** Accessible name of the group the actions sit in. */\n  group: string\n}\n\nconst defaultLabels: SwipeActionsLabels = {\n  toggle: \"Row actions\",\n  group: \"Row actions\",\n}\n\nexport interface SwipeActionsProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"children\" | \"onTouchStart\" | \"onTouchEnd\" | \"onTouchCancel\"\n  > {\n  /** The actions revealed behind the row, in the order they appear. An empty list disables the gesture. */\n  actions: SwipeAction[]\n  /** The row itself — whatever the list shows when nothing is revealed. */\n  children: React.ReactNode\n  /**\n   * Which edge the actions come from, logically: `end` is the right of an English list and the left\n   * of an Arabic one (default `end`).\n   */\n  side?: \"start\" | \"end\"\n  /** Controlled state. Leave it out and the row manages its own. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Turns the gesture and the disclosure button off, leaving the row inert. */\n  disabled?: boolean\n  /**\n   * Drop the disclosure button.\n   *\n   * Only pass this alongside your own control wired to `open`/`onOpenChange`. The gesture is touch\n   * only: to a keyboard, a switch, a screen reader or a desktop mouse, actions that exist solely\n   * behind a swipe do not exist at all.\n   */\n  hideToggle?: boolean\n  /** Width assumed per action before the panel has been measured (default 72px). */\n  actionWidth?: number\n  /** Fraction of the panel that has to be showing for a release to open it (default 0.5). */\n  openRatio?: number\n  /** Classes for the moving row. Its background has to be opaque; the default supplies one. */\n  rowClassName?: string\n  labels?: Partial<SwipeActionsLabels>\n}\n\n/**\n * A list row that slides sideways to reveal its actions, with a button that reveals the same ones.\n *\n * The gesture is an accelerator, never the only way in — which is the half that is normally missing,\n * because a swipe looks finished on the phone it was built on.\n */\nexport const SwipeActions = React.forwardRef<HTMLDivElement, SwipeActionsProps>(\n  function SwipeActions(\n    {\n      actions,\n      children,\n      side = \"end\",\n      open: openProp,\n      onOpenChange,\n      disabled = false,\n      hideToggle = false,\n      actionWidth = DEFAULT_ACTION_WIDTH,\n      openRatio = DEFAULT_OPEN_RATIO,\n      rowClassName,\n      labels,\n      className,\n      style,\n      ...props\n    },\n    ref\n  ) {\n    const text = { ...defaultLabels, ...labels }\n    const panelId = React.useId()\n\n    const rootRef = React.useRef<HTMLDivElement | null>(null)\n    const panelRef = React.useRef<HTMLDivElement | null>(null)\n    const toggleRef = React.useRef<HTMLButtonElement | null>(null)\n\n    /**\n     * How far the row is translated, in physical pixels, signed.\n     *\n     * The single rendered number, and deliberately physical rather than a logical offset plus a\n     * direction read during render. `getComputedStyle` does not exist on the server, so resolving\n     * the writing direction while rendering would put a transform in the client's first tree that is\n     * absent from the server's HTML — and React discards the whole tree over that rather than\n     * reconciling it. The direction is read at event time instead, when there is certainly a node.\n     */\n    const [shift, setShift] = React.useState(0)\n    /** True only while a finger is down and swiping, which is when the transform must not animate. */\n    const [dragging, setDragging] = React.useState(false)\n\n    const gestureRef = React.useRef<SwipeGesture | null>(null)\n    /**\n     * Set when a gesture claimed the touch, and read by the capture-phase click handler.\n     *\n     * A touch that ends after moving still synthesises a click, so without this the row's own\n     * `onClick` — opening the message that was being swiped — fires on every swipe.\n     */\n    const swipedRef = React.useRef(false)\n\n    const openState = shift !== 0\n    const isControlled = openProp !== undefined\n\n    const currentSign = React.useCallback((): 1 | -1 => {\n      const node = rootRef.current\n      // No node and no `getComputedStyle` is the server, where nothing is being dragged; LTR is the\n      // right guess because it is also what the CSS `inset-inline-end` will have resolved to in the\n      // markup that was sent.\n      const rtl =\n        node && typeof getComputedStyle === \"function\"\n          ? getComputedStyle(node as unknown as Element)?.direction === \"rtl\"\n          : false\n      return openingSign(side, rtl)\n    }, [side])\n\n    const measureReveal = React.useCallback(() => {\n      const measured = panelRef.current?.offsetWidth\n      return revealWidth(typeof measured === \"number\" ? measured : 0, actions.length, actionWidth)\n    }, [actionWidth, actions.length])\n\n    /**\n     * Moves the row, and reports the change once — only when the row crosses between shut and open,\n     * not on every pixel of a drag.\n     */\n    /**\n     * The last open/shut state handed to `onOpenChange`.\n     *\n     * Kept separately from `shift` because the two do not change together: every pixel of a drag\n     * moves the row, and none of them is a change of state. Reporting straight off `shift` would\n     * announce a row as open the moment a finger nudged it three pixels, and announce it shut again\n     * when the same nudge snapped back — a controlled parent would see a pair of events for a\n     * gesture that did nothing. It is a ref rather than state because nothing renders from it, and\n     * because comparing inside a `setShift` updater would fire the callback twice in StrictMode,\n     * which runs updaters twice on purpose.\n     */\n    const reportedOpen = React.useRef(false)\n    const report = React.useCallback(\n      (open: boolean) => {\n        if (reportedOpen.current === open) return\n        reportedOpen.current = open\n        onOpenChange?.(open)\n      },\n      [onOpenChange]\n    )\n\n    /** Moves the row, and reports only a crossing between shut and open. */\n    const applyShift = React.useCallback(\n      (next: number) => {\n        setShift(next)\n        report(next !== 0)\n      },\n      [report]\n    )\n\n    const close = React.useCallback(() => applyShift(0), [applyShift])\n\n    const openPanel = React.useCallback(() => {\n      const reveal = measureReveal()\n      if (!(reveal > 0)) return\n      applyShift(reveal * currentSign())\n    }, [applyShift, currentSign, measureReveal])\n\n    /**\n     * Follows the controlled prop, in one direction only.\n     *\n     * A layout effect rather than a render-time comparison because it measures: reading\n     * `offsetWidth` during render is a layout flush in the middle of one, and the measurement it\n     * would take on the first pass is 0 anyway.\n     */\n    const lastOpenProp = React.useRef<boolean | undefined>(undefined)\n    React.useLayoutEffect(() => {\n      if (openProp === undefined) return\n      if (openProp === lastOpenProp.current) return\n      lastOpenProp.current = openProp\n      reportedOpen.current = openProp\n      if (openProp === openState) return\n      const reveal = measureReveal()\n      setShift(openProp && reveal > 0 ? reveal * currentSign() : 0)\n    }, [currentSign, measureReveal, openProp, openState])\n\n    const runAction = React.useCallback(\n      (action: SwipeAction) => {\n        if (!action.keepOpen) {\n          close()\n          // The actions are about to be hidden, and hiding the element that has focus drops the\n          // focus ring on the document body — which for a keyboard user means losing their place in\n          // a long list. The disclosure button is in the same row and is the way back in.\n          toggleRef.current?.focus?.()\n        }\n        action.onSelect()\n      },\n      [close]\n    )\n\n    const onTouchStart = React.useCallback(\n      (event: React.TouchEvent<HTMLDivElement>) => {\n        gestureRef.current = null\n        swipedRef.current = false\n        if (disabled || actions.length === 0) return\n        if (event.touches.length !== 1) return\n        const touch = event.touches[0]\n        gestureRef.current = beginSwipe(\n          touch.clientX,\n          touch.clientY,\n          event.timeStamp,\n          Math.abs(shift),\n          currentSign()\n        )\n      },\n      [actions.length, currentSign, disabled, shift]\n    )\n\n    const onTouchEnd = React.useCallback(() => {\n      const gesture = gestureRef.current\n      gestureRef.current = null\n      setDragging(false)\n      if (!gesture || gesture.abandoned) return\n      const reveal = measureReveal()\n      const open = shouldOpen(gesture.offset, gesture.velocity, reveal, { openRatio })\n      applyShift(open ? reveal * gesture.sign : 0)\n    }, [applyShift, measureReveal, openRatio])\n\n    /**\n     * The touch was taken away — an incoming call, the system's own edge gesture, a finger sliding\n     * off the digitiser. Deliberately *not* the same handler as `onTouchEnd`: the row is far enough\n     * across to snap open at that moment, and sharing one handler reveals actions the person never\n     * released onto.\n     *\n     * It puts the row back where the finger found it instead, which is the same rule `abandon`\n     * follows and for the same reason: a row that was already showing its actions has to survive\n     * the interruption, or an incoming call takes away the buttons the person was reaching for. So\n     * a row that started shut closes, a row that was open stays open, and neither is left half way.\n     */\n    const onTouchCancel = React.useCallback(() => {\n      const gesture = gestureRef.current\n      gestureRef.current = null\n      setDragging(false)\n      if (!gesture) return\n      applyShift(gesture.baseOffset * gesture.sign)\n    }, [applyShift])\n\n    /**\n     * `touchmove` is attached by hand, with `passive: false`.\n     *\n     * React registers its own `onTouchMove` passively, and `preventDefault` on a passive listener is\n     * dropped with a console warning — so the row would slide sideways *and* the list would scroll\n     * behind it. No prop changes that. `touchstart`, `touchend` and `touchcancel` stay React props\n     * precisely because none of them ever has to prevent anything.\n     *\n     * Registered once, against a handler kept in a ref and replaced each render, so a swipe does not\n     * re-subscribe the row sixty times a second.\n     */\n    const moveHandler = React.useRef<(event: TouchEvent) => void>(() => {})\n    React.useEffect(() => {\n      moveHandler.current = (event: TouchEvent) => {\n        const gesture = gestureRef.current\n        if (!gesture) return\n        const touch = event.touches[0]\n        const { gesture: next, claim } = advanceSwipe(\n          gesture,\n          {\n            x: touch?.clientX ?? gesture.lastX,\n            y: touch?.clientY ?? gesture.originY,\n            at: event.timeStamp,\n            touchCount: event.touches.length,\n            cancelable: event.cancelable,\n          },\n          { reveal: measureReveal() }\n        )\n        gestureRef.current = next\n        if (claim) {\n          event.preventDefault()\n          swipedRef.current = true\n        }\n        setDragging(claim)\n        setShift(next.offset * next.sign)\n      }\n    })\n    React.useEffect(() => {\n      const node = rootRef.current\n      if (!node?.addEventListener) return\n      const listener = (event: TouchEvent) => moveHandler.current(event)\n      node.addEventListener(\"touchmove\", listener, { passive: false })\n      return () => node.removeEventListener(\"touchmove\", listener)\n    }, [])\n\n    /**\n     * A pointer going down anywhere else closes the row.\n     *\n     * Without it the actions stay out until something happens to the row itself, and a list read\n     * with a thumb ends up with rows hanging open behind the one being used. `pointerdown` rather\n     * than `click` so the row is already shut by the time the tap lands on whatever it was aimed at.\n     */\n    React.useEffect(() => {\n      if (!openState) return\n      if (typeof document === \"undefined\" || !document.addEventListener) return\n      const listener = (event: Event) => {\n        const node = rootRef.current\n        if (node?.contains?.(event.target as Node)) return\n        close()\n      }\n      document.addEventListener(\"pointerdown\", listener, true)\n      return () => document.removeEventListener(\"pointerdown\", listener, true)\n    }, [close, openState])\n\n    const onKeyDown = React.useCallback(\n      (event: React.KeyboardEvent<HTMLDivElement>) => {\n        if (event.key !== \"Escape\" || !openState) return\n        // Stopped here so one Escape does not also close the dialog or drawer the list is inside.\n        event.stopPropagation()\n        close()\n        toggleRef.current?.focus?.()\n      },\n      [close, openState]\n    )\n\n    /**\n     * Hands the caller's ref the same node, instead of merging the two into one callback ref.\n     *\n     * This component needs an object ref of its own — `touchmove` is registered on this node by\n     * hand, and a callback ref leaves nothing to register it against — and a row in a long list is\n     * not the place to spend a second wrapper element on the difference. Cleared on unmount so a\n     * caller holding the ref is not left pointing at a detached node.\n     */\n    React.useLayoutEffect(() => {\n      if (typeof ref === \"function\") {\n        ref(rootRef.current)\n        return () => ref(null)\n      }\n      if (ref) {\n        const holder = ref\n        holder.current = rootRef.current\n        return () => {\n          holder.current = null\n        }\n      }\n    }, [ref])\n\n    const onClickCapture = React.useCallback(\n      (event: React.MouseEvent<HTMLDivElement>) => {\n        if (!swipedRef.current) return\n        swipedRef.current = false\n        // The click synthesised at the end of a swipe. Swallowed in the capture phase so it never\n        // reaches the row's own handler, whatever that is: the person was moving the row, not\n        // choosing it.\n        event.preventDefault()\n        event.stopPropagation()\n      },\n      []\n    )\n\n    const gestureEnabled = !disabled && actions.length > 0\n\n    return (\n      <div\n        ref={rootRef}\n        onTouchStart={gestureEnabled ? onTouchStart : undefined}\n        onTouchEnd={gestureEnabled ? onTouchEnd : undefined}\n        onTouchCancel={gestureEnabled ? onTouchCancel : undefined}\n        onKeyDown={onKeyDown}\n        onClickCapture={onClickCapture}\n        className={cn(\"relative overflow-hidden\", className)}\n        // `pan-y` hands vertical scrolling back to the browser while keeping the horizontal\n        // direction for this row, so an ancestor that scrolls sideways — a carousel, a wide table —\n        // does not slide under the gesture. It is also why the `preventDefault` above has something\n        // to prevent: with the default `auto` the browser would have started scrolling and marked\n        // the sequence non-cancelable before the axis was even decided.\n        style={{ touchAction: gestureEnabled ? \"pan-y\" : undefined, ...style }}\n        {...props}\n      >\n        {/*\n          Behind the row, on the logical side, and laid out by the browser rather than by a\n          direction read in JavaScript: `inset-inline-end` is the right in English and the left in\n          Arabic without this component having to know which.\n\n          `aria-hidden` and `tabIndex={-1}` while shut, because a button that is completely covered\n          by the row is one a keyboard can focus and nobody can see — a focus ring vanishing into a\n          list is worse than no keyboard support at all. The `inert` attribute would say this more\n          precisely and is deliberately not used: its typings landed in React 19, and a consumer on\n          18 would have a component that does not compile. `pointer-events-none` covers the rest of\n          what `inert` would have done.\n        */}\n        <div\n          ref={panelRef}\n          id={panelId}\n          role=\"group\"\n          aria-label={text.group}\n          aria-hidden={openState ? undefined : true}\n          className={cn(\n            \"absolute inset-y-0 flex items-stretch\",\n            !openState && \"pointer-events-none\"\n          )}\n          style={side === \"end\" ? { insetInlineEnd: 0 } : { insetInlineStart: 0 }}\n        >\n          {actions.map((action) => (\n            <button\n              key={action.id}\n              type=\"button\"\n              tabIndex={openState ? undefined : -1}\n              onClick={() => runAction(action)}\n              className={cn(\n                \"inline-flex min-w-[4.5rem] flex-col items-center justify-center gap-1 px-3\",\n                \"text-xs font-medium transition-colors\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                action.variant === \"destructive\"\n                  ? \"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n                  : \"bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground\"\n              )}\n            >\n              {action.icon ? (\n                <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n                  {action.icon}\n                </span>\n              ) : null}\n              <span className={cn(action.iconOnly && \"sr-only\")}>{action.label}</span>\n            </button>\n          ))}\n        </div>\n\n        <div\n          style={{ transform: shift !== 0 ? `translateX(${shift}px)` : undefined }}\n          className={cn(\n            // Opaque on purpose: the panel is directly underneath, and a transparent row shows the\n            // actions through itself while claiming to be shut.\n            \"relative flex items-center gap-2 bg-background\",\n            // Animated on release, never during the drag: a transition while the finger is moving\n            // makes the row trail behind it.\n            !dragging && \"transition-transform duration-200 motion-reduce:transition-none\",\n            rowClassName\n          )}\n        >\n          <div className=\"min-w-0 flex-1\">{children}</div>\n          {/*\n            Left out rather than hidden when `hideToggle` is set, because the `hidden` attribute\n            would not have hidden it: `[hidden]` is a user-agent rule and any `display` from a class\n            beats it, so the button would have stayed on screen while claiming to be gone.\n\n            `aria-disabled`, not `disabled`: a disabled button loses focus the moment it is pressed,\n            which drops the keyboard user out of the list they were working through.\n          */}\n          {hideToggle || actions.length === 0 ? null : (\n            <button\n              ref={toggleRef}\n              type=\"button\"\n              aria-label={text.toggle}\n              aria-expanded={openState}\n              aria-controls={panelId}\n              aria-disabled={disabled || undefined}\n              onClick={() => {\n                if (disabled) return\n                if (openState) close()\n                else openPanel()\n              }}\n              className={cn(\n                \"mr-1 inline-flex size-8 flex-none items-center justify-center rounded-md\",\n                \"text-muted-foreground transition-colors\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                disabled\n                  ? \"cursor-not-allowed opacity-50\"\n                  : \"hover:bg-accent hover:text-accent-foreground\"\n              )}\n            >\n              <MoreHorizontal className=\"size-4\" aria-hidden=\"true\" />\n            </button>\n          )}\n        </div>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
