Components/Transfer

Transfer

Move and filter items between two lists

VerifiedSince 0.3.4

Usage

Filterable transfer

When filterable is enabled, both panels show a filter input. Use emptyText to localize the empty state.

Loading demo...

Ranked target list

orderable turns the target panel into an ordered list: every row shows its rank and carries move up / move down. The rank is read from modelValue, so filtering never shifts it, and the target order is the modelValue order — targetOrder no longer applies. maxHeight caps each panel so a long list scrolls inside the list instead of stretching the surrounding dialog; minHeight is the matching floor, which a compact host needs to lower.

Loading demo...

Best Practices

  • Keep modelValue controlled by the host and store only item keys, not full item objects.
  • Keep every data[].key unique and stable across filtering; duplicate keys make source/target membership ambiguous.
  • Use targetOrder="original" when the target list should mirror source data order, and targetOrder="push" when selection order is part of the workflow.
  • Turn on orderable when the user has to rank the target list by hand (priority, fallback chains), and set targetOrder="push" to state the intent; the component then honours modelValue order only.
  • Keep disabled rows in data instead of filtering them out, so users can distinguish unavailable resources from missing resources.
  • Provide explicit addAriaLabel and removeAriaLabel whenever the action buttons are icon-only, plus moveUpAriaLabel and moveDownAriaLabel once orderable is on, and selectAllAriaLabel in a localized UI.
  • Use filterable only for medium or large lists; short permission lists are faster to scan without two filter inputs.
  • Do not add your own "select all" control outside the component: each panel header already has one, and it is correctly scoped to the filtered rows.
  • Panels cap at 320px by default. Inside a dialog or drawer, set maxHeight to match the container (e.g. min(56dvh, 520px)) so scrolling always happens inside the list rather than growing the dialog.
  • Panels also have a 240px floor, which is a hard one: in a box shorter than that the panel keeps its own height and spills past the container. Lower minHeight whenever the transfer sits somewhere compact.
  • Pass a tuple to emptyText when the two sides mean different things ("nothing left to assign" vs "pick at least one to enable this"), instead of reusing one sentence.

API Reference

Props

PropertyTypeDefaultDescription
modelValueKeys shown in the target list
dataComplete item list with key, label, and optional disabled
titlesSource and target panel titles
filterableWhether to show filter inputs
filterPlaceholderPlaceholder for the filter input
emptyTextText shown when a panel is empty; pass a tuple for per-panel copy
maxHeightPanel height cap; numbers are treated as px, or set --tx-transfer-max-height directly
minHeightPanel height floor; numbers are treated as px, or set --tx-transfer-min-height directly
addAriaLabelAccessible label for the add action button
removeAriaLabelAccessible label for the remove action button
selectAllAriaLabelAccessible label for each panel's select-all box; the panel title is appended
moveUpAriaLabelAccessible label for the move-up button; the row label is appended
moveDownAriaLabelAccessible label for the move-down button; the row label is appended
targetOrderSort target items by original data order or append order; ignored once `orderable` is on
orderableTarget panel shows ranks and move up/down; order follows modelValue

Permission Resource Grants

In admin authorization flows, TxTransfer should only own controlled movement between assignable and granted resources. Let TxTree select the permission scope, TxTreeSelect select the owner team, and TxTimeline show audit progress so every component keeps one clear responsibility.

Permission orchestration panel

A screenshot-verified resource grant transfer.

Loading demo...

Events

EventPayloadDescription
update:modelValueArray<string | number>Selected keys update
changeArray<string | number>Selected keys changed

Slots

TxTransfer does not expose custom slots. Render labels through data[].label, localize panel headings through titles, and localize empty/filter/action copy through props.

Overview

  • Each panel header carries a select-all box scoped to the rows that are currently visible and enabled: with a filter applied it only touches the filtered rows rather than checking items the user cannot see. A partial selection reports aria-checked="mixed".
  • There are two ways to move a row: check it and press the middle button, or double-click the row. Double-clicking a disabled row does nothing.
  • The middle buttons carry a border and fill, turn primary once something is checked, and show how many rows are pending. Icon-only borderless buttons between two bordered panels read as decoration, so pressing them looks like nothing happened.
  • With orderable, target rows show a rank plus move up / move down. The rank comes from modelValue and is independent of the filter.

Technologies

View source
packages/tuffex/packages/components/src/transfer/index.ts
  • Source: packages/tuffex/packages/components/src/transfer/src/TxTransfer.vue confirms source/target derivation, filter matching, disabled item checks, targetOrder, and icon-only action labels.
  • Type contracts: packages/tuffex/packages/components/src/transfer/src/types.ts exports TransferItem, TransferProps, and TransferEmits.
  • Height contract: panels read --tx-transfer-max-height (default 320px) and --tx-transfer-min-height (default 240px); the props only write those variables. The cap has to exist — with no cap the panel grows with its content, .tx-transfer__list's overflow: auto never triggers, and the surrounding page or dialog becomes the scroll container. The floor used to be hardcoded, which made it unbeatable: a transfer inside a 190px box still laid out at 240px and overflowed it.
  • Row labels wrap with overflow-wrap: anywhere, not word-break: break-all. break-all broke at whatever character ran out of room, so "Quick actions" wrapped as "Quick actio / ns"; anywhere takes the space first and only splits a word that genuinely cannot fit.
  • Props are declared as a runtime object, not defineProps<TransferProps>(). The SFC compiler resolves the sibling types.ts once and does not redo it when that file changes, so a prop added to the interface ships as an unknown attribute while both vitest and the built dist stay correct.
  • Ordering contract: with orderable, both targetItems and resolveOrder short-circuit targetOrder='original'. Changing only the render order and leaving resolveOrder re-deriving from data was tried first: every move was re-sorted away on the next emit, which reads as "the button does nothing". Rank and first/last checks come from the unfiltered modelValue; using the filtered index gets both the rank and the disabled state wrong while a filter is applied.
  • Verified coverage: Coverage: packages/tuffex/packages/components/src/transfer/__tests__/transfer.test.ts covers checked item movement, double-click single-row movement and the disabled-row refusal, select-all (skipping disabled rows, filter scoping, mixed reporting, and clearing on a second press), custom empty text (including per-panel copy), accessible action labels, maxHeight and minHeight variable injection (separately and together), and orderable rank rendering, move up/down, boundary disabling, and rank/bounds under an active filter.
  • Screenshot: .codex-screenshots/nexus-transfer-permission-orchestration-demo-playwright-2026-05-28.png.