{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-actions",
  "type": "registry:ui",
  "title": "Swipe Actions",
  "description": "List rows that reveal actions when swiped, with full-swipe triggers and an accessible Actions menu on every row.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/swipe-actions.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useTransform } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\ntype Side = \"leading\" | \"trailing\";\n\nexport interface SwipeAction {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  /** Background of the revealed action. */\n  color?: string;\n  /** Text / icon colour on that background. */\n  textColor?: string;\n  onSelect?: () => void;\n  /** Slide the row out before calling onSelect (archive, delete). Otherwise the row springs back. */\n  dismiss?: boolean;\n  /** Ask first, e.g. \"Delete this message?\". Applies to swipes, taps and the menu. */\n  confirm?: string;\n  /** Label of the confirm button. Defaults to the action label. */\n  confirmLabel?: string;\n  /** Styled as destructive in the menu and confirmation. */\n  destructive?: boolean;\n  /** Announced after the action runs. Defaults to the label. */\n  announcement?: string;\n}\n\nexport interface SwipeActionsProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  children: React.ReactNode;\n  /** Revealed by swiping right. The first (outermost) one is the full-swipe action. */\n  leadingActions?: SwipeAction[];\n  /** Revealed by swiping left. The last (outermost) one is the full-swipe action. */\n  trailingActions?: SwipeAction[];\n  /** Short name of the row, used for the menu button: \"Actions for Ana Ruiz\". */\n  label?: string;\n  /** Swiping past the threshold runs the outermost action on release. */\n  fullSwipe?: boolean;\n  /** Full-swipe threshold as a fraction of the row width. */\n  fullSwipeThreshold?: number;\n  /** Width of each revealed action in px. */\n  actionWidth?: number;\n  /** Show labels under the action icons. */\n  showLabels?: boolean;\n  /** Spring for snapping open, closed and back. */\n  spring?: Spring;\n  /** Focus ring and menu highlight colour. */\n  accent?: string;\n  /** Classes for the sliding surface (it needs an opaque background). */\n  contentClassName?: string;\n  disabled?: boolean;\n}\n\nexport interface SwipeActionsGroupProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Accessible name of the list. */\n  label?: string;\n}\n\ninterface GroupContextValue {\n  openId: string | null;\n  setOpenId: (id: string | null) => void;\n  announce: (message: string) => void;\n}\n\nconst GroupContext = React.createContext<GroupContextValue | null>(null);\n\nconst EDGE = 8;\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/** Rubber band: distance past a limit is compressed, approaching `dimension`. */\nfunction rubber(overshoot: number, dimension: number) {\n  return (1 - 1 / ((overshoot * 0.55) / dimension + 1)) * dimension;\n}\n\nfunction menuItems(menu: HTMLElement) {\n  return Array.from(menu.querySelectorAll<HTMLElement>(\"[role=menuitem]\"));\n}\n\n/**\n * Wraps SwipeActions rows: renders a list, keeps only one row open at a time and owns the live\n * region, so announcements survive rows that are removed by their action.\n */\nexport function SwipeActionsGroup({ label = \"List\", children, className = \"\", ...props }: SwipeActionsGroupProps) {\n  const [openId, setOpenId] = React.useState<string | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n  const announce = React.useCallback((next: string) => {\n    // Re-announce identical messages by toggling an invisible suffix.\n    setMessage((previous) => (previous === next ? `${next}\\u00a0` : next));\n  }, []);\n  const value = React.useMemo(() => ({ openId, setOpenId, announce }), [openId, announce]);\n\n  return (\n    <GroupContext.Provider value={value}>\n      <div {...props} role=\"list\" aria-label={label} className={className}>\n        {children}\n      </div>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {message}\n      </span>\n    </GroupContext.Provider>\n  );\n}\n\n/**\n * A list row that reveals actions when swiped (the mobile mail pattern): leading actions on a right\n * swipe, trailing on a left swipe, a full swipe runs the outermost action, and it springs back.\n * Every action is also reachable without swiping through the row's \"Actions\" menu button.\n */\nexport function SwipeActions({\n  children,\n  leadingActions = [],\n  trailingActions = [],\n  label,\n  fullSwipe = true,\n  fullSwipeThreshold = 0.5,\n  actionWidth = 76,\n  showLabels = true,\n  spring = { stiffness: 420, damping: 34, mass: 0.9 },\n  accent = \"#FF7448\",\n  contentClassName = \"bg-white dark:bg-[#17191c]\",\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: SwipeActionsProps) {\n  const group = React.useContext(GroupContext);\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const rowRef = React.useRef<HTMLDivElement>(null);\n  const menuButtonRef = React.useRef<HTMLButtonElement>(null);\n  const menuRef = React.useRef<HTMLDivElement | null>(null);\n  const cancelRef = React.useRef<HTMLButtonElement>(null);\n  // Set when focus should return to the menu button once the (inert-making) confirmation is gone.\n  const refocusMenu = React.useRef(false);\n\n  const x = useMotionValue(0);\n  const leadingWidth = useTransform(x, (v) => Math.max(0, v));\n  const trailingWidth = useTransform(x, (v) => Math.max(0, -v));\n\n  const [openSide, setOpenSide] = React.useState<Side | null>(null);\n  const [armed, setArmed] = React.useState<Side | null>(null);\n  const [menuOpen, setMenuOpen] = React.useState(false);\n  const [confirming, setConfirming] = React.useState<SwipeAction | null>(null);\n  const [localMessage, setLocalMessage] = React.useState(\"\");\n\n  const drag = React.useRef({\n    active: false,\n    decided: false,\n    pointerId: -1,\n    startX: 0,\n    startY: 0,\n    origin: 0,\n    width: 0,\n    armed: null as Side | null,\n    samples: [] as { x: number; t: number }[],\n    suppressClick: false,\n  });\n  const mounted = React.useRef(true);\n  // True while a dismissing action slides the row out (nothing may pull it back meanwhile).\n  const dismissing = React.useRef(false);\n  React.useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  // In a group, this row only counts as open while it is the group's open row.\n  const effectiveSide = group && group.openId !== uid ? null : openSide;\n  const openLeading = leadingActions.length * actionWidth;\n  const openTrailing = trailingActions.length * actionWidth;\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  const snapTo = React.useCallback(\n    (target: number) => {\n      animate(x, target, transition);\n    },\n    // transition is rebuilt each render from spring + reduceMotion\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [x, reduceMotion, spring.stiffness, spring.damping, spring.mass],\n  );\n\n  const setOpen = (side: Side | null) => {\n    setOpenSide(side);\n    if (group) {\n      if (side) group.setOpenId(uid);\n      else if (group.openId === uid) group.setOpenId(null);\n    }\n  };\n\n  const close = () => {\n    snapTo(0);\n    setOpen(null);\n  };\n\n  // Another row in the group opened: slide this one closed.\n  const groupOpenId = group?.openId;\n  React.useEffect(() => {\n    if (group && groupOpenId !== uid && x.get() !== 0 && !drag.current.active && !dismissing.current) animate(x, 0, transition);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [groupOpenId]);\n\n  const announce = (message: string) => {\n    if (group) group.announce(message);\n    else setLocalMessage((previous) => (previous === message ? `${message}\\u00a0` : message));\n  };\n\n  /** Runs an action, sliding the row out first when it dismisses. */\n  const run = (action: SwipeAction, side: Side) => {\n    if (confirming && !action.dismiss) refocusMenu.current = true;\n    setConfirming(null);\n    setOpen(null);\n    announce(action.announcement ?? action.label);\n    if (action.dismiss) {\n      // Keep keyboard users in the list: move focus to a neighbouring row's menu button.\n      const row = rowRef.current;\n      if (row && row.contains(document.activeElement)) {\n        const buttons = Array.from((row.closest(\"[role=list]\") ?? document).querySelectorAll<HTMLElement>(\"[data-swipe-menu]\"));\n        const index = buttons.indexOf(menuButtonRef.current as HTMLElement);\n        (buttons[index + 1] ?? buttons[index - 1])?.focus({ preventScroll: true });\n      }\n      const width = rowRef.current?.offsetWidth ?? 400;\n      const out = side === \"leading\" ? width : -width;\n      if (reduceMotion) {\n        action.onSelect?.();\n        return;\n      }\n      dismissing.current = true;\n      animate(x, out, { duration: 0.22, ease: [0.4, 0, 1, 1] }).then(() => {\n        action.onSelect?.();\n        // If the row was not removed, bring it back.\n        window.setTimeout(() => {\n          dismissing.current = false;\n          if (mounted.current) snapTo(0);\n        }, 500);\n      });\n    } else {\n      snapTo(0);\n      action.onSelect?.();\n    }\n  };\n\n  const trigger = (action: SwipeAction, side: Side) => {\n    if (disabled) return;\n    if (action.confirm) {\n      snapTo(0);\n      setOpen(null);\n      setConfirming(action);\n      return;\n    }\n    run(action, side);\n  };\n\n  // Focus the safe choice when a confirmation appears; Escape cancels it (only while it is shown).\n  React.useEffect(() => {\n    if (!confirming) {\n      if (refocusMenu.current) menuButtonRef.current?.focus({ preventScroll: true });\n      refocusMenu.current = false;\n      return;\n    }\n    cancelRef.current?.focus({ preventScroll: true });\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.isComposing) return;\n      event.preventDefault();\n      event.stopPropagation();\n      refocusMenu.current = true;\n      setConfirming(null);\n    };\n    document.addEventListener(\"keydown\", onKey, true);\n    return () => document.removeEventListener(\"keydown\", onKey, true);\n  }, [confirming]);\n\n  // While swiped open: Escape (with focus in the row or nowhere) or a press outside closes it.\n  React.useEffect(() => {\n    if (!effectiveSide) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.isComposing) return;\n      const active = document.activeElement;\n      if (active && active !== document.body && !rowRef.current?.contains(active)) return;\n      event.preventDefault();\n      event.stopPropagation();\n      animate(x, 0, transition);\n      setOpenSide(null);\n      group?.setOpenId(null);\n    };\n    const onPointer = (event: PointerEvent) => {\n      if (rowRef.current?.contains(event.target as Node)) return;\n      animate(x, 0, transition);\n      setOpenSide(null);\n    };\n    document.addEventListener(\"keydown\", onKey, true);\n    document.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey, true);\n      document.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [effectiveSide]);\n\n  /* --------------------------------- drag --------------------------------- */\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    // A swipe may start anywhere on the row, including the menu button (a drag never clicks it).\n    if (disabled || confirming || event.button !== 0) return;\n    const d = drag.current;\n    d.active = false;\n    d.decided = false;\n    d.pointerId = event.pointerId;\n    d.startX = event.clientX;\n    d.startY = event.clientY;\n    d.origin = x.get();\n    d.width = rowRef.current?.offsetWidth ?? 360;\n    d.armed = null;\n    d.samples = [{ x: event.clientX, t: event.timeStamp }];\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (d.pointerId !== event.pointerId) return;\n    const dx = event.clientX - d.startX;\n    const dy = event.clientY - d.startY;\n    if (!d.decided) {\n      if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;\n      d.decided = true;\n      // Vertical intent: let the page scroll and forget this gesture.\n      if (Math.abs(dy) > Math.abs(dx)) {\n        d.pointerId = -1;\n        return;\n      }\n      d.active = true;\n      x.stop();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      if (group && group.openId !== uid) group.setOpenId(uid);\n    }\n    if (!d.active) return;\n\n    const raw = d.origin + dx;\n    let value = raw;\n    if (raw > 0) {\n      const limit = !leadingActions.length ? 0 : fullSwipe ? d.width : openLeading;\n      if (raw > limit) value = limit + rubber(raw - limit, limit ? 80 : 48);\n    } else {\n      const limit = !trailingActions.length ? 0 : fullSwipe ? d.width : openTrailing;\n      if (-raw > limit) value = -(limit + rubber(-raw - limit, limit ? 80 : 48));\n    }\n    x.set(value);\n\n    d.samples.push({ x: event.clientX, t: event.timeStamp });\n    if (d.samples.length > 5) d.samples.shift();\n\n    // Arm the full-swipe action past the threshold; only re-render when that flips.\n    let nextArmed: Side | null = null;\n    if (fullSwipe) {\n      const threshold = (side: Side) => Math.max(d.width * fullSwipeThreshold, (side === \"leading\" ? openLeading : openTrailing) + 24);\n      if (value > 0 && leadingActions.length && value > threshold(\"leading\")) nextArmed = \"leading\";\n      if (value < 0 && trailingActions.length && -value > threshold(\"trailing\")) nextArmed = \"trailing\";\n    }\n    if (nextArmed !== d.armed) {\n      d.armed = nextArmed;\n      setArmed(nextArmed);\n    }\n  };\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (d.pointerId !== event.pointerId) return;\n    d.pointerId = -1;\n    if (!d.active) return;\n    d.active = false;\n    d.suppressClick = true;\n\n    const first = d.samples[0];\n    const last = d.samples[d.samples.length - 1];\n    const velocity = last && first && last.t > first.t ? (last.x - first.x) / (last.t - first.t) : 0; // px / ms\n    const value = x.get();\n    const fired = d.armed;\n    d.armed = null;\n    setArmed(null);\n\n    if (fired === \"leading\" && leadingActions[0]) return trigger(leadingActions[0], \"leading\");\n    if (fired === \"trailing\" && trailingActions.length) return trigger(trailingActions[trailingActions.length - 1], \"trailing\");\n\n    if (value > 0 && leadingActions.length && (value > openLeading / 2 || velocity > 0.45) && velocity > -0.45) {\n      snapTo(openLeading);\n      setOpen(\"leading\");\n    } else if (value < 0 && trailingActions.length && (-value > openTrailing / 2 || velocity < -0.45) && velocity < 0.45) {\n      snapTo(-openTrailing);\n      setOpen(\"trailing\");\n    } else {\n      close();\n    }\n  };\n\n  // Swallow the click that ends a drag, and use a tap on an open row to close it.\n  const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (d.suppressClick) {\n      d.suppressClick = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (effectiveSide && !(event.target as HTMLElement).closest(\"[data-swipe-menu]\")) {\n      event.preventDefault();\n      event.stopPropagation();\n      close();\n    }\n  };\n\n  /* --------------------------------- menu --------------------------------- */\n\n  const allActions: { action: SwipeAction; side: Side }[] = [\n    ...leadingActions.map((action) => ({ action, side: \"leading\" as const })),\n    ...trailingActions.map((action) => ({ action, side: \"trailing\" as const })),\n  ];\n\n  const positionMenu = React.useCallback(() => {\n    const menu = menuRef.current;\n    const anchor = menuButtonRef.current;\n    if (!menu || !anchor) return;\n    const r = anchor.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = menu.offsetWidth;\n    const h = menu.offsetHeight;\n    const above = r.bottom + 6 + h > vh - EDGE && r.top - 6 - h > EDGE;\n    const left = Math.max(EDGE, Math.min(r.right - w, vw - w - EDGE));\n    const top = above ? r.top - 6 - h : Math.min(r.bottom + 6, vh - h - EDGE);\n    menu.style.left = `${Math.round(left)}px`;\n    menu.style.top = `${Math.round(top)}px`;\n    menu.style.transformOrigin = `${Math.round(r.right - left - r.width / 2)}px ${above ? \"100%\" : \"0%\"}`;\n  }, []);\n\n  const closeMenu = React.useCallback((returnFocus: boolean) => {\n    setMenuOpen(false);\n    if (returnFocus) menuButtonRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  const mountMenu = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      if (!node) return;\n      menuRef.current = node;\n      if (typeof node.showPopover === \"function\" && !node.matches(\":popover-open\")) {\n        try {\n          node.showPopover();\n        } catch {\n          // No popover support: position: fixed still applies.\n        }\n      }\n    },\n    [],\n  );\n\n  // Position and focus on every open — also when reopening during the exit animation, where\n  // AnimatePresence reuses the exiting node and the callback ref doesn't run again.\n  React.useEffect(() => {\n    const menu = menuRef.current;\n    if (!menuOpen || !menu) return;\n    positionMenu();\n    menuItems(menu)[0]?.focus({ preventScroll: true });\n  }, [menuOpen, positionMenu]);\n\n  React.useEffect(() => {\n    if (!menuOpen) return;\n    let frame = 0;\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target) || menuButtonRef.current?.contains(target)) return;\n      closeMenu(false);\n    };\n    const onMove = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(positionMenu);\n    };\n    document.addEventListener(\"pointerdown\", onPointer, true);\n    window.addEventListener(\"scroll\", onMove, { capture: true, passive: true });\n    window.addEventListener(\"resize\", onMove);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"pointerdown\", onPointer, true);\n      window.removeEventListener(\"scroll\", onMove, { capture: true });\n      window.removeEventListener(\"resize\", onMove);\n    };\n  }, [menuOpen, closeMenu, positionMenu]);\n\n  const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const items = menuItems(event.currentTarget);\n    const index = items.indexOf(document.activeElement as HTMLElement);\n    const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus({ preventScroll: true });\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusAt(index + 1);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusAt(index < 0 ? -1 : index - 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusAt(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusAt(-1);\n        break;\n      case \"Escape\":\n        // Close only the menu, not a surrounding dialog.\n        event.preventDefault();\n        event.stopPropagation();\n        closeMenu(true);\n        break;\n      case \"Tab\":\n        event.preventDefault();\n        closeMenu(true);\n        break;\n      case \"Enter\":\n      case \" \":\n        if (index >= 0) {\n          event.preventDefault();\n          items[index].click();\n        }\n        break;\n    }\n  };\n\n  const chooseFromMenu = (action: SwipeAction, side: Side) => {\n    closeMenu(true);\n    trigger(action, side);\n  };\n\n  /* -------------------------------- render -------------------------------- */\n\n  const renderActions = (actions: SwipeAction[], side: Side) => {\n    const primaryIndex = side === \"leading\" ? 0 : actions.length - 1;\n    const isArmed = armed === side;\n    return actions.map((action, i) => {\n      const primary = i === primaryIndex;\n      const collapsed = isArmed && !primary;\n      return (\n        <button\n          key={action.id}\n          type=\"button\"\n          tabIndex={effectiveSide === side ? 0 : -1}\n          onClick={() => trigger(action, side)}\n          className={`flex min-w-0 basis-0 items-center overflow-hidden outline-hidden transition-[flex-grow,padding] duration-200 ease-out focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-solid focus-visible:outline-white ${\n            primary && isArmed ? (side === \"leading\" ? \"justify-end pr-6\" : \"justify-start pl-6\") : \"justify-center\"\n          }`}\n          style={{ flexGrow: collapsed ? 0.0001 : 1, background: action.color ?? \"#71717A\", color: action.textColor ?? \"#FFFFFF\" }}\n        >\n          <span className=\"flex w-[var(--sa-w)] shrink-0 flex-col items-center justify-center gap-1\" style={{ [\"--sa-w\" as string]: `${actionWidth}px` }}>\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"grid size-5 place-items-center [&_svg]:size-5\"\n              animate={{ scale: primary && isArmed && !reduceMotion ? 1.22 : 1 }}\n              transition={{ type: \"spring\", stiffness: 700, damping: 12 }}\n            >\n              {action.icon}\n            </motion.span>\n            <span className={showLabels ? \"max-w-full truncate px-1 text-[11px] font-medium tracking-tight\" : \"sr-only\"}>{action.label}</span>\n          </span>\n        </button>\n      );\n    });\n  };\n\n  return (\n    <div\n      {...props}\n      ref={rowRef}\n      role={group ? \"listitem\" : undefined}\n      className={`relative overflow-hidden ${className}`}\n      style={{ [\"--sa-accent\" as string]: accent, ...style }}\n    >\n      {/* Revealed actions. Inert unless their side is open: the menu is the accessible path. */}\n      {leadingActions.length ? (\n        <motion.div className=\"absolute inset-y-0 left-0 flex\" style={{ width: leadingWidth }} inert={effectiveSide !== \"leading\"}>\n          {renderActions(leadingActions, \"leading\")}\n        </motion.div>\n      ) : null}\n      {trailingActions.length ? (\n        <motion.div className=\"absolute inset-y-0 right-0 flex\" style={{ width: trailingWidth }} inert={effectiveSide !== \"trailing\"}>\n          {renderActions(trailingActions, \"trailing\")}\n        </motion.div>\n      ) : null}\n\n      <motion.div\n        className={`relative flex touch-pan-y select-none items-center ${contentClassName}`}\n        style={{ x }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onClickCapture={onClickCapture}\n        inert={!!confirming}\n      >\n        <div className=\"min-w-0 flex-1\">{children}</div>\n        {allActions.length ? (\n          <button\n            ref={menuButtonRef}\n            type=\"button\"\n            data-swipe-menu=\"\"\n            aria-label={label ? `Actions for ${label}` : \"Actions\"}\n            aria-haspopup=\"menu\"\n            aria-expanded={menuOpen}\n            aria-controls={menuOpen ? `${uid}-menu` : undefined}\n            disabled={disabled}\n            onClick={() => (menuOpen ? closeMenu(true) : setMenuOpen(true))}\n            onKeyDown={(event) => {\n              if (event.key === \"ArrowDown\" && !menuOpen) {\n                event.preventDefault();\n                setMenuOpen(true);\n              }\n            }}\n            className=\"mr-2 grid size-8 shrink-0 place-items-center rounded-full text-zinc-400 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)] aria-expanded:bg-zinc-900/[0.06] aria-expanded:text-zinc-900 disabled:opacity-40 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 dark:aria-expanded:bg-white/[0.08] dark:aria-expanded:text-zinc-100\"\n          >\n            <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-4\" fill=\"currentColor\">\n              <circle cx=\"3.5\" cy=\"8\" r=\"1.25\" />\n              <circle cx=\"8\" cy=\"8\" r=\"1.25\" />\n              <circle cx=\"12.5\" cy=\"8\" r=\"1.25\" />\n            </svg>\n          </button>\n        ) : null}\n      </motion.div>\n\n      {/* Inline confirmation for destructive actions. */}\n      <AnimatePresence>\n        {confirming ? (\n          <motion.div\n            key=\"confirm\"\n            role=\"group\"\n            aria-labelledby={`${uid}-confirm`}\n            className=\"absolute inset-0 flex items-center justify-between gap-3 bg-white px-4 dark:bg-[#1c1e22]\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: reduceMotion ? 0 : 0.16 }}\n          >\n            <span aria-hidden=\"true\" className=\"absolute inset-y-0 left-0 w-[3px]\" style={{ background: confirming.color ?? \"#E5484D\" }} />\n            <p id={`${uid}-confirm`} className=\"min-w-0 truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-50\">\n              {confirming.confirm}\n            </p>\n            <div className=\"flex shrink-0 items-center gap-1.5\">\n              <button\n                ref={cancelRef}\n                type=\"button\"\n                onClick={() => {\n                  refocusMenu.current = true;\n                  setConfirming(null);\n                }}\n                className=\"h-8 rounded-full px-3 text-[13px] font-medium tracking-tight text-zinc-600 outline-hidden hover:bg-zinc-900/[0.06] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)] dark:text-zinc-300 dark:hover:bg-white/[0.08]\"\n              >\n                Cancel\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  const side: Side = trailingActions.includes(confirming) ? \"trailing\" : \"leading\";\n                  run(confirming, side);\n                }}\n                className=\"h-8 rounded-full px-3 text-[13px] font-medium tracking-tight text-white outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sa-accent)]\"\n                style={{ background: confirming.color ?? \"#E5484D\" }}\n              >\n                {confirming.confirmLabel ?? confirming.label}\n              </button>\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      <AnimatePresence>\n        {menuOpen ? (\n          <motion.div\n            key=\"menu\"\n            ref={mountMenu}\n            id={`${uid}-menu`}\n            role=\"menu\"\n            aria-label={label ? `Actions for ${label}` : \"Actions\"}\n            tabIndex={-1}\n            popover=\"manual\"\n            onKeyDown={onMenuKeyDown}\n            className=\"fixed inset-auto m-0 min-w-44 overflow-hidden rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]\"\n            style={{ [\"--sa-accent\" as string]: accent }}\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n            transition={reduceMotion ? { duration: 0.08 } : { type: \"spring\", stiffness: 600, damping: 36 }}\n          >\n            {allActions.map(({ action, side }) => (\n              <div\n                key={`${side}-${action.id}`}\n                role=\"menuitem\"\n                tabIndex={-1}\n                onClick={() => chooseFromMenu(action, side)}\n                onPointerMove={(event) => {\n                  if (event.pointerType === \"mouse\" && document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n                }}\n                className={`flex cursor-default items-center gap-2.5 rounded-lg px-2.5 py-2 outline-hidden transition-colors duration-75 focus:bg-[color-mix(in_oklab,var(--sa-accent)_14%,transparent)] ${\n                  action.destructive ? \"text-[#D93D42] dark:text-[#FF8A8E]\" : \"focus:text-zinc-950 dark:focus:text-white\"\n                }`}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"grid size-5 shrink-0 place-items-center rounded-md [&_svg]:size-3.5\"\n                  style={{ background: action.color ?? \"#71717A\", color: action.textColor ?? \"#FFFFFF\" }}\n                >\n                  {action.icon}\n                </span>\n                <span className=\"font-medium tracking-tight\">{action.label}</span>\n                {action.confirm ? <span className=\"ml-auto pl-3 font-mono text-[10px] text-zinc-400 dark:text-zinc-500\">…</span> : null}\n              </div>\n            ))}\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n\n      {!group ? (\n        <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n          {localMessage}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/swipe-actions"
}
