{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-actions",
  "type": "registry:ui",
  "title": "Swipe Actions",
  "description": "Swipe a row to reveal actions; swipe far and the tray fills to commit.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/swipe-actions.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\nimport { useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SwipeSide = \"start\" | \"end\";\nexport type SwipeTone = \"neutral\" | \"danger\" | \"success\" | \"warning\" | \"info\";\n\nexport type SwipeAction = {\n  id: string;\n  /** A verb: \"Archive\", \"Delete\", \"Mark read\". Also the tooltip and accessible name. */\n  label: string;\n  icon: React.ReactNode;\n  tone?: SwipeTone;\n  /** The row leaves when this runs: it slides out and folds shut before `onAction` fires. */\n  removes?: boolean;\n  onAction: () => void;\n};\n\nexport type SwipeActionsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The row itself. Keep its own click target inside; drags never reach it as a click. */\n  children: React.ReactNode;\n  /** Revealed by swiping toward the end edge (right in LTR). The first is the outermost and the full-swipe action. */\n  startActions?: SwipeAction[];\n  /** Revealed by swiping toward the start edge (left in LTR). The first is the outermost and the full-swipe action. */\n  endActions?: SwipeAction[];\n  /** Swiping far enough runs the outermost action without a second tap. `true` for both sides. */\n  fullSwipe?: boolean | SwipeSide;\n  /** Which tray is open. `null` when the row rests closed. */\n  open?: SwipeSide | null;\n  defaultOpen?: SwipeSide | null;\n  onOpenChange?: (open: SwipeSide | null) => void;\n  /** The icon buttons that appear on hover and focus, for mouse and keyboard users. */\n  toolbar?: boolean;\n  /** Where the hover toolbar sits: centered on the row, or over its first line (where a timestamp usually is). */\n  toolbarAlign?: \"center\" | \"top\";\n  /** Width of each revealed action in px. */\n  actionWidth?: number;\n  disabled?: boolean;\n  /** Classes for the sliding layer. It inherits the row's background so the trays stay hidden behind it. */\n  contentClassName?: string;\n};\n\ntype Phase = \"idle\" | \"dragging\" | \"committing\";\n\n// Tray colors are soft while you're still deciding and turn solid once a full swipe will commit.\nconst tones: Record<SwipeTone, { soft: string; solid: string; hover: string }> = {\n  neutral: { soft: \"bg-fg/[0.07] text-fg\", solid: \"bg-fg text-frame\", hover: \"hover:bg-fg/[0.11]\" },\n  danger: { soft: \"bg-danger-soft text-danger\", solid: \"bg-danger text-frame\", hover: \"hover:bg-danger/20\" },\n  success: { soft: \"bg-success-soft text-success\", solid: \"bg-success text-frame\", hover: \"hover:bg-success/20\" },\n  warning: { soft: \"bg-warning-soft text-warning\", solid: \"bg-warning text-frame\", hover: \"hover:bg-warning/20\" },\n  info: { soft: \"bg-info-soft text-info\", solid: \"bg-info text-frame\", hover: \"hover:bg-info/20\" },\n};\n\n/** The resistance of a scroll view pulled past its end: fast at first, never past `dim`. */\nconst rubber = (over: number, dim: number) => (over * dim * 0.55) / (dim + over * 0.55);\nconst clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));\nconst OPEN_EVENT = \"swipe-actions:engage\";\nconst settleSpring = { type: \"spring\", stiffness: 420, damping: 40, mass: 0.8 } as const;\n\nexport function SwipeActions({\n  children,\n  startActions = [],\n  endActions = [],\n  fullSwipe = true,\n  open: openProp,\n  defaultOpen = null,\n  onOpenChange,\n  toolbar = true,\n  actionWidth = 72,\n  disabled = false,\n  className,\n  contentClassName,\n  toolbarAlign = \"center\",\n  onKeyDown,\n  ...rest\n}: SwipeActionsProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useControllableState<SwipeSide | null>({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [armed, setArmedState] = useState<SwipeSide | null>(null);\n  // Which action on the armed side takes over the tray: the outermost for a full swipe, the pressed one for a tap.\n  const [expand, setExpand] = useState(0);\n  const [commits, setCommits] = useState(0);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const actions = { start: startActions, end: endActions };\n  const reveal = (side: SwipeSide) => actions[side].length * actionWidth;\n  const full = (side: SwipeSide) => fullSwipe === true || fullSwipe === side;\n\n  // x is physical pixels; dir flips it to logical so \"start\" means the start edge in RTL too.\n  const x = useMotionValue(defaultOpen === \"start\" ? startActions.length * actionWidth : defaultOpen === \"end\" ? -endActions.length * actionWidth : 0);\n  const dir = useMotionValue(1);\n  const logical = useTransform(() => x.get() * dir.get());\n  const startRevealed = useTransform(() => Math.max(0, logical.get()));\n  const endRevealed = useTransform(() => Math.max(0, -logical.get()));\n  const armStart = useMotionValue(0);\n  const armEnd = useMotionValue(0);\n\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const armedRef = useRef<SwipeSide | null>(null);\n  const targetRef = useRef<SwipeSide | null>(defaultOpen);\n  const suppressClick = useRef(false);\n  const g = useRef({ id: -1, x0: 0, y0: 0, base: 0, lock: null as null | \"x\" | \"y\", width: 0, samples: [] as { t: number; v: number }[] });\n  const wheel = useRef({ active: false, base: 0, acc: 0, timer: 0 });\n\n  useEffect(() => {\n    if (rootRef.current && getComputedStyle(rootRef.current).direction === \"rtl\") dir.set(-1);\n    return () => {\n      anim.current?.stop();\n      window.clearTimeout(wheel.current.timer);\n    };\n  }, [dir]);\n\n  const width = () => rootRef.current?.getBoundingClientRect().width ?? 360;\n  const armAt = (side: SwipeSide, w: number) => Math.max(reveal(side) + 56, w * 0.5);\n\n  function setArmed(side: SwipeSide | null, index = 0) {\n    if (side) setExpand(index);\n    if (armedRef.current === side) return;\n    armedRef.current = side;\n    setArmedState(side);\n    for (const [s, mv] of [[\"start\", armStart], [\"end\", armEnd]] as const) {\n      const to = s === side ? 1 : 0;\n      if (reduce) mv.set(to);\n      else animate(mv, to, spring.snappy);\n    }\n    // A tick under the thumb where the platform allows it. Coarse, Android only, harmless elsewhere.\n    if (side) navigator.vibrate?.(8);\n  }\n\n  /** Maps a raw drag offset to where the row may actually be. */\n  function constrain(l: number, w: number) {\n    const side: SwipeSide = l >= 0 ? \"start\" : \"end\";\n    const mag = Math.abs(l);\n    const sign = Math.sign(l) || 1;\n    const r = reveal(side);\n    let out: number;\n    if (!r) out = rubber(mag, 28);\n    else if (mag <= r) out = mag;\n    else if (full(side)) out = Math.min(mag, w);\n    else out = r + rubber(mag - r, 56);\n    if (armedRef.current && armedRef.current !== side) setArmed(null);\n    if (r && full(side)) {\n      const at = armAt(side, w);\n      if (out >= at) setArmed(side);\n      else if (armedRef.current === side && out < at - 24) setArmed(null);\n    }\n    return sign * out;\n  }\n\n  function moveTo(l: number) {\n    x.set(l * dir.get());\n  }\n\n  function settle(side: SwipeSide | null, velocity = 0) {\n    anim.current?.stop();\n    setArmed(null);\n    const target = (side === \"start\" ? reveal(\"start\") : side === \"end\" ? -reveal(\"end\") : 0) * dir.get();\n    if (reduce) x.set(target);\n    else anim.current = animate(x, target, { ...settleSpring, velocity: velocity * dir.get() });\n    targetRef.current = side;\n    setOpen(side);\n  }\n\n  function focusNextRow() {\n    const root = rootRef.current;\n    if (!root || !root.contains(document.activeElement)) return;\n    const scope = root.closest(\"ul,ol,[role=list],[role=grid]\") ?? document;\n    const rows = Array.from(scope.querySelectorAll<HTMLElement>(\"[data-swipe-actions]\")).filter((r) => r.dataset.state !== \"committing\" || r === root);\n    const i = rows.indexOf(root);\n    const next = rows[i + 1] ?? rows[i - 1];\n    next?.querySelector<HTMLElement>('[data-swipe-content] :is(a[href],button,[tabindex=\"0\"]):not([tabindex=\"-1\"]):not(:disabled)')?.focus();\n  }\n\n  async function remove(side: SwipeSide, action: SwipeAction, how: \"gesture\" | \"pointer\" | \"keyboard\", velocity = 0) {\n    const root = rootRef.current;\n    if (!root) return;\n    anim.current?.stop();\n    setPhase(\"committing\");\n    const w = width();\n    if (!reduce && how !== \"keyboard\") {\n      setArmed(side, Math.max(0, actions[side].indexOf(action)));\n      const target = (side === \"start\" ? w : -w) * dir.get();\n      // A throw keeps its speed; a tap slides out on its own.\n      anim.current =\n        how === \"gesture\"\n          ? animate(x, target, { type: \"spring\", stiffness: 380, damping: 42, velocity: velocity * dir.get(), restDelta: 2 })\n          : animate(x, target, { duration: 0.2, ease: ease.in });\n      await anim.current;\n    }\n    focusNextRow();\n    await animate(root, reduce ? { opacity: 0 } : { height: [root.offsetHeight, 0], opacity: how === \"keyboard\" ? 0 : 1 }, { duration: reduce ? 0.12 : 0.22, ease: ease.inOut });\n    action.onAction();\n  }\n\n  function run(side: SwipeSide, action: SwipeAction, how: \"gesture\" | \"pointer\" | \"keyboard\", velocity = 0) {\n    if (action.removes) return void remove(side, action, how, velocity);\n    action.onAction();\n    setCommits((c) => c + 1);\n    settle(null, velocity);\n  }\n\n  function release(velocity: number) {\n    const l = logical.get();\n    const side: SwipeSide = l >= 0 ? \"start\" : \"end\";\n    if (armedRef.current) {\n      const s = armedRef.current;\n      const primary = actions[s][0];\n      if (primary) return run(s, primary, \"gesture\", velocity);\n    }\n    const r = reveal(side);\n    if (!r) return settle(null, velocity);\n    const outward = side === \"start\" ? velocity : -velocity;\n    let openIt = Math.abs(l) > r * 0.5;\n    if (outward > 350) openIt = true;\n    else if (outward < -350) openIt = false;\n    settle(openIt ? side : null, velocity);\n  }\n\n  const velocityOf = (samples: { t: number; v: number }[]) => {\n    const now = performance.now();\n    const recent = samples.filter((s) => now - s.t < 100);\n    if (recent.length < 2) return 0;\n    const a = recent[0];\n    const b = recent[recent.length - 1];\n    return b.t === a.t ? 0 : ((b.v - a.v) / (b.t - a.t)) * 1000;\n  };\n\n  // ---- pointer ---------------------------------------------------------------------------\n\n  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {\n    if (disabled || phase === \"committing\" || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    anim.current?.stop();\n    g.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: logical.get(), lock: null, width: width(), samples: [] };\n  }\n\n  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    const s = g.current;\n    if (s.id !== e.pointerId || s.lock === \"y\") return;\n    const dx = (e.clientX - s.x0) * dir.get();\n    const dy = e.clientY - s.y0;\n    if (!s.lock) {\n      if (Math.hypot(dx, dy) < 6) return;\n      // Mostly sideways is a swipe; anything else belongs to the page's scroll.\n      if (Math.abs(dx) < Math.abs(dy) * 1.2) {\n        s.lock = \"y\";\n        return;\n      }\n      s.lock = \"x\";\n      e.currentTarget.setPointerCapture(e.pointerId);\n      window.getSelection()?.removeAllRanges();\n      window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: uid }));\n      setPhase(\"dragging\");\n      // Pick up from the finger, not from the 6px threshold, so the row never jumps.\n      s.x0 = e.clientX;\n      s.base = logical.get();\n    }\n    const l = constrain(s.base + (e.clientX - s.x0) * dir.get(), s.width);\n    moveTo(l);\n    s.samples.push({ t: performance.now(), v: l });\n    if (s.samples.length > 12) s.samples.shift();\n  }\n\n  function onPointerEnd(e: React.PointerEvent<HTMLDivElement>) {\n    const s = g.current;\n    if (s.id !== e.pointerId) return;\n    s.id = -1;\n    if (s.lock !== \"x\") return;\n    suppressClick.current = true;\n    setPhase(\"idle\");\n    release(e.type === \"pointercancel\" ? 0 : velocityOf(s.samples));\n  }\n\n  // ---- trackpad: two-finger horizontal scroll swipes the row, like a desktop mail client ----\n\n  const onWheel = useEffectEvent((e: WheelEvent) => {\n    const w = wheel.current;\n    if (disabled || phase === \"committing\") return;\n    if (!w.active) {\n      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || Math.abs(e.deltaX) < 2) return;\n      anim.current?.stop();\n      w.active = true;\n      w.base = logical.get();\n      w.acc = 0;\n      g.current.samples = [];\n      g.current.width = width();\n      window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: uid }));\n      setPhase(\"dragging\");\n    }\n    e.preventDefault();\n    w.acc -= e.deltaX * dir.get();\n    const l = constrain(w.base + w.acc, g.current.width);\n    moveTo(l);\n    g.current.samples.push({ t: performance.now(), v: l });\n    window.clearTimeout(w.timer);\n    w.timer = window.setTimeout(() => {\n      w.active = false;\n      setPhase(\"idle\");\n      release(0);\n    }, 140);\n  });\n\n  useEffect(() => {\n    const el = contentRef.current;\n    if (!el) return;\n    const handler = (e: WheelEvent) => onWheel(e);\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, []);\n\n  // ---- closing from elsewhere ------------------------------------------------------------\n\n  const closeFromOutside = useEffectEvent(() => {\n    if (open && phase !== \"committing\") settle(null);\n  });\n\n  useEffect(() => {\n    const onEngage = (e: Event) => {\n      if ((e as CustomEvent).detail !== uid) closeFromOutside();\n    };\n    window.addEventListener(OPEN_EVENT, onEngage);\n    return () => window.removeEventListener(OPEN_EVENT, onEngage);\n  }, [uid]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) closeFromOutside();\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [open]);\n\n  // A controlled `open` that changes from outside moves the row there.\n  const follow = useEffectEvent((next: SwipeSide | null) => {\n    if (phase !== \"idle\" || next === targetRef.current) return;\n    targetRef.current = next;\n    const target = (next === \"start\" ? reveal(\"start\") : next === \"end\" ? -reveal(\"end\") : 0) * dir.get();\n    if (Math.abs(x.get() - target) < 0.5) return;\n    anim.current?.stop();\n    if (reduce) x.set(target);\n    else anim.current = animate(x, target, settleSpring);\n  });\n  useEffect(() => follow(open), [open]);\n\n  const state = phase === \"committing\" ? \"committing\" : phase === \"dragging\" ? \"dragging\" : open ? \"open\" : \"closed\";\n  const showToolbar = toolbar && (startActions.length > 0 || endActions.length > 0);\n  const toolbarItems = [\n    ...startActions.map((a) => ({ side: \"start\" as const, action: a })),\n    ...[...endActions].reverse().map((a) => ({ side: \"end\" as const, action: a })),\n  ];\n\n  return (\n    <div\n      ref={rootRef}\n      data-swipe-actions=\"\"\n      data-state={state}\n      data-side={open ?? undefined}\n      data-armed={armed ?? undefined}\n      data-disabled={disabled || undefined}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.key === \"Escape\" && open) {\n          e.stopPropagation();\n          anim.current?.stop();\n          // Keyboard closes instantly.\n          x.set(0);\n          targetRef.current = null;\n          setOpen(null);\n        }\n      }}\n      className={cn(\"group/swipe relative isolate overflow-hidden bg-raised\", className)}\n      {...rest}\n    >\n      {([\"start\", \"end\"] as const).map((side) =>\n        actions[side].length ? (\n          <div\n            key={side}\n            aria-hidden\n            className={cn(\"absolute inset-y-0 flex\", side === \"start\" ? \"start-0\" : \"end-0 flex-row-reverse\")}\n          >\n            {actions[side].map((action, i) => (\n              <TrayAction\n                key={action.id}\n                action={action}\n                side={side}\n                index={i}\n                count={actions[side].length}\n                width={actionWidth}\n                revealed={side === \"start\" ? startRevealed : endRevealed}\n                arm={side === \"start\" ? armStart : armEnd}\n                dir={dir}\n                expand={armed === side ? expand : 0}\n                solid={armed === side && i === expand}\n                commits={i === 0 ? commits : 0}\n                reduce={!!reduce}\n                onPress={() => run(side, action, \"pointer\")}\n              />\n            ))}\n          </div>\n        ) : null,\n      )}\n\n      <motion.div\n        ref={contentRef}\n        data-swipe-content=\"\"\n        style={{ x }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerEnd}\n        onPointerCancel={onPointerEnd}\n        onDragStart={(e) => e.preventDefault()}\n        onClickCapture={(e) => {\n          // A drag never lands as a click, and a tap on an open row just closes it.\n          if (suppressClick.current || (open && phase === \"idle\")) {\n            e.preventDefault();\n            e.stopPropagation();\n            if (!suppressClick.current) settle(null);\n          }\n          suppressClick.current = false;\n        }}\n        className={cn(\n          \"group/row relative z-10 bg-inherit touch-pan-y\",\n          state === \"dragging\" && \"select-none\",\n          state === \"committing\" && \"pointer-events-none\",\n          contentClassName,\n        )}\n      >\n        {children}\n        {showToolbar && (\n          <Toolbar\n            items={toolbarItems}\n            visible={state === \"closed\"}\n            disabled={disabled}\n            align={toolbarAlign}\n            onRun={(side, action, keyboard) => run(side, action, keyboard ? \"keyboard\" : \"pointer\")}\n          />\n        )}\n      </motion.div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The revealed tray\n * -----------------------------------------------------------------------------------------------*/\n\nfunction TrayAction({\n  action,\n  side,\n  index,\n  count,\n  expand,\n  width,\n  revealed,\n  arm,\n  dir,\n  solid,\n  commits,\n  reduce,\n  onPress,\n}: {\n  action: SwipeAction;\n  side: SwipeSide;\n  index: number;\n  count: number;\n  expand: number;\n  width: number;\n  revealed: MotionValue<number>;\n  arm: MotionValue<number>;\n  dir: MotionValue<number>;\n  solid: boolean;\n  commits: number;\n  reduce: boolean;\n  onPress: () => void;\n}) {\n  // Each action takes its share of the revealed width. Once a full swipe arms (or a tap commits),\n  // that action takes all of it and the rest fold away beneath the row.\n  const share = useTransform(() => {\n    const r = revealed.get();\n    const a = arm.get();\n    return index === expand ? r * (1 / count + (1 - 1 / count) * a) : (r * (1 - a)) / count;\n  });\n  const progress = useTransform(() => clamp(share.get() / width));\n  // Centered while narrower than its slot; once stretched, it hugs the row's edge and follows the finger.\n  const shift = useTransform(() => {\n    const w = share.get();\n    const off = w < width ? (w - width) / 2 : 0;\n    return (side === \"end\" ? off : -off) * dir.get();\n  });\n  const iconOpacity = useTransform(() => clamp((progress.get() - 0.35) / 0.5));\n  // Driven by the finger, so it stays under reduced motion; nothing moves on its own.\n  const iconScale = useTransform(() => 0.72 + 0.28 * progress.get());\n  const tone = tones[action.tone ?? \"neutral\"];\n\n  return (\n    <motion.button\n      type=\"button\"\n      tabIndex={-1}\n      style={{ width: share }}\n      onClick={onPress}\n      className={cn(\n        \"group/act relative flex h-full shrink-0 overflow-hidden outline-none\",\n        side === \"start\" ? \"justify-end\" : \"justify-start\",\n        \"transition-colors duration-150\",\n        solid ? tone.solid : cn(tone.soft, tone.hover),\n      )}\n    >\n      <motion.span style={{ x: shift, width }} className=\"flex h-full shrink-0 flex-col items-center justify-center gap-1 transition-transform duration-100 group-active/act:scale-95\">\n        <motion.span style={{ opacity: iconOpacity, scale: iconScale }} className=\"flex flex-col items-center gap-1\">\n          <motion.span\n            key={commits}\n            initial={commits && !reduce ? { scale: 1.2 } : false}\n            animate={solid && !reduce ? { scale: [1, 1.16, 1] } : { scale: 1 }}\n            transition={{ duration: 0.3, ease: ease.out }}\n            className=\"grid size-4 place-items-center\"\n          >\n            {action.icon}\n          </motion.span>\n          <span className=\"max-w-full truncate px-1 text-[11px] font-medium leading-none tracking-[-0.005em]\">{action.label}</span>\n        </motion.span>\n      </motion.span>\n    </motion.button>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Hover and focus toolbar: the same actions for a mouse, a keyboard and a screen reader\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Toolbar({\n  items,\n  visible,\n  disabled,\n  align,\n  onRun,\n}: {\n  items: { side: SwipeSide; action: SwipeAction }[];\n  visible: boolean;\n  disabled: boolean;\n  align: \"center\" | \"top\";\n  onRun: (side: SwipeSide, action: SwipeAction, keyboard: boolean) => void;\n}) {\n  return (\n    <Tooltip.Provider delay={500} timeout={400}>\n      <div\n        role=\"group\"\n        aria-label=\"Row actions\"\n        className={cn(\n          \"absolute end-2 z-10 flex items-center gap-0.5 rounded-lg border border-line-2 bg-raised p-0.5 shadow-[var(--shadow)]\",\n          \"origin-right transition-[opacity,scale,translate] duration-150 ease-out-expo motion-reduce:transition-opacity\",\n          // Hidden until the row is hovered or something in it has keyboard focus. Never on touch alone.\n          \"pointer-events-none translate-x-1 scale-96 opacity-0\",\n          visible &&\n            \"group-hover/row:pointer-events-auto group-hover/row:translate-x-0 group-hover/row:scale-100 group-hover/row:opacity-100 group-has-focus-visible/row:pointer-events-auto group-has-focus-visible/row:translate-x-0 group-has-focus-visible/row:scale-100 group-has-focus-visible/row:opacity-100\",\n          align === \"top\" ? \"top-1.5\" : \"inset-y-0 my-auto h-fit\",\n        )}\n      >\n        {items.map(({ side, action }) => (\n          <Tooltip.Root key={`${side}-${action.id}`}>\n            <Tooltip.Trigger\n              render={\n                <button\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-label={action.label}\n                  onClick={(e) => onRun(side, action, e.detail === 0)}\n                  className={cn(\n                    \"grid size-7 place-items-center rounded-md text-fg-2 outline-none\",\n                    \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                    \"disabled:pointer-events-none disabled:opacity-50\",\n                    action.tone === \"danger\" && \"hover:bg-danger-soft hover:text-danger\",\n                  )}\n                />\n              }\n            >\n              {action.icon}\n            </Tooltip.Trigger>\n            <Tooltip.Portal>\n              <Tooltip.Positioner side=\"top\" sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n                <Tooltip.Popup\n                  className={cn(\n                    \"origin-(--transform-origin) rounded-lg border border-line-2 bg-raised px-2 py-[3px] text-[12px] leading-5 text-fg shadow-pop\",\n                    \"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100\",\n                    \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-instant:transition-none\",\n                  )}\n                >\n                  {action.label}\n                </Tooltip.Popup>\n              </Tooltip.Positioner>\n            </Tooltip.Portal>\n          </Tooltip.Root>\n        ))}\n      </div>\n    </Tooltip.Provider>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/swipe-actions"
}
