Skip to content
ShineUI
Install

PickList

Available on the left, chosen on the right.

<sui-pick-list>DataStable

About

Two-pane transfer list, available first and chosen second in reading order, for column choosers and report builders. Rows cross by the transfer controls, by pointer drag between or within the panes, or by a keyboard lift. Drag is never the only path; dragOnly hides the controls but the keyboard lift stays.

Usage

<sui-pick-list source-heading="Available columns" target-heading="Shown" height="200"></sui-pick-list> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-pick-list"); el.source = [ { value: "terms", label: "Terms" }, { value: "memo", label: "Memo" }, { value: "aging-bucket", label: "Aging bucket" }, { value: "sales-rep", label: "Sales rep" }, ]; /* SuiReorderItem[] */ el.target = [ { value: "invoice-", label: "Invoice #" }, { value: "customer", label: "Customer" }, { value: "amount", label: "Amount" }, ]; /* SuiReorderItem[] */ el.addEventListener("suiSelectionChange", (e) => console.log(e.detail)); </script>
import { SuiPickList } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const source = [ { value: "terms", label: "Terms" }, { value: "memo", label: "Memo" }, { value: "aging-bucket", label: "Aging bucket" }, { value: "sales-rep", label: "Sales rep" }, ]; /* SuiReorderItem[] */ const target = [ { value: "invoice-", label: "Invoice #" }, { value: "customer", label: "Customer" }, { value: "amount", label: "Amount" }, ]; /* SuiReorderItem[] */ return <SuiPickList sourceHeading="Available columns" targetHeading="Shown" height="200" source={source} target={target} onSuiSelectionChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiPickList } from "@shineui/vue"; import "@shineui/core/shineui.css"; const source = [ { value: "terms", label: "Terms" }, { value: "memo", label: "Memo" }, { value: "aging-bucket", label: "Aging bucket" }, { value: "sales-rep", label: "Sales rep" }, ]; // SuiReorderItem[] const target = [ { value: "invoice-", label: "Invoice #" }, { value: "customer", label: "Customer" }, { value: "amount", label: "Amount" }, ]; // SuiReorderItem[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiPickList source-heading="Available columns" target-heading="Shown" height="200" :source="source" :target="target" @sui-selection-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiPickList } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiPickList], template: `<sui-pick-list source-heading="Available columns" target-heading="Shown" height="200" [(selection)]="selection" [source]="source" [target]="target"></sui-pick-list>`, }) export class Example { selection: SuiKey[] = []; source = [ { value: "terms", label: "Terms" }, { value: "memo", label: "Memo" }, { value: "aging-bucket", label: "Aging bucket" }, { value: "sales-rep", label: "Sales rep" }, ]; // SuiReorderItem[] target = [ { value: "invoice-", label: "Invoice #" }, { value: "customer", label: "Customer" }, { value: "amount", label: "Amount" }, ]; // SuiReorderItem[] }

Basic

Rows in each pane are options in a listbox with one tab stop per pane. A row is { value, label } and takes the optional fields a sui-list row draws, so two columns with one label stay two rows. Every keyboard move is announced in a live region, so the transfer never needs a pointer.

<sui-pick-list source-heading="Available columns" target-heading="Shown" height="200"></sui-pick-list>
// setup(root): properties and events setup(root) { const el = root.querySelector('sui-pick-list'); el.source = availableColumns(); el.target = shownColumns(); }

Checkbox selection

selection-mode="checkbox" puts a box on every row under a select-all header per pane, and the transfer controls carry the whole ticked set at once. Ticks live in one pane at a time: ticking in the other clears the first, because every control and both crossing arrow keys act on the pane that holds the selection.

Nothing ticked.
<div style="display:grid;gap:10px"> <code id="pl-check-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing ticked.</code> <sui-pick-list id="pl-check" selection-mode="checkbox" source-heading="Available columns" target-heading="Shown" height="200"></sui-pick-list> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pl-check'); const log = root.querySelector('#pl-check-log'); el.source = availableColumns(); el.target = shownColumns(); el.addEventListener('suiSelectionChange', (e) => { log.textContent = e.detail.length ? `Ticked: ${e.detail.join(', ')}.` : 'Nothing ticked.'; }); }

Filter

Each pane gets its own box, named after that pane. Narrowing a pane to find the rows you want and moving them across is the point, so a transfer still runs while a box has text; only a move inside that pane waits for it to empty.

<sui-pick-list id="pl-filter" filter selection-mode="checkbox" source-heading="Available columns" target-heading="Report columns" height="220"></sui-pick-list>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pl-filter'); el.source = ledgerColumns(); el.target = rows(['Invoice #', 'Amount']); }

Nothing chosen yet

An empty pane reads "Nothing here yet." until a row lands in it, or empty-message when set, and "Drop here" while a drag is in flight; a filter that finds nothing reads empty-filter-message. The header count tracks each pane.

<sui-pick-list id="pl-empty" source-heading="Available columns" target-heading="Report columns" empty-message="Pick a column on the left." height="200"></sui-pick-list>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pl-empty'); el.source = shownColumns().concat(availableColumns()); el.target = []; }

Drag only, controls only

drag-only hides the four transfer controls, and no-drag removes the pointer drag and the insertion indicator for a touch surface where a long press already means something else. The keyboard lift stays in both.

<div style="display:grid;gap:16px"> <sui-pick-list id="pl-drag" drag-only source-heading="Available columns" target-heading="Shown" height="160"></sui-pick-list> <sui-pick-list id="pl-static" no-drag source-heading="Available columns" target-heading="Shown" height="160"></sui-pick-list> </div>
// setup(root): properties and events setup(root) { for (const id of ['#pl-drag', '#pl-static']) { const el = root.querySelector(id); el.source = availableColumns(); el.target = shownColumns(); } }

Loading and stacked

loading holds the controls and puts one loading row in each pane while the lists are on their way. breakpoint is the frame width under which the panes stack with the controls between them, 640 unless set and measured by the element itself, so a pick list in a narrow column stacks whatever the screen is.

<div style="display:grid;gap:16px"> <sui-pick-list id="pl-loading" source-heading="Available columns" target-heading="Shown" height="120" loading></sui-pick-list> <div style="max-width:360px"><sui-pick-list id="pl-stacked" source-heading="Available columns" target-heading="Shown" breakpoint="480" height="140"></sui-pick-list></div> </div>
// setup(root): properties and events setup(root) { const stacked = root.querySelector('#pl-stacked'); stacked.source = availableColumns(); stacked.target = shownColumns(); }

Events

suiSelectionChange carries the new selection, the picked values; a transfer carries the picks with their rows and does not fire it. suiSourceChange and suiTargetChange carry each pane after a transfer or reorder that changed it, so saving the chosen columns is one listener. None bubbles.

Nothing selected.
<div style="display:grid;gap:10px"> <code id="pl-out" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing selected.</code> <sui-pick-list id="pl-live" source-heading="Available columns" target-heading="Shown" height="180"></sui-pick-list> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#pl-live'); const out = root.querySelector('#pl-out'); el.source = availableColumns(); el.target = shownColumns(); el.addEventListener('suiSelectionChange', (e) => { out.textContent = e.detail.length ? `Selected: ${e.detail[0]}` : 'Nothing selected.'; }); el.addEventListener('suiTargetChange', (e) => { out.textContent = `Shown: ${e.detail.map((it) => it.label).join(', ') || 'none'}`; }); }

Keyboard

KeyAction
TabEnters the available pane, then the four transfer buttons, then the chosen pane. Each pane is one tab stop, at the row that has it, and in checkbox mode its select-all header is the stop before it. A transfer button that runs out of rows hands focus to the first row where they landed.
SpaceIn single mode, lifts the focused row, and drops a lifted row where it is. In checkbox mode, ticks the focused row. On a select-all header, ticks every row that pane shows, or clears them.
EnterLifts the focused row, and drops a lifted row where it is, in both modes.
ArrowDown ArrowUpMoves the tab stop within the pane, and the selection with it in single mode. With a row lifted, moves the row one place.
ArrowRightFrom the available pane, moves to the chosen pane, at its selected row or else its first. With a row lifted, carries it to the end of the chosen pane. Under dir="rtl" the pair swaps, so the arrow that points along the reading direction is always the forward one.
ArrowLeftFrom the chosen pane, moves to the available pane, at its selected row or else its first. With a row lifted, carries it to the end of the available pane.
Home EndMoves to the first or last row of the pane. With a row lifted, moves the row to the top or bottom.
EscapeCancels a lift and returns the row to the pane and place it started in.
ArrowDown ArrowUp stackedUnder the breakpoint the panes stack and the pair crosses between them: ArrowDown past the last available row lands on the chosen pane and ArrowUp from its first row comes back. With a row lifted, ArrowDown carries it to the chosen pane.

API

<sui-pick-list>

Properties

PropertyAttributeTypeDefaultDescription
breakpointbreakpointnumber640Frame width in px under which the two panes stack, the available pane over the chosen one, with the transfer controls in a row between them and their arrows turned. Measured by the element's own observer, so a pane in a narrow column stacks whatever the screen is. Zero keeps the panes side by side at every width.
dragOnlydrag-onlybooleanfalseHides the four transfer controls (move, move all, remove, remove all); pointer drag and the keyboard lift remain. Reflected.
emptyFilterMessageempty-filter-messagestringstring | undefinedRow a pane shows when its filter box narrowed the rows to none. An empty list and a search that found nothing are two states, so this is a second prop; defaults to "No matches.".
emptyMessageempty-messagestringstring | undefinedRow a pane shows when its list holds nothing. Defaults to "Nothing here yet.".
filterfilterbooleanfalseShows a filter box over each pane. It narrows what a pane draws (by label, description and meta), never the source and target arrays. Transfers still run while a pane is filtered, which is the point of filtering a column chooser; reordering inside a filtered pane waits for an empty box. Reflected.
heightheightnumber240Maximum pane height in px before it scrolls (minimum 120).
loadingloadingbooleanfalseThe rows are still on their way: one loading row stands in for each pane, the controls are held and the panes are aria-busy. Reflected, so a page can style the wait.
noDragno-dragbooleanfalseTurns off the pointer drag between and within the panes, and its insertion indicator. The transfer controls and the keyboard lift stay. Reflected.
selectionproperty onlySuiKey[][]Values of the selected rows, in pane order. The picks live in one pane at a time: picking in the other pane clears them, since every control and every arrow key acts on the pane that holds the selection. Values, not positions, so the selection travels with its rows through a reorder or a transfer; a value no row holds is ignored and dropped at the next change. Mutable; updated before suiSelectionChange fires. Property, not attribute.
selectionModeselection-modeSuiListSelectionMode"checkbox" | "single"'single'single picks one row at a time. checkbox draws a box on every row under a per-pane select-all header, and the transfer controls carry the whole ticked set at once. Reflected.
sourceproperty onlySuiReorderItem[][]Available rows, { value, label, description?, meta?, icon?, avatar?, initials?, severity? }. value is the row's identity, unique across both panes, so two rows with one label stay two rows. Mutable: a transfer or a reorder writes back here and emits suiSourceChange. Property, not attribute.
sourceHeadingsource-headingstringstring | undefinedHeading of the available pane, the first in reading order. heading is the settled word across the library.
targetproperty onlySuiReorderItem[][]Chosen rows, in the same shape as source. Mutable: a transfer or a reorder writes back here and emits suiTargetChange. Property, not attribute.
targetHeadingtarget-headingstringstring | undefinedHeading of the chosen pane, the second in reading order.

Events

EventDetailReactVueDescription
suiSelectionChangeSuiKey[]onSuiSelectionChange@sui-selection-changeFires when the selection changes; detail is the new selection, the picked rows' values in pane order. A transfer carries the picks with their rows and does not fire it. Does not bubble.
suiSourceChangeSuiReorderItem[]onSuiSourceChange@sui-source-changeFires after a transfer or a reorder that changed the available pane; detail is the new source. A transfer changes both panes and fires this, then suiTargetChange. A state report: the pick list is not a form field, so a form around it never hears it. Does not bubble.
suiTargetChangeSuiReorderItem[]onSuiTargetChange@sui-target-changeFires after a transfer or a reorder that changed the chosen pane; detail is the new target. Does not bubble.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the tab stop of the pane holding the selection (else the first row of the available pane).

Types

SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiListSelectionMode typeHow a reorderable list picks rows: one at a time, or many through checkboxes under a select-all header. sui-order-list and sui-pick-list share it, and it is the same vocabulary sui-tree's selectionMode uses, minus the none a tree can offer and a reorder list cannot: the move controls act on a selection, so a list with no way to select is inert.
No members.
SuiReorderItem interfaceOne row of sui-order-list and sui-pick-list. value is the row's identity, which selection holds and which keeps two rows with one label apart; label is what the row reads. The optional fields are the ones a sui-list row draws: a supporting line, a trailing figure and a leading disc.
valueSuiKeyIdentity, unique in the list (in both panes of a pick list), kept as given.
labelstringThe row's text, and what the filter box, the announcements and a drag carry.
description?stringSupporting line under the label.
meta?stringTrailing text on the label line in tabular figures: a count, a date.
icon?stringFont Awesome class drawn in a neutral disc before the text.
avatar?stringImage URL for an avatar disc before the text; wins over severity and icon.
initials?stringLetters for an avatar disc without an image.
severity?SuiSeverityStatus disc before the text, on the shared ladder; wins over icon.
SuiSeverity type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

Color31

--accentthe one chart highlight: the flagged series, down candles, the peak cell--accent-inkorange shaded for small text on light--accent-softorange 18% on white, warning fills, highlights--accent-soft-ink--bordercard + table outer borders--border-strongcontrol borders, emphasized rules--brand--brand-contrasttext and glyphs sitting on solid brand fills--control-borderthe resting edge of every field and control: 3:1 on its grounds--danger--danger-soft--disabled-bg--disabled-ink--focus-halo--focus-ring--ink--ink-faintmetadata beside body ink: 4.8:1 on the page grounds (5.4:1 in dark)--ink-mutedsecondary text: 7:1 on the page grounds and 1.5 times faint--ink-muted-on-washthe muted ink a selected, current or pressed row paints: 4.5:1 on that wash--ink-placeholdera field's placeholder, not a text tier: 4.5:1 on the page grounds--status-attention-bg--status-attention-inkOpen, Pending, In transit--status-neutral-bg--status-neutral-ink--status-positive-bg--status-positive-inkPaid, Cleared, Active--surfacecards, tables, inputs--surface-hovera hovered row or quiet control on any ground: the ink, translucent--surface-pressed--surface-selecteda selected row or option: the primary's hue, always heavier than a hover--surface-sunkentable headers, wells

Typography1

Ag--font-sans

Spacing and size4

42--control-lgform-page inputs34--control-mdDEFAULT toolbar/filter controls12--control-pad-x28--control-sm

Radius3

--radius-lgcards, tables, panels, dialogs--radius-mdALL controls: inputs, buttons, selects--radius-xscheckboxes, mini swatches

Elevation2

--focus-shadow--shadow-raisedhover-lift, dragged rows

Motion2

--duration-fasthovers, presses, every exit--ease-out

Every name above is listed with its value and its role in Foundations.

See also