UIPackage

Transfer

React data-display
Edit on GitHub

Dual-list move-between control. Two columns plus a center pair of move buttons. Optional search, pagination, and one-way mode.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/transfer.json

Or with the named registry: npx shadcn@latest add @uipkge-react/transfer

Examples

Props

Name Type / Values Default Required
targetKeys string[] optional
defaultTargetKeys

Uncontrolled initial target keys.

string[] optional
dataSource TransferItem[] required
titles [string, string] optional
showSearch boolean optional
filterFn (query: string, item: TransferItem) => boolean optional
height number | string optional
pagination boolean | { pageSize: number } optional
oneWay boolean optional
disabled boolean optional
draggable boolean optional
selectable boolean optional
className string optional
onTargetKeysChange (keys: string[]) => void optional
onChange (keys: string[], direction: 'left' | 'right', moved: string[]) => void optional
onSearch (payload: { direction: 'left' | 'right'; query: string }) => void optional
onSelectChange (payload: { left: string[]; right: string[] }) => void optional
footerLeft

Footer rendered under the left list.

React.ReactNode optional
footerRight

Footer rendered under the right list.

React.ReactNode optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TransferItem
interface TransferItem {
  key: string
  label: string
  description?: string
  disabled?: boolean
}
TransferDragPayload
interface TransferDragPayload {
  keys: string[]
  fromSide: TransferSide
}
TransferContextValue
interface TransferContextValue {
  disabled: boolean
  showSearch: boolean
  height: number | string
  pageSize: number | null
  filterFn: (query: string, item: TransferItem) => boolean
  draggable: boolean
  selectable: boolean
  oneWay: boolean
  dragPayload: TransferDragPayload | null
  startDrag: (payload: TransferDragPayload) => void
  endDrag: () => void
  drop: (toSide: TransferSide, beforeKey: string | null) => void
}

Dependencies

Files (2)

  • components/ui/transfer/transfer.tsx 22.7 kB
    'use client'
    
    import * as React from 'react'
    import { ChevronLeft, ChevronRight, GripVertical, Search } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { Button } from '@/components/ui/button'
    import { Checkbox } from '@/components/ui/checkbox'
    import { Input } from '@/components/ui/input'
    import { ScrollArea } from '@/components/ui/scroll-area'
    
    export interface TransferItem {
      key: string
      label: string
      description?: string
      disabled?: boolean
    }
    
    export type TransferSide = 'left' | 'right'
    
    interface TransferDragPayload {
      keys: string[]
      fromSide: TransferSide
    }
    
    interface TransferContextValue {
      disabled: boolean
      showSearch: boolean
      height: number | string
      pageSize: number | null
      filterFn: (query: string, item: TransferItem) => boolean
      draggable: boolean
      selectable: boolean
      oneWay: boolean
      dragPayload: TransferDragPayload | null
      startDrag: (payload: TransferDragPayload) => void
      endDrag: () => void
      drop: (toSide: TransferSide, beforeKey: string | null) => void
    }
    
    const TransferContext = React.createContext<TransferContextValue | null>(null)
    
    function useTransferContext(): TransferContextValue {
      const ctx = React.useContext(TransferContext)
      if (!ctx) throw new Error('TransferList must be used inside <Transfer>.')
      return ctx
    }
    
    export interface TransferProps {
      targetKeys?: string[]
      /** Uncontrolled initial target keys. */
      defaultTargetKeys?: string[]
      dataSource: TransferItem[]
      titles?: [string, string]
      showSearch?: boolean
      filterFn?: (query: string, item: TransferItem) => boolean
      height?: number | string
      pagination?: boolean | { pageSize: number }
      oneWay?: boolean
      disabled?: boolean
      draggable?: boolean
      selectable?: boolean
      className?: string
      onTargetKeysChange?: (keys: string[]) => void
      onChange?: (keys: string[], direction: 'left' | 'right', moved: string[]) => void
      onSearch?: (payload: { direction: 'left' | 'right'; query: string }) => void
      onSelectChange?: (payload: { left: string[]; right: string[] }) => void
      /** Footer rendered under the left list. */
      footerLeft?: React.ReactNode
      /** Footer rendered under the right list. */
      footerRight?: React.ReactNode
    }
    
    const defaultFilter = (q: string, item: TransferItem) => item.label.toLowerCase().includes(q.toLowerCase())
    
    const Transfer = React.forwardRef<HTMLDivElement, TransferProps>(
      (
        {
          targetKeys,
          defaultTargetKeys,
          dataSource,
          titles = ['Source', 'Target'],
          showSearch = false,
          filterFn,
          height = 320,
          pagination = false,
          oneWay = false,
          disabled = false,
          draggable = false,
          selectable = true,
          className,
          onTargetKeysChange,
          onChange,
          onSearch,
          onSelectChange,
          footerLeft,
          footerRight,
        },
        ref,
      ) => {
        const isControlled = targetKeys !== undefined
        const [internalKeys, setInternalKeys] = React.useState<string[]>(defaultTargetKeys ?? [])
        const resolvedTargetKeys = isControlled ? targetKeys : internalKeys
    
        const setTargetKeys = React.useCallback(
          (next: string[]) => {
            if (!isControlled) setInternalKeys(next)
            onTargetKeysChange?.(next)
          },
          [isControlled, onTargetKeysChange],
        )
    
        const [selectedLeft, setSelectedLeft] = React.useState<string[]>([])
        const [selectedRight, setSelectedRight] = React.useState<string[]>([])
        const [dragPayload, setDragPayload] = React.useState<TransferDragPayload | null>(null)
    
        const dataMap = React.useMemo(() => new Map(dataSource.map((i) => [i.key, i])), [dataSource])
    
        const sourceItems = React.useMemo(
          () => dataSource.filter((i) => !resolvedTargetKeys.includes(i.key)),
          [dataSource, resolvedTargetKeys],
        )
    
        // When draggable, target order follows targetKeys exactly so reorder persists.
        // Otherwise keep legacy dataSource ordering for backwards compat.
        const targetItems = React.useMemo<TransferItem[]>(() => {
          if (draggable) {
            const out: TransferItem[] = []
            for (const k of resolvedTargetKeys) {
              const item = dataMap.get(k)
              if (item) out.push(item)
            }
            return out
          }
          return dataSource.filter((i) => resolvedTargetKeys.includes(i.key))
        }, [draggable, resolvedTargetKeys, dataMap, dataSource])
    
        const pageSize = React.useMemo<number | null>(() => {
          if (pagination === false) return null
          if (pagination === true) return 10
          return pagination.pageSize
        }, [pagination])
    
        const startDrag = React.useCallback((payload: TransferDragPayload) => {
          setDragPayload(payload)
        }, [])
    
        const endDrag = React.useCallback(() => {
          setDragPayload(null)
        }, [])
    
        // Refs so the stable `drop` callback always reads current values.
        const stateRef = React.useRef({ resolvedTargetKeys, dragPayload, selectedLeft, selectedRight, dataMap, disabled, oneWay })
        stateRef.current = { resolvedTargetKeys, dragPayload, selectedLeft, selectedRight, dataMap, disabled, oneWay }
    
        const drop = React.useCallback(
          (toSide: TransferSide, beforeKey: string | null) => {
            const s = stateRef.current
            const payload = s.dragPayload
            setDragPayload(null)
            if (!payload || s.disabled) return
            const keys = payload.keys.filter((k) => {
              const item = s.dataMap.get(k)
              return item && !item.disabled
            })
            if (keys.length === 0) return
    
            // left → left: reorder source not supported (parent owns dataSource order). No-op.
            if (payload.fromSide === 'left' && toSide === 'left') return
    
            // right → left: remove from targetKeys (skip when oneWay).
            if (payload.fromSide === 'right' && toSide === 'left') {
              if (s.oneWay) return
              const removeSet = new Set(keys)
              const next = s.resolvedTargetKeys.filter((k) => !removeSet.has(k))
              const nextRight = s.selectedRight.filter((k) => !removeSet.has(k))
              setSelectedRight(nextRight)
              setTargetKeys(next)
              onChange?.(next, 'left', keys)
              onSelectChange?.({ left: s.selectedLeft, right: nextRight })
              return
            }
    
            // → right: insert (cross-list move) or reorder (within-target).
            const movingSet = new Set(keys)
            const without = s.resolvedTargetKeys.filter((k) => !movingSet.has(k))
            let insertAt = without.length
            if (beforeKey != null) {
              const idx = without.indexOf(beforeKey)
              if (idx >= 0) insertAt = idx
            }
            const next = [...without.slice(0, insertAt), ...keys, ...without.slice(insertAt)]
            if (payload.fromSide === 'left') {
              const movedSet = new Set(keys)
              const nextLeft = s.selectedLeft.filter((k) => !movedSet.has(k))
              setSelectedLeft(nextLeft)
              setTargetKeys(next)
              onChange?.(next, 'right', keys)
              onSelectChange?.({ left: nextLeft, right: s.selectedRight })
            } else {
              // right → right: pure reorder, no change event (target set unchanged).
              setTargetKeys(next)
            }
          },
          [setTargetKeys, onChange, onSelectChange],
        )
    
        const contextValue = React.useMemo<TransferContextValue>(
          () => ({
            disabled,
            showSearch,
            height,
            pageSize,
            filterFn: filterFn ?? defaultFilter,
            draggable,
            selectable,
            oneWay,
            dragPayload,
            startDrag,
            endDrag,
            drop,
          }),
          [disabled, showSearch, height, pageSize, filterFn, draggable, selectable, oneWay, dragPayload, startDrag, endDrag, drop],
        )
    
        function onLeftSelected(keys: string[]) {
          setSelectedLeft(keys)
          onSelectChange?.({ left: keys, right: selectedRight })
        }
    
        function onRightSelected(keys: string[]) {
          setSelectedRight(keys)
          onSelectChange?.({ left: selectedLeft, right: keys })
        }
    
        function moveRight() {
          if (selectedLeft.length === 0) return
          const next = [...resolvedTargetKeys, ...selectedLeft]
          const moved = [...selectedLeft]
          setSelectedLeft([])
          setTargetKeys(next)
          onChange?.(next, 'right', moved)
          onSelectChange?.({ left: [], right: selectedRight })
        }
    
        function moveLeft() {
          if (selectedRight.length === 0) return
          const remove = new Set(selectedRight)
          const next = resolvedTargetKeys.filter((k) => !remove.has(k))
          const moved = [...selectedRight]
          setSelectedRight([])
          setTargetKeys(next)
          onChange?.(next, 'left', moved)
          onSelectChange?.({ left: selectedLeft, right: [] })
        }
    
        return (
          <TransferContext.Provider value={contextValue}>
            <div ref={ref} className={cn('flex items-stretch gap-3', className)} data-uipkge="" data-slot="transfer">
              <div className="min-w-0 flex-1">
                <TransferList
                  side="left"
                  title={titles[0]}
                  items={sourceItems}
                  selected={selectedLeft}
                  onSelectedChange={onLeftSelected}
                  onSearch={(q) => onSearch?.({ direction: 'left', query: q })}
                  footer={footerLeft}
                />
              </div>
              <TransferOperation
                canMoveRight={selectedLeft.length > 0 && !disabled}
                canMoveLeft={selectedRight.length > 0 && !disabled}
                oneWay={oneWay}
                onMoveRight={moveRight}
                onMoveLeft={moveLeft}
              />
              <div className="min-w-0 flex-1">
                <TransferList
                  side="right"
                  title={titles[1]}
                  items={targetItems}
                  selected={selectedRight}
                  onSelectedChange={onRightSelected}
                  onSearch={(q) => onSearch?.({ direction: 'right', query: q })}
                  footer={footerRight}
                />
              </div>
            </div>
          </TransferContext.Provider>
        )
      },
    )
    Transfer.displayName = 'Transfer'
    
    interface TransferOperationProps {
      canMoveRight: boolean
      canMoveLeft: boolean
      oneWay?: boolean
      onMoveRight: () => void
      onMoveLeft: () => void
    }
    
    function TransferOperation({ canMoveRight, canMoveLeft, oneWay, onMoveRight, onMoveLeft }: TransferOperationProps) {
      return (
        <div className="flex flex-col items-center justify-center gap-2 px-2">
          <Button
            size="icon-sm"
            variant="outline"
            disabled={!canMoveRight}
            aria-label="Move selected to right"
            onClick={onMoveRight}
          >
            <ChevronRight aria-hidden="true" />
          </Button>
          {!oneWay && (
            <Button
              size="icon-sm"
              variant="outline"
              disabled={!canMoveLeft}
              aria-label="Move selected to left"
              onClick={onMoveLeft}
            >
              <ChevronLeft aria-hidden="true" />
            </Button>
          )}
        </div>
      )
    }
    
    interface TransferListProps {
      side: TransferSide
      title: string
      items: TransferItem[]
      selected: string[]
      onSelectedChange: (keys: string[]) => void
      onSearch: (query: string) => void
      footer?: React.ReactNode
    }
    
    function TransferList({ side, title, items, selected, onSelectedChange, onSearch, footer }: TransferListProps) {
      const ctx = useTransferContext()
    
      const [query, setQuery] = React.useState('')
      const [page, setPage] = React.useState(1)
    
      const filtered = React.useMemo(() => {
        if (!query) return items
        return items.filter((i) => ctx.filterFn(query, i))
      }, [query, items, ctx])
    
      const effectivePageSize = ctx.pageSize ?? Math.max(1, filtered.length)
      const totalPages = Math.max(1, Math.ceil(filtered.length / effectivePageSize))
    
      const visible = React.useMemo(() => {
        if (!ctx.pageSize) return filtered
        const start = (page - 1) * effectivePageSize
        return filtered.slice(start, start + effectivePageSize)
      }, [ctx.pageSize, filtered, page, effectivePageSize])
    
      React.useEffect(() => {
        if (page > totalPages) setPage(totalPages)
      }, [page, totalPages])
    
      const visibleEnabledKeys = React.useMemo(
        () => visible.filter((i) => !i.disabled).map((i) => i.key),
        [visible],
      )
      const selectedSet = React.useMemo(() => new Set(selected), [selected])
    
      const visibleSelectedCount = visibleEnabledKeys.filter((k) => selectedSet.has(k)).length
    
      const masterChecked = visibleEnabledKeys.length > 0 && visibleSelectedCount === visibleEnabledKeys.length
      const masterIndeterminate = visibleSelectedCount > 0 && visibleSelectedCount < visibleEnabledKeys.length
    
      const [lastAnchor, setLastAnchor] = React.useState<string | null>(null)
    
      function toggleAll(checked: boolean) {
        let next = [...selected]
        if (checked) {
          for (const k of visibleEnabledKeys) {
            if (!selectedSet.has(k)) next.push(k)
          }
        } else {
          next = next.filter((k) => !visibleEnabledKeys.includes(k))
        }
        onSelectedChange(next)
      }
    
      function toggleItem(item: TransferItem, checked: boolean) {
        if (item.disabled || ctx.disabled) return
        let next = [...selected]
        if (checked) {
          if (!next.includes(item.key)) next.push(item.key)
        } else {
          next = next.filter((k) => k !== item.key)
        }
        onSelectedChange(next)
        setLastAnchor(item.key)
      }
    
      function onRowClick(e: React.MouseEvent, item: TransferItem) {
        if (item.disabled || ctx.disabled) return
        // With checkbox visible, click toggles (matches checkbox UX).
        if (ctx.selectable) {
          toggleItem(item, !selectedSet.has(item.key))
          return
        }
        // No checkbox: desktop list pattern — plain=replace, cmd/ctrl=toggle, shift=range.
        const enabledKeys = visible.filter((i) => !i.disabled).map((i) => i.key)
        if (e.shiftKey && lastAnchor && enabledKeys.includes(lastAnchor)) {
          const start = enabledKeys.indexOf(lastAnchor)
          const end = enabledKeys.indexOf(item.key)
          const [lo, hi] = start < end ? [start, end] : [end, start]
          onSelectedChange(enabledKeys.slice(lo, hi + 1))
          return
        }
        if (e.metaKey || e.ctrlKey) {
          let next = [...selected]
          if (selectedSet.has(item.key)) next = next.filter((k) => k !== item.key)
          else next.push(item.key)
          onSelectedChange(next)
          setLastAnchor(item.key)
          return
        }
        onSelectedChange([item.key])
        setLastAnchor(item.key)
      }
    
      function handleSearch(v: string) {
        setQuery(v)
        setPage(1)
        onSearch(v)
      }
    
      function prevPage() {
        if (page > 1) setPage((p) => p - 1)
      }
    
      function nextPage() {
        if (page < totalPages) setPage((p) => p + 1)
      }
    
      const heightStyle: React.CSSProperties = {
        height: typeof ctx.height === 'number' ? ctx.height + 'px' : ctx.height,
      }
    
      // ----- DnD -----
    
      const [dropIndicator, setDropIndicator] = React.useState<{ key: string; position: 'before' | 'after' } | null>(null)
      const [draggingKeys, setDraggingKeys] = React.useState<Set<string>>(new Set())
    
      const isDropTarget = React.useMemo(() => {
        const p = ctx.dragPayload
        if (!p) return false
        // left list rejects drops when oneWay
        if (side === 'left' && ctx.oneWay && p.fromSide === 'right') return false
        // left → left is a no-op (parent owns dataSource order)
        if (side === 'left' && p.fromSide === 'left') return false
        return true
      }, [ctx.dragPayload, ctx.oneWay, side])
    
      function onItemDragStart(e: React.DragEvent, item: TransferItem) {
        if (!ctx.draggable || item.disabled || ctx.disabled) {
          e.preventDefault()
          return
        }
        // If the dragged row is part of the current selection, drag the whole selection.
        // Else, drag just this row (and clear selection visually for clarity).
        const keys = selectedSet.has(item.key)
          ? selected.filter((k) => {
              const i = items.find((x) => x.key === k)
              return i && !i.disabled
            })
          : [item.key]
        setDraggingKeys(new Set(keys))
        ctx.startDrag({ keys, fromSide: side })
        if (e.dataTransfer) {
          e.dataTransfer.effectAllowed = 'move'
          // Required by Firefox to actually start the drag.
          try {
            e.dataTransfer.setData('text/plain', keys.join(','))
          } catch {
            /* noop */
          }
        }
      }
    
      function onItemDragEnd() {
        setDraggingKeys(new Set())
        setDropIndicator(null)
        ctx.endDrag()
      }
    
      function onItemDragOver(e: React.DragEvent, item: TransferItem) {
        if (!isDropTarget) return
        e.preventDefault()
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
        if (side !== 'right') {
          // left list: no insertion indicator, drop just removes from target
          return
        }
        const target = e.currentTarget as HTMLElement
        const rect = target.getBoundingClientRect()
        const after = e.clientY > rect.top + rect.height / 2
        setDropIndicator({ key: item.key, position: after ? 'after' : 'before' })
      }
    
      function onItemDrop(e: React.DragEvent, item: TransferItem) {
        if (!isDropTarget) return
        e.preventDefault()
        e.stopPropagation()
        if (side === 'right') {
          const after = dropIndicator?.position === 'after'
          const idx = items.findIndex((x) => x.key === item.key)
          const beforeKey = after ? (items[idx + 1]?.key ?? null) : item.key
          ctx.drop('right', beforeKey)
        } else {
          ctx.drop('left', null)
        }
        setDropIndicator(null)
      }
    
      function onListDragOver(e: React.DragEvent) {
        if (!isDropTarget) return
        e.preventDefault()
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
      }
    
      function onListDrop(e: React.DragEvent) {
        if (!isDropTarget) return
        e.preventDefault()
        // Empty zone or below all items → append (right) / remove (left).
        ctx.drop(side, null)
        setDropIndicator(null)
      }
    
      function onListDragLeave(e: React.DragEvent) {
        // Clear indicator only when leaving the list container, not when crossing item rows.
        const related = e.relatedTarget as Node | null
        const current = e.currentTarget as Node
        if (!related || !current.contains(related)) {
          setDropIndicator(null)
        }
      }
    
      return (
        <div
          className={cn(
            'bg-card flex flex-col overflow-hidden rounded-md border transition-colors',
            ctx.dragPayload && isDropTarget && 'ring-ring/40 ring-1',
          )}
        >
          <div className="bg-muted/40 flex items-center justify-between gap-2 border-b px-3 py-2">
            <div className="flex min-w-0 items-center gap-2">
              {ctx.selectable && (
                <Checkbox
                  checked={masterIndeterminate ? 'indeterminate' : masterChecked}
                  disabled={ctx.disabled || visibleEnabledKeys.length === 0}
                  onCheckedChange={(c) => toggleAll(c === true)}
                />
              )}
              <span className="truncate text-sm font-medium">{title}</span>
            </div>
            <span className="text-muted-foreground text-xs tabular-nums">
              {' '}
              {selected.length}/{items.length}{' '}
            </span>
          </div>
    
          {ctx.showSearch && (
            <div className="border-b p-2">
              <div className="relative">
                <Search
                  className="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2"
                  aria-hidden="true"
                />
                <Input
                  value={query}
                  placeholder="Search"
                  className="h-8 pl-8"
                  onChange={(e) => handleSearch(e.target.value)}
                />
              </div>
            </div>
          )}
    
          <ScrollArea style={heightStyle} className="flex-1" onDragOver={onListDragOver} onDrop={onListDrop} onDragLeave={onListDragLeave}>
            <ul role="listbox" aria-multiselectable="true" className="py-1">
              {visible.map((item) => (
                <li
                  key={item.key}
                  role="option"
                  aria-selected={selectedSet.has(item.key)}
                  draggable={ctx.draggable && !item.disabled && !ctx.disabled}
                  className={cn(
                    'hover:bg-accent focus-visible:ring-ring relative flex min-h-11 cursor-pointer items-start gap-2 px-3 py-3 text-sm select-none focus-visible:ring-2 focus-visible:outline-none',
                    item.disabled && 'cursor-not-allowed opacity-50',
                    draggingKeys.has(item.key) && 'opacity-40',
                    !ctx.selectable && selectedSet.has(item.key) && 'bg-accent',
                  )}
                  onClick={(e) => onRowClick(e, item)}
                  onDragStart={(e) => onItemDragStart(e, item)}
                  onDragEnd={onItemDragEnd}
                  onDragOver={(e) => onItemDragOver(e, item)}
                  onDrop={(e) => onItemDrop(e, item)}
                >
                  {dropIndicator && dropIndicator.key === item.key && dropIndicator.position === 'before' && side === 'right' && (
                    <span
                      className="bg-primary pointer-events-none absolute -top-px right-2 left-2 h-0.5 rounded-full"
                      aria-hidden="true"
                    />
                  )}
                  {dropIndicator && dropIndicator.key === item.key && dropIndicator.position === 'after' && side === 'right' && (
                    <span
                      className="bg-primary pointer-events-none absolute right-2 -bottom-px left-2 h-0.5 rounded-full"
                      aria-hidden="true"
                    />
                  )}
                  {ctx.selectable && (
                    <Checkbox
                      checked={selectedSet.has(item.key)}
                      disabled={item.disabled || ctx.disabled}
                      onCheckedChange={(c) => toggleItem(item, c === true)}
                      onClick={(e) => e.stopPropagation()}
                    />
                  )}
                  <div className="min-w-0 flex-1">
                    <div className="truncate">{item.label}</div>
                    {item.description && <div className="text-muted-foreground truncate text-xs">{item.description}</div>}
                  </div>
                  {ctx.draggable && !item.disabled && (
                    <GripVertical className="text-muted-foreground/60 mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
                  )}
                </li>
              ))}
              {visible.length === 0 && <li className="text-muted-foreground px-3 py-6 text-center text-sm">No items</li>}
            </ul>
          </ScrollArea>
    
          {ctx.pageSize && totalPages > 1 && (
            <div className="flex items-center justify-center gap-2 border-t p-2 text-xs">
              <button
                type="button"
                className="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
                disabled={page <= 1}
                onClick={prevPage}
              >
                Prev
              </button>
              <span className="tabular-nums">
                {page} / {totalPages}
              </span>
              <button
                type="button"
                className="hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
                disabled={page >= totalPages}
                onClick={nextPage}
              >
                Next
              </button>
            </div>
          )}
    
          {footer && <div className="border-t p-2">{footer}</div>}
        </div>
      )
    }
    
    export { Transfer, TransferList, TransferOperation }
  • components/ui/transfer/index.ts 0.1 kB
    export {
      Transfer,
      TransferList,
      TransferOperation,
      type TransferProps,
      type TransferItem,
      type TransferSide,
    } from './transfer'

Raw manifest: https://react.uipkge.dev/r/react/transfer.json