Transfer
Move and filter items between two lists
Usage
Filterable transfer
When filterable is enabled, both panels show a filter input. Use emptyText to localize the empty state.
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.
Best Practices
- Keep
modelValuecontrolled by the host and store only item keys, not full item objects. - Keep every
data[].keyunique and stable across filtering; duplicate keys make source/target membership ambiguous. - Use
targetOrder="original"when the target list should mirror source data order, andtargetOrder="push"when selection order is part of the workflow. - Turn on
orderablewhen the user has to rank the target list by hand (priority, fallback chains), and settargetOrder="push"to state the intent; the component then honoursmodelValueorder only. - Keep disabled rows in
datainstead of filtering them out, so users can distinguish unavailable resources from missing resources. - Provide explicit
addAriaLabelandremoveAriaLabelwhenever the action buttons are icon-only, plusmoveUpAriaLabelandmoveDownAriaLabelonceorderableis on, andselectAllAriaLabelin a localized UI. - Use
filterableonly 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
maxHeightto 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
minHeightwhenever the transfer sits somewhere compact. - Pass a tuple to
emptyTextwhen 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
| Property | Type | Default | Description |
|---|---|---|---|
| modelValue | Keys shown in the target list | ||
| data | Complete item list with key, label, and optional disabled | ||
| titles | Source and target panel titles | ||
| filterable | Whether to show filter inputs | ||
| filterPlaceholder | Placeholder for the filter input | ||
| emptyText | Text shown when a panel is empty; pass a tuple for per-panel copy | ||
| maxHeight | Panel height cap; numbers are treated as px, or set --tx-transfer-max-height directly | ||
| minHeight | Panel height floor; numbers are treated as px, or set --tx-transfer-min-height directly | ||
| addAriaLabel | Accessible label for the add action button | ||
| removeAriaLabel | Accessible label for the remove action button | ||
| selectAllAriaLabel | Accessible label for each panel's select-all box; the panel title is appended | ||
| moveUpAriaLabel | Accessible label for the move-up button; the row label is appended | ||
| moveDownAriaLabel | Accessible label for the move-down button; the row label is appended | ||
| targetOrder | Sort target items by original data order or append order; ignored once `orderable` is on | ||
| orderable | Target 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.
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | Array<string | number> | Selected keys update |
change | Array<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 frommodelValueand is independent of the filter.
Technologies
- Source:
packages/tuffex/packages/components/src/transfer/src/TxTransfer.vueconfirms source/target derivation, filter matching, disabled item checks,targetOrder, and icon-only action labels. - Type contracts:
packages/tuffex/packages/components/src/transfer/src/types.tsexportsTransferItem,TransferProps, andTransferEmits. - Height contract: panels read
--tx-transfer-max-height(default320px) and--tx-transfer-min-height(default240px); the props only write those variables. The cap has to exist — with no cap the panel grows with its content,.tx-transfer__list'soverflow: autonever 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, notword-break: break-all.break-allbroke at whatever character ran out of room, so "Quick actions" wrapped as "Quick actio / ns";anywheretakes 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 siblingtypes.tsonce 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 builtdiststay correct. - Ordering contract: with
orderable, bothtargetItemsandresolveOrdershort-circuittargetOrder='original'. Changing only the render order and leavingresolveOrderre-deriving fromdatawas 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 unfilteredmodelValue; 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.tscovers 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,maxHeightandminHeightvariable injection (separately and together), andorderablerank 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.