
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
transferuiDual-list move-between control. Two columns plus a center pair of move buttons. Optional search, pagination, and one-way mode.
Also available for React ->$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.jsonnpx shadcn-vue@latest add @uipkge/transferInstalls to:app/components/ui/transfer/| Name | Type / Values | Default | Required |
|---|---|---|---|
targetKeys | string[] | — | optional |
dataSource | TransferItem[] | — | required |
titles | [string, string] | — | optional |
showSearch | boolean | — | optional |
filterFn | (query: string, item: TransferItem) | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
TransferIteminterface TransferItem {
key: string
label: string
description?: string
disabled?: boolean
}TransferDragPayloadinterface TransferDragPayload {
keys: string[]
fromSide: TransferSide
}TransferContextinterface 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
/** Move left→right. Optional keys override the current left selection. */
moveRight: (keys?: string[]) => void
/** Move right→left. Optional keys override the current right selection. */
moveLeft: (keys?: string[]) => void
}<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)
}
}
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(keys?: string[]) {
if (props.disabled) return
const moved = keys?.length ? [...keys] : [...selectedLeft.value]
if (moved.length === 0) return
const next = [...props.targetKeys, ...moved.filter((k) => !props.targetKeys.includes(k))]
selectedLeft.value = selectedLeft.value.filter((k) => !moved.includes(k))
emits('update:targetKeys', next)
emits('change', next, 'right', moved)
emits('select-change', { left: selectedLeft.value, right: selectedRight.value })
}
function moveLeft(keys?: string[]) {
if (props.disabled || props.oneWay) return
const moved = keys?.length ? [...keys] : [...selectedRight.value]
if (moved.length === 0) return
const remove = new Set(moved)
const next = props.targetKeys.filter((k) => !remove.has(k))
selectedRight.value = selectedRight.value.filter((k) => !remove.has(k))
emits('update:targetKeys', next)
emits('change', next, 'left', moved)
emits('select-change', { left: selectedLeft.value, right: selectedRight.value })
}
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,
moveRight,
moveLeft,
})
</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>
<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 getOptionRows(from: HTMLElement): HTMLElement[] {
const list = from.closest('[role="listbox"]')
if (!list) return []
return Array.from(list.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])'))
}
function focusOption(el: HTMLElement | null | undefined) {
el?.focus()
}
function onRowKeydown(e: KeyboardEvent, item: TransferItem) {
const target = e.currentTarget as HTMLElement
// Arrow navigation within the list.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const rows = getOptionRows(target)
const idx = rows.indexOf(target)
if (idx < 0) return
focusOption(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1])
return
}
if (e.key === 'Home') {
e.preventDefault()
focusOption(getOptionRows(target)[0])
return
}
if (e.key === 'End') {
e.preventDefault()
const rows = getOptionRows(target)
focusOption(rows[rows.length - 1])
return
}
// Keys to transfer: current multi-selection, or just the focused item.
function keysToTransfer(): string[] {
if (selectedSet.value.has(item.key) && props.selected.length > 0) {
return props.selected.filter((k) => {
const i = props.items.find((x) => x.key === k)
return i && !i.disabled
})
}
return item.disabled ? [] : [item.key]
}
// Ctrl/Cmd+Enter transfers selected items (or the focused item if none selected).
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (item.disabled || ctx.disabled.value) return
const keys = keysToTransfer()
if (!keys.length) return
if (props.side === 'left') ctx.moveRight(keys)
else if (!ctx.oneWay.value) ctx.moveLeft(keys)
return
}
// Alt+Arrow transfers without redesigning the button strip.
if (e.key === 'ArrowRight' && e.altKey && props.side === 'left') {
e.preventDefault()
if (item.disabled || ctx.disabled.value) return
const keys = keysToTransfer()
if (keys.length) ctx.moveRight(keys)
return
}
if (e.key === 'ArrowLeft' && e.altKey && props.side === 'right' && !ctx.oneWay.value) {
e.preventDefault()
if (item.disabled || ctx.disabled.value) return
const keys = keysToTransfer()
if (keys.length) ctx.moveLeft(keys)
return
}
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
if (item.disabled || ctx.disabled.value) return
if (ctx.selectable.value) {
toggleItem(item, !selectedSet.value.has(item.key))
return
}
// Keyboard without modifiers: toggle single-item selection (desktop replace).
emits('update:selected', selectedSet.value.has(item.key) && props.selected.length === 1 ? [] : [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"
:aria-label="`Select all in ${title}`"
@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"
:aria-label="`Search ${title}`"
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-label="title" aria-multiselectable="true" class="py-1">
<li
v-for="item in visible"
:key="item.key"
role="option"
:aria-selected="selectedSet.has(item.key)"
:aria-disabled="item.disabled || undefined"
:tabindex="item.disabled || ctx.disabled.value ? -1 : 0"
: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)"
@keydown="(e) => onRowKeydown(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"
:aria-label="`Previous page of ${title}`"
@click="prevPage"
>
Prev
</button>
<span class="tabular-nums" aria-live="polite">{{ 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"
:aria-label="`Next page of ${title}`"
@click="nextPage"
>
Next
</button>
</div>
<div v-if="$slots.footer" class="border-t p-2">
<slot name="footer" />
</div>
</div>
</template>
<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>
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
/** Move left→right. Optional keys override the current left selection. */
moveRight: (keys?: string[]) => void
/** Move right→left. Optional keys override the current right selection. */
moveLeft: (keys?: string[]) => void
}
export const TRANSFER_INJECTION_KEY: InjectionKey<TransferContext> = Symbol('uipkge-transfer')
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