Transfer / Dual List
ProDual-list transfer widget for moving items between a source and target panel, with per-panel search, check-all and move-all controls.
Also belongs under Forms & Inputs — it is catalogued in Data Display and stays there.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvTransferComponent } from '@styloviz-pro/display';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Without search
ProPersisting a move, and putting it back
ProA pane that loads from the server
ProWhen to use
- Moving items between two explicit lists: assign members, pick columns, build playlists.
- You want per-panel search and move-all / check-all controls.
- For ordering within one list, use the Drag-and-Drop List.
Async states
- loading
- error
- retry
Item identity & owned state
- Restorable state
- sourceChecked
- targetChecked
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Server writes
- Writes
- transferChange
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
saving— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
moveErrorto show it in place — without taking away what the user was working on.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| sourceItems | TransferItem[] | [] | Items in the source (left) panel. |
| targetItems | TransferItem[] | [] | Items in the target (right) panel. |
| sourceTitle | string | 'Source' | Source panel heading. |
| targetTitle | string | 'Target' | Target panel heading. |
| searchable | boolean | true | Show per-panel search fields. |
| disabled | boolean | false | Disabled state — no interactions possible. |
| serverSide | boolean | false | Let the server do the searching. The supplied items are shown exactly as given — client-side search can only ever look at the page already loaded. |
| debounceMs | number | 300 | Delay before a search event fires, in ms. `0` emits immediately. |
| loadingSource | boolean | false | True while the source pane is being fetched. |
| loadingTarget | boolean | false | True while the target pane is being fetched. |
| sourceError | SvDataError | string | null | null | Failure loading the source pane, rendered in its place. |
| targetError | SvDataError | string | null | null | Failure loading the target pane, rendered in its place. |
| saving | boolean | false | True while a move is being written. Every move button and every tick stops responding, so a second move cannot race the first and leave `rollback()` holding an arrangement that predates it. |
| moveError | SvDataError | string | null | null | The last MOVE failed, shown above both panes. Spelled `moveError` rather than `error` because `sourceError` and `targetError` are already here and mean something else: *that pane would not load*. Those replace a pane and offer a retry, because a failed fetch leaves nothing to press. A failed move leaves both panes full and the buttons where they were, so it is reported and not retryable — you retry by pressing the arrow again. Overloading one input with both meanings is what the split exists to stop. |
| sourceChecked | Set<string> (two-way) | new Set() | Which source-pane items are ticked (two-way). A `model` because it is the state the two transfer buttons act on, so a host driving the component — preselecting a saved set, or enabling its own "move" control — has to be able to both write it and watch it. Held in a plain signal it was readable only from inside the template. |
| targetChecked | Set<string> (two-way) | new Set() | Which target-pane items are ticked (two-way). Same reasoning as sourceChecked. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| transferChange | TransferChangeEvent | Emitted whenever items are transferred. |
| sourceSearchChange | string | The source pane's search term changed — fetch matching items. Debounced. |
| targetSearchChange | string | The target pane's search term changed — fetch matching items. Debounced. |
| retrySource | void | The host should re-run the failed source load. |
| retryTarget | void | The host should re-run the failed target load. |
| sourceCheckedChange | Set<string> | Emits the new `sourceChecked` whenever it changes — the output half of `[(sourceChecked)]`. |
| targetCheckedChange | Set<string> | Emits the new `targetChecked` whenever it changes — the output half of `[(targetChecked)]`. |
Customization & theming
- Label each side with `sourceTitle` / `targetTitle`.
- Enable `searchable` for per-panel filtering.
- Read moved items from the `transferChange` event; replacing the `sourceItems`/`targetItems` set re-initialises the panels while in-progress moves survive unrelated re-renders.
- For a pane too large to hold in memory, set `serverSide` and drive it from `sourceSearchChange` (debounced by `debounceMs`) plus `loadingSource` / `sourceError`.
- `sourceChecked` / `targetChecked` are two-way, so a host can preselect a saved set or drive its own move control.
- `moveError` accepts a bare string or a structured `SvDataError`, and renders above both panes rather than replacing either.
Best practices
- Do: Label both panels clearly so source vs target is unambiguous.
- Do: Enable `searchable` once either panel can hold many items.
- Do: Bind `saving` while a move is being written. Every arrow and tick locks, so a second move cannot overtake the first and leave `rollback()` holding an arrangement that predates it. Search deliberately stays live.
- Do: Call `event.rollback()` when the write fails. The panes rearrange the instant an arrow is pressed, so the component holds the only copy of the previous arrangement — including the ticks the move cleared.
- Do: Send `event.requestKey` and ignore a key you have already applied, so a retried request cannot grant the same item twice.
- Do: Move your own state only on success. A rejected move is rolled back inside the component, so writing `event.source` / `event.target` into your signals regardless would make the failure stick.
- Don't: Do not use `moveError` for a failed fetch — `sourceError` / `targetError` replace their pane and offer a retry, because a failed load leaves nothing to press. A failed move leaves both panes full.
- Don't: Do not expect the panes to wait for the server before moving. A dual list that pauses on a round trip reads as a broken control.
- Tip: Read `event.moved` and `event.direction` instead of diffing the two panes — a host persisting one grant at a time needs exactly those.
- Tip: Pass stable array references; swapping in a new item set (e.g. after an async load) re-syncs the panels.
- Do: Read the check-all contract before wiring your own: it covers the **filtered** set, never the whole loaded list. The pane discloses any ticks the search is hiding, and a move takes those too.
- Do: Handle `transferChange.moved` rather than diffing the panes — it is already the skipped-items-excluded list, and a disabled item is never in it.
Accessibility
- Keyboard at scale (X-10): each pane is ONE tab stop over its item checkboxes: ↑/↓/Home/End, PageUp/PageDown (ten items, clamped) and typeahead over labels move within the pane, while ←/→ keep their meaning — move the checked items across. Documented ceiling: 2,000 items in a pane, past which a dev-mode warning fires.
- Each panel is a labelled group of individually-named checkboxes; the header check-all reflects a tri-state (indeterminate) when partially selected.
- The check-all box **names its own scope** — "Select all 2 matching in Available" while a search is running, "Select all 5,000 in Available" when it is not — so the contract is legible before the click rather than inferable after it.
- Move buttons are labelled with the count and the keyboard shortcut ("Move 2 selected to Selected. Right arrow."); the arrow glyphs are hidden from assistive tech.
- Right and Left arrow move the ticked items from inside the list, and Ctrl/⌘ with them moves everything shown — so a keyboard user never has to tab out to the control column between the panes and back for each item. Space and Enter are left to the checkbox under the cursor.
- A polite live region announces each transfer, and the same sentence is **rendered** under the panes — a move that skipped something has to say so to everyone, and the most likely reading of an item that stayed put is that the click missed. "Move undone." on a rollback, so the jump is not silent.
- While `saving`, the move buttons carry `aria-busy="true"` — in flight, not merely switched off.
- A failed move is announced through `role="alert"` above both panes.
- A selection the current search is hiding is disclosed in the pane header — "2 more selected, hidden by this search — a move takes 4 in total" — because ticks survive a search, and without it the header reads "all selected" over the visible rows while a move takes every hidden one too.
Feedback on Transfer / Dual List
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.