Swipe Row

PreviousNext

A swipe row for providing actions of swipe of left and right

Docs
moleculeuiui

Preview

Loading preview…
registry/molecule-ui/swipe-row.tsx
"use client"

import React from "react"
import { HTMLMotionProps, motion, type PanInfo } from "motion/react"

import { cn } from "@/lib/utils"

type SwipeRowContextType = {
  dragX: number
  setDragX: React.Dispatch<React.SetStateAction<number>>
  actionRefLeft?: React.RefObject<HTMLDivElement | null>
  actionRefRight?: React.RefObject<HTMLDivElement | null>
  handleDrag: (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => void
  handleDragEnd: (
    _: MouseEvent | TouchEvent | PointerEvent,
    info: PanInfo,
  ) => void
}

const DRAG_THRESHOLD = 100
const ACTIONS_VIEW_THRESHOLD = 50

const SwipeRowContext = React.createContext<SwipeRowContextType | null>(null)

export function useSwipeRowContext() {
  const ctx = React.useContext(SwipeRowContext)
  if (!ctx)
    throw new Error("Swipe Row components must be used inside <SwipeRow>")
  return ctx
}

export function SwipeRow({ className, children }: React.ComponentProps<"div">) {
  const [dragX, setDragX] = React.useState<number>(0)

  const actionRefLeft = React.useRef<HTMLDivElement>(null)
  const actionRefRight = React.useRef<HTMLDivElement>(null)

  const handleDragEnd = React.useCallback(
    (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
      const absoluteOffset = Math.abs(info.offset.x)

      if (
        actionRefLeft.current &&
        absoluteOffset > DRAG_THRESHOLD &&
        info.offset.x > 0
      ) {
        setDragX(actionRefLeft.current.offsetWidth)
      } else if (
        actionRefRight.current &&
        absoluteOffset > DRAG_THRESHOLD &&
        info.offset.x < 0
      ) {
        setDragX(-actionRefRight.current.offsetWidth)
      } else {
        setDragX(0)
      }
    },
    [],
  )

  const handleDrag = React.useCallback(
    (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
      setDragX(info.offset.x)
    },
    [],
  )

  const contextValue = React.useMemo<SwipeRowContextType>(
    () => ({
      dragX,
      setDragX,
      actionRefLeft,
      actionRefRight,
      handleDrag,
      handleDragEnd,
    }),
    [dragX, setDragX, actionRefLeft, actionRefRight, handleDrag, handleDragEnd],
  )

  return (
    <SwipeRowContext.Provider value={contextValue}>
      <div
        role="group"
        aria-roledescription="swipe-row-list-item"
        aria-label="swipe-row-item"
        className={cn("relative w-full overflow-hidden", className)}
      >
        {children}
      </div>
    </SwipeRowContext.Provider>
  )
}

export function SwipeRowContent({
  children,
  className,
  ...props
}: HTMLMotionProps<"div">) {
  const { actionRefLeft, actionRefRight, dragX, handleDrag, handleDragEnd } =
    useSwipeRowContext()

  return (
    <motion.div
      aria-label="swipe-row-item-content"
      tabIndex={0}
      className={cn(
        "relative cursor-grab p-4 select-none active:cursor-grabbing",
        className,
      )}
      drag="x"
      dragConstraints={{
        left: actionRefLeft?.current
          ? -actionRefLeft?.current?.offsetWidth || 0
          : 0,
        right: actionRefRight ? actionRefRight.current?.offsetWidth : 0,
      }}
      dragElastic={0.1}
      onDrag={handleDrag}
      onDragEnd={handleDragEnd}
      animate={{ x: dragX }}
      transition={{ stiffness: 300 }}
      {...props}
    >
      {children}
    </motion.div>
  )
}

export function SwipeLeftActions({
  className,
  children,
  ...props
}: HTMLMotionProps<"div">) {
  const { actionRefLeft, dragX } = useSwipeRowContext()
  return (
    <motion.div
      role="region"
      aria-label="left-actions"
      ref={actionRefLeft}
      className={cn(
        "absolute top-0 left-0 flex h-full items-center",
        className,
      )}
      initial={{ opacity: 0 }}
      animate={{
        opacity: dragX > ACTIONS_VIEW_THRESHOLD && actionRefLeft ? 1 : 0,
        x:
          dragX > 0 && actionRefLeft
            ? 0
            : -(actionRefLeft?.current?.offsetWidth || 0),
      }}
      transition={{ stiffness: 300 }}
      {...props}
    >
      {children}
    </motion.div>
  )
}

export function SwipeRightActions({
  className,
  children,
  ...props
}: HTMLMotionProps<"div">) {
  const { actionRefRight, dragX } = useSwipeRowContext()
  return (
    <motion.div
      role="region"
      aria-label="right-actions"
      ref={actionRefRight}
      className={cn(
        "absolute top-0 right-0 flex h-full items-center",
        className,
      )}
      initial={{ opacity: 0 }}
      animate={{
        opacity: dragX < -ACTIONS_VIEW_THRESHOLD && actionRefRight ? 1 : 0,
        x:
          dragX < 0 && actionRefRight
            ? 0
            : actionRefRight?.current?.offsetWidth || 0,
      }}
      transition={{ stiffness: 300 }}
      {...props}
    >
      {children}
    </motion.div>
  )
}

Installation

npx shadcn@latest add @moleculeui/swipe-row

Usage

import { SwipeRow } from "@/components/ui/swipe-row"
<SwipeRow />