UIPackage

Transfer

Vue 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 React ->

Installation

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

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

Examples

Props

Name Type / Values Default Required
targetKeys string[] optional
dataSource TransferItem[] required
titles [string, string] optional
showSearch boolean optional
filterFn (query: string, item: TransferItem) 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
}
TransferContext
interface TransferContext {
  disabled: Ref<boolean>
  showSearch: Ref<boolean>
  height: Ref<number | string>
  pageSize: Ref<number | null>
  filterFn: (query: string, item: TransferItem) => boolean
  draggable: Ref<boolean>
  selectable: Ref<boolean>
  oneWay: Ref<boolean>
  dragPayload: Ref<TransferDragPayload | null>
  startDrag: (payload: TransferDragPayload) => void
  endDrag: () => void
  drop: (toSide: TransferSide, beforeKey: string | null) => void
}

Dependencies

Files (5)

  • app/components/ui/transfer/Transfer.vue 6.8 kB
    <script setup lang="ts">
    import { computed, provide, ref, toRef } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    import TransferList from './TransferList.vue'
    import TransferOperation from './TransferOperation.vue'
    import { TRANSFER_INJECTION_KEY, type TransferDragPayload, type TransferItem, type TransferSide } from './context'
    
    const props = withDefaults(
      defineProps<{
        targetKeys?: 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
        class?: HTMLAttributes['class']
      }>(),
      {
        targetKeys: () => [],
        titles: () => ['Source', 'Target'],
        showSearch: false,
        height: 320,
        pagination: false,
        oneWay: false,
        disabled: false,
        draggable: false,
        selectable: true,
      },
    )
    
    const emits = defineEmits<{
      (e: 'update:targetKeys', keys: string[]): void
      (e: 'change', keys: string[], direction: 'left' | 'right', moved: string[]): void
      (e: 'search', payload: { direction: 'left' | 'right'; query: string }): void
      (e: 'select-change', payload: { left: string[]; right: string[] }): void
    }>()
    
    const dataMap = computed(() => new Map(props.dataSource.map((i) => [i.key, i])))
    
    const sourceItems = computed(() => props.dataSource.filter((i) => !props.targetKeys.includes(i.key)))
    
    // When draggable, target order follows targetKeys exactly so reorder persists.
    // Otherwise keep legacy dataSource ordering for backwards compat.
    const targetItems = computed<TransferItem[]>(() => {
      if (props.draggable) {
        const out: TransferItem[] = []
        for (const k of props.targetKeys) {
          const item = dataMap.value.get(k)
          if (item) out.push(item)
        }
        return out
      }
      return props.dataSource.filter((i) => props.targetKeys.includes(i.key))
    })
    
    const selectedLeft = ref<string[]>([])
    const selectedRight = ref<string[]>([])
    
    const pageSize = computed<number | null>(() => {
      if (props.pagination === false) return null
      if (props.pagination === true) return 10
      return props.pagination.pageSize
    })
    
    const defaultFilter = (q: string, item: TransferItem) => item.label.toLowerCase().includes(q.toLowerCase())
    
    const dragPayload = ref<TransferDragPayload | null>(null)
    
    function startDrag(payload: TransferDragPayload) {
      dragPayload.value = payload
    }
    
    function endDrag() {
      dragPayload.value = null
    }
    
    function drop(toSide: TransferSide, beforeKey: string | null) {
      const payload = dragPayload.value
      dragPayload.value = null
      if (!payload || props.disabled) return
      const keys = payload.keys.filter((k) => {
        const item = dataMap.value.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 (props.oneWay) return
        const removeSet = new Set(keys)
        const next = props.targetKeys.filter((k) => !removeSet.has(k))
        selectedRight.value = selectedRight.value.filter((k) => !removeSet.has(k))
        emits('update:targetKeys', next)
        emits('change', next, 'left', keys)
        emits('select-change', { left: selectedLeft.value, right: selectedRight.value })
        return
      }
    
      // → right: insert (cross-list move) or reorder (within-target).
      const movingSet = new Set(keys)
      const without = props.targetKeys.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)
        selectedLeft.value = selectedLeft.value.filter((k) => !movedSet.has(k))
        emits('update:targetKeys', next)
        emits('change', next, 'right', keys)
        emits('select-change', { left: selectedLeft.value, right: selectedRight.value })
      } else {
        // right → right: pure reorder, no change event (target set unchanged).
        emits('update:targetKeys', next)
      }
    }
    
    provide(TRANSFER_INJECTION_KEY, {
      disabled: toRef(props, 'disabled'),
      showSearch: toRef(props, 'showSearch'),
      height: toRef(props, 'height'),
      pageSize,
      filterFn: props.filterFn ?? defaultFilter,
      draggable: toRef(props, 'draggable'),
      selectable: toRef(props, 'selectable'),
      oneWay: toRef(props, 'oneWay'),
      dragPayload,
      startDrag,
      endDrag,
      drop,
    })
    
    function onLeftSelected(keys: string[]) {
      selectedLeft.value = keys
      emits('select-change', { left: keys, right: selectedRight.value })
    }
    
    function onRightSelected(keys: string[]) {
      selectedRight.value = keys
      emits('select-change', { left: selectedLeft.value, right: keys })
    }
    
    function moveRight() {
      if (selectedLeft.value.length === 0) return
      const next = [...props.targetKeys, ...selectedLeft.value]
      const moved = [...selectedLeft.value]
      selectedLeft.value = []
      emits('update:targetKeys', next)
      emits('change', next, 'right', moved)
      emits('select-change', { left: [], right: selectedRight.value })
    }
    
    function moveLeft() {
      if (selectedRight.value.length === 0) return
      const remove = new Set(selectedRight.value)
      const next = props.targetKeys.filter((k) => !remove.has(k))
      const moved = [...selectedRight.value]
      selectedRight.value = []
      emits('update:targetKeys', next)
      emits('change', next, 'left', moved)
      emits('select-change', { left: selectedLeft.value, right: [] })
    }
    </script>
    
    <template>
      <div :class="cn('flex items-stretch gap-3', props.class)" data-uipkge data-slot="transfer">
        <div class="min-w-0 flex-1">
          <TransferList
            side="left"
            :title="titles[0]"
            :items="sourceItems"
            :selected="selectedLeft"
            @update:selected="onLeftSelected"
            @search="emits('search', { direction: 'left', query: $event })"
          >
            <template v-if="$slots['footer-left']" #footer>
              <slot name="footer-left" />
            </template>
          </TransferList>
        </div>
        <TransferOperation
          :can-move-right="selectedLeft.length > 0 && !disabled"
          :can-move-left="selectedRight.length > 0 && !disabled"
          :one-way="oneWay"
          @move-right="moveRight"
          @move-left="moveLeft"
        />
        <div class="min-w-0 flex-1">
          <TransferList
            side="right"
            :title="titles[1]"
            :items="targetItems"
            :selected="selectedRight"
            @update:selected="onRightSelected"
            @search="emits('search', { direction: 'right', query: $event })"
          >
            <template v-if="$slots['footer-right']" #footer>
              <slot name="footer-right" />
            </template>
          </TransferList>
        </div>
      </div>
    </template>
  • app/components/ui/transfer/TransferList.vue 12 kB
    <script setup lang="ts">
    import { computed, inject, ref, watch } from 'vue'
    import { GripVertical, Search } from 'lucide-vue-next'
    import { Checkbox } from '@/components/ui/checkbox'
    import { Input } from '@/components/ui/input'
    import { ScrollArea } from '@/components/ui/scroll-area'
    import { TRANSFER_INJECTION_KEY, type TransferItem, type TransferSide } from './context'
    
    const props = defineProps<{
      side: TransferSide
      title: string
      items: TransferItem[]
      selected: string[]
    }>()
    
    const emits = defineEmits<{
      (e: 'update:selected', keys: string[]): void
      (e: 'search', query: string): void
    }>()
    
    const _maybeCtx = inject(TRANSFER_INJECTION_KEY, null)
    if (!_maybeCtx) throw new Error('TransferList must be used inside <Transfer>.')
    const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx
    
    const query = ref('')
    const page = ref(1)
    
    const filtered = computed(() => {
      if (!query.value) return props.items
      return props.items.filter((i) => ctx.filterFn(query.value, i))
    })
    
    const effectivePageSize = computed(() => ctx.pageSize.value ?? Math.max(1, filtered.value.length))
    const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / effectivePageSize.value)))
    
    const visible = computed(() => {
      if (!ctx.pageSize.value) return filtered.value
      const start = (page.value - 1) * effectivePageSize.value
      return filtered.value.slice(start, start + effectivePageSize.value)
    })
    
    watch(filtered, () => {
      if (page.value > totalPages.value) page.value = totalPages.value
    })
    
    const visibleEnabledKeys = computed(() => visible.value.filter((i) => !i.disabled).map((i) => i.key))
    const selectedSet = computed(() => new Set(props.selected))
    
    const visibleSelectedCount = computed(() => visibleEnabledKeys.value.filter((k) => selectedSet.value.has(k)).length)
    
    const masterChecked = computed(
      () => visibleEnabledKeys.value.length > 0 && visibleSelectedCount.value === visibleEnabledKeys.value.length,
    )
    
    const masterIndeterminate = computed(
      () => visibleSelectedCount.value > 0 && visibleSelectedCount.value < visibleEnabledKeys.value.length,
    )
    
    function toggleAll(checked: boolean) {
      let next = [...props.selected]
      if (checked) {
        for (const k of visibleEnabledKeys.value) {
          if (!selectedSet.value.has(k)) next.push(k)
        }
      } else {
        next = next.filter((k) => !visibleEnabledKeys.value.includes(k))
      }
      emits('update:selected', next)
    }
    
    function toggleItem(item: TransferItem, checked: boolean) {
      if (item.disabled || ctx.disabled.value) return
      let next = [...props.selected]
      if (checked) {
        if (!next.includes(item.key)) next.push(item.key)
      } else {
        next = next.filter((k) => k !== item.key)
      }
      emits('update:selected', next)
      lastAnchor.value = item.key
    }
    
    const lastAnchor = ref<string | null>(null)
    
    function onRowClick(e: MouseEvent, item: TransferItem) {
      if (item.disabled || ctx.disabled.value) return
      // With checkbox visible, click toggles (matches checkbox UX).
      if (ctx.selectable.value) {
        toggleItem(item, !selectedSet.value.has(item.key))
        return
      }
      // No checkbox: desktop list pattern — plain=replace, cmd/ctrl=toggle, shift=range.
      const enabledKeys = visible.value.filter((i) => !i.disabled).map((i) => i.key)
      if (e.shiftKey && lastAnchor.value && enabledKeys.includes(lastAnchor.value)) {
        const start = enabledKeys.indexOf(lastAnchor.value)
        const end = enabledKeys.indexOf(item.key)
        const [lo, hi] = start < end ? [start, end] : [end, start]
        emits('update:selected', enabledKeys.slice(lo, hi + 1))
        return
      }
      if (e.metaKey || e.ctrlKey) {
        let next = [...props.selected]
        if (selectedSet.value.has(item.key)) next = next.filter((k) => k !== item.key)
        else next.push(item.key)
        emits('update:selected', next)
        lastAnchor.value = item.key
        return
      }
      emits('update:selected', [item.key])
      lastAnchor.value = item.key
    }
    
    function onSearch(v: string) {
      query.value = v
      page.value = 1
      emits('search', v)
    }
    
    function prevPage() {
      if (page.value > 1) page.value--
    }
    
    function nextPage() {
      if (page.value < totalPages.value) page.value++
    }
    
    const heightStyle = computed(() => ({
      height: typeof ctx.height.value === 'number' ? ctx.height.value + 'px' : ctx.height.value,
    }))
    
    // ----- DnD -----
    
    const dropIndicator = ref<{ key: string; position: 'before' | 'after' } | null>(null)
    const draggingKeys = ref<Set<string>>(new Set())
    
    const isDropTarget = computed(() => {
      const p = ctx.dragPayload.value
      if (!p) return false
      // left list rejects drops when oneWay
      if (props.side === 'left' && ctx.oneWay.value && p.fromSide === 'right') return false
      // left → left is a no-op (parent owns dataSource order)
      if (props.side === 'left' && p.fromSide === 'left') return false
      return true
    })
    
    function onItemDragStart(e: DragEvent, item: TransferItem) {
      if (!ctx.draggable.value || item.disabled || ctx.disabled.value) {
        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.value.has(item.key)
        ? props.selected.filter((k) => {
            const i = props.items.find((x) => x.key === k)
            return i && !i.disabled
          })
        : [item.key]
      draggingKeys.value = new Set(keys)
      ctx.startDrag({ keys, fromSide: props.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() {
      draggingKeys.value = new Set()
      dropIndicator.value = null
      ctx.endDrag()
    }
    
    function onItemDragOver(e: DragEvent, item: TransferItem) {
      if (!isDropTarget.value) return
      e.preventDefault()
      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
      if (props.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
      dropIndicator.value = { key: item.key, position: after ? 'after' : 'before' }
    }
    
    function onItemDrop(e: DragEvent, item: TransferItem) {
      if (!isDropTarget.value) return
      e.preventDefault()
      e.stopPropagation()
      if (props.side === 'right') {
        const after = dropIndicator.value?.position === 'after'
        const idx = props.items.findIndex((x) => x.key === item.key)
        const beforeKey = after ? (props.items[idx + 1]?.key ?? null) : item.key
        ctx.drop('right', beforeKey)
      } else {
        ctx.drop('left', null)
      }
      dropIndicator.value = null
    }
    
    function onListDragOver(e: DragEvent) {
      if (!isDropTarget.value) return
      e.preventDefault()
      if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
    }
    
    function onListDrop(e: DragEvent) {
      if (!isDropTarget.value) return
      e.preventDefault()
      // Empty zone or below all items → append (right) / remove (left).
      ctx.drop(props.side, null)
      dropIndicator.value = null
    }
    
    function onListDragLeave(e: 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)) {
        dropIndicator.value = null
      }
    }
    </script>
    
    <template>
      <div
        class="bg-card flex flex-col overflow-hidden rounded-md border transition-colors"
        :class="[ctx.dragPayload.value && isDropTarget && 'ring-ring/40 ring-1']"
      >
        <div class="bg-muted/40 flex items-center justify-between gap-2 border-b px-3 py-2">
          <div class="flex min-w-0 items-center gap-2">
            <Checkbox
              v-if="ctx.selectable.value"
              :model-value="masterIndeterminate ? 'indeterminate' : masterChecked"
              :disabled="ctx.disabled.value || visibleEnabledKeys.length === 0"
              @update:model-value="toggleAll($event === true)"
            />
            <span class="truncate text-sm font-medium">{{ title }}</span>
          </div>
          <span class="text-muted-foreground text-xs tabular-nums"> {{ selected.length }}/{{ items.length }} </span>
        </div>
    
        <div v-if="ctx.showSearch.value" class="border-b p-2">
          <div class="relative">
            <Search
              class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2"
              aria-hidden="true"
            />
            <Input
              :model-value="query"
              placeholder="Search"
              class="h-8 pl-8"
              @update:model-value="(v) => onSearch(String(v ?? ''))"
            />
          </div>
        </div>
    
        <ScrollArea
          :style="heightStyle"
          class="flex-1"
          @dragover="onListDragOver"
          @drop="onListDrop"
          @dragleave="onListDragLeave"
        >
          <ul role="listbox" aria-multiselectable="true" class="py-1">
            <li
              v-for="item in visible"
              :key="item.key"
              role="option"
              :aria-selected="selectedSet.has(item.key)"
              :draggable="ctx.draggable.value && !item.disabled && !ctx.disabled.value"
              :class="[
                '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.value && selectedSet.has(item.key) && 'bg-accent',
              ]"
              @click="(e) => onRowClick(e, item)"
              @dragstart="(e) => onItemDragStart(e, item)"
              @dragend="onItemDragEnd"
              @dragover="(e) => onItemDragOver(e, item)"
              @drop="(e) => onItemDrop(e, item)"
            >
              <span
                v-if="
                  dropIndicator && dropIndicator.key === item.key && dropIndicator.position === 'before' && side === 'right'
                "
                class="bg-primary pointer-events-none absolute -top-px right-2 left-2 h-0.5 rounded-full"
                aria-hidden="true"
              />
              <span
                v-if="
                  dropIndicator && dropIndicator.key === item.key && dropIndicator.position === 'after' && side === 'right'
                "
                class="bg-primary pointer-events-none absolute right-2 -bottom-px left-2 h-0.5 rounded-full"
                aria-hidden="true"
              />
              <Checkbox
                v-if="ctx.selectable.value"
                :model-value="selectedSet.has(item.key)"
                :disabled="item.disabled || ctx.disabled.value"
                @update:model-value="toggleItem(item, $event === true)"
                @click.stop
              />
              <div class="min-w-0 flex-1">
                <div class="truncate">{{ item.label }}</div>
                <div v-if="item.description" class="text-muted-foreground truncate text-xs">
                  {{ item.description }}
                </div>
              </div>
              <GripVertical
                v-if="ctx.draggable.value && !item.disabled"
                class="text-muted-foreground/60 mt-0.5 size-3.5 shrink-0"
                aria-hidden="true"
              />
            </li>
            <li v-if="visible.length === 0" class="text-muted-foreground px-3 py-6 text-center text-sm">No items</li>
          </ul>
        </ScrollArea>
    
        <div
          v-if="ctx.pageSize.value && totalPages > 1"
          class="flex items-center justify-center gap-2 border-t p-2 text-xs"
        >
          <button
            type="button"
            class="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"
            @click="prevPage"
          >
            Prev
          </button>
          <span class="tabular-nums">{{ page }} / {{ totalPages }}</span>
          <button
            type="button"
            class="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"
            @click="nextPage"
          >
            Next
          </button>
        </div>
    
        <div v-if="$slots.footer" class="border-t p-2">
          <slot name="footer" />
        </div>
      </div>
    </template>
  • app/components/ui/transfer/TransferOperation.vue 0.9 kB
    <script setup lang="ts">
    import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
    import { Button } from '@/components/ui/button'
    
    defineProps<{
      canMoveRight: boolean
      canMoveLeft: boolean
      oneWay?: boolean
    }>()
    
    defineEmits<{
      (e: 'move-right'): void
      (e: 'move-left'): void
    }>()
    </script>
    
    <template>
      <div class="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"
          @click="$emit('move-right')"
        >
          <ChevronRight aria-hidden="true" />
        </Button>
        <Button
          v-if="!oneWay"
          size="icon-sm"
          variant="outline"
          :disabled="!canMoveLeft"
          aria-label="Move selected to left"
          @click="$emit('move-left')"
        >
          <ChevronLeft aria-hidden="true" />
        </Button>
      </div>
    </template>
  • app/components/ui/transfer/context.ts 0.8 kB
    import type { InjectionKey, Ref } from 'vue'
    
    export interface TransferItem {
      key: string
      label: string
      description?: string
      disabled?: boolean
    }
    
    export type TransferSide = 'left' | 'right'
    
    export interface TransferDragPayload {
      keys: string[]
      fromSide: TransferSide
    }
    
    export interface TransferContext {
      disabled: Ref<boolean>
      showSearch: Ref<boolean>
      height: Ref<number | string>
      pageSize: Ref<number | null>
      filterFn: (query: string, item: TransferItem) => boolean
      draggable: Ref<boolean>
      selectable: Ref<boolean>
      oneWay: Ref<boolean>
      dragPayload: Ref<TransferDragPayload | null>
      startDrag: (payload: TransferDragPayload) => void
      endDrag: () => void
      drop: (toSide: TransferSide, beforeKey: string | null) => void
    }
    
    export const TRANSFER_INJECTION_KEY: InjectionKey<TransferContext> = Symbol('uipkge-transfer')
  • app/components/ui/transfer/index.ts 0.2 kB
    export type { TransferItem } from './context'
    export { default as Transfer } from './Transfer.vue'
    export { default as TransferList } from './TransferList.vue'
    export { default as TransferOperation } from './TransferOperation.vue'

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