Transfer
Vue data-displayDual-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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/transfer.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/transfer.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/transfer.json$ bunx 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