Pick List
Move items between two lists — transfer with the middle controls, optionally reorder each side; filterable, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Pick List moves items between two lists — a source and a target.
Select items on either side and transfer them with the middle controls (move /
move-all, each way); optionally reorder within each list (drag or the side controls).
Controlled (value = { source, target }) or uncontrolled, filterable, and
responsive (the two lists stack on small screens). In Angular it's
<bpdm-pick-list> with [(value)] and an itemTemplate.
Usage
Give it itemKey, renderItem, and the two lists via value/onChange. The
middle column transfers the current selection between the lists.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
const FEATURES = ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'];
export function Features() {
const [value, setValue] = useState({ source: FEATURES, target: [] as string[] });
return (
<PickList
value={value}
onChange={setValue}
itemKey={(s) => s}
renderItem={(s) => s}
sourceHeader="Available"
targetHeader="Enabled"
/>
);
}[(value)] two-way binds { source, target }; the row is an <ng-template>
passed as itemTemplate.
import { Component, signal } from '@angular/core';
import { BpdmPickList } from '@bpdm/ng';
@Component({
selector: 'features',
imports: [BpdmPickList],
template: `
<bpdm-pick-list [(value)]="value" [itemKey]="itemKey" [itemTemplate]="row" sourceHeader="Available" targetHeader="Enabled">
<ng-template #row let-item>{{ item }}</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (s: string) => s;
readonly value = signal({
source: ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'],
target: [] as string[],
});
}Custom rows
renderItem / the itemTemplate can render anything — a Badge, an avatar,
secondary text.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
import { Badge } from '@bpdm/ui/badge';
type Feature = { id: string; name: string; tag: 'Core' | 'Add-on' | 'Beta' };
const FEATURES: Feature[] = [
{ id: 'f1', name: 'Analytics', tag: 'Core' },
{ id: 'f2', name: 'Billing', tag: 'Core' },
{ id: 'f3', name: 'Webhooks', tag: 'Add-on' },
{ id: 'f4', name: 'SSO', tag: 'Add-on' },
{ id: 'f5', name: 'Audit log', tag: 'Beta' },
];
export function Features() {
const [value, setValue] = useState({ source: FEATURES, target: [] as Feature[] });
return (
<PickList
value={value}
onChange={setValue}
itemKey={(f) => f.id}
sourceHeader="Available"
targetHeader="Enabled"
renderItem={(f) => (
<div className="flex flex-1 items-center justify-between gap-3">
<span className="font-medium">{f.name}</span>
<Badge variant="secondary" appearance="soft">
{f.tag}
</Badge>
</div>
)}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmPickList, BpdmBadge } from '@bpdm/ng';
type Feature = { id: string; name: string; tag: 'Core' | 'Add-on' | 'Beta' };
@Component({
selector: 'features',
imports: [BpdmPickList, BpdmBadge],
template: `
<bpdm-pick-list [(value)]="value" [itemKey]="itemKey" [itemTemplate]="row" sourceHeader="Available" targetHeader="Enabled">
<ng-template #row let-f>
<div class="flex flex-1 items-center justify-between gap-3">
<span class="font-medium">{{ f.name }}</span>
<bpdm-badge variant="secondary" appearance="soft">{{ f.tag }}</bpdm-badge>
</div>
</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (f: Feature) => f.id;
readonly value = signal<{ source: Feature[]; target: Feature[] }>({
source: [
{ id: 'f1', name: 'Analytics', tag: 'Core' },
{ id: 'f2', name: 'Billing', tag: 'Core' },
{ id: 'f3', name: 'Webhooks', tag: 'Add-on' },
{ id: 'f4', name: 'SSO', tag: 'Add-on' },
{ id: 'f5', name: 'Audit log', tag: 'Beta' },
],
target: [],
});
}Filtering
Pass filterBy to show a filter box on both lists — handy for long option sets.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
import { Badge } from '@bpdm/ui/badge';
type Feature = { id: string; name: string; tag: 'Core' | 'Add-on' | 'Beta' };
const FEATURES: Feature[] = [
{ id: 'f1', name: 'Analytics', tag: 'Core' },
{ id: 'f2', name: 'Billing', tag: 'Core' },
{ id: 'f3', name: 'Webhooks', tag: 'Add-on' },
{ id: 'f4', name: 'SSO', tag: 'Add-on' },
{ id: 'f5', name: 'Audit log', tag: 'Beta' },
];
export function Features() {
const [value, setValue] = useState({ source: FEATURES, target: [] as Feature[] });
return (
<PickList
value={value}
onChange={setValue}
itemKey={(f) => f.id}
sourceHeader="Available"
targetHeader="Enabled"
filterBy={(f) => f.name}
filterPlaceholder="Filter features…"
renderItem={(f) => (
<div className="flex flex-1 items-center justify-between gap-3">
<span className="font-medium">{f.name}</span>
<Badge variant="secondary" appearance="soft">
{f.tag}
</Badge>
</div>
)}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmPickList, BpdmBadge } from '@bpdm/ng';
type Feature = { id: string; name: string; tag: 'Core' | 'Add-on' | 'Beta' };
@Component({
selector: 'features',
imports: [BpdmPickList, BpdmBadge],
template: `
<bpdm-pick-list
[(value)]="value"
[itemKey]="itemKey"
[itemTemplate]="row"
sourceHeader="Available"
targetHeader="Enabled"
[filterBy]="filterBy"
filterPlaceholder="Filter features…"
>
<ng-template #row let-f>
<div class="flex flex-1 items-center justify-between gap-3">
<span class="font-medium">{{ f.name }}</span>
<bpdm-badge variant="secondary" appearance="soft">{{ f.tag }}</bpdm-badge>
</div>
</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (f: Feature) => f.id;
readonly filterBy = (f: Feature) => f.name;
readonly value = signal<{ source: Feature[]; target: Feature[] }>({
source: [
{ id: 'f1', name: 'Analytics', tag: 'Core' },
{ id: 'f2', name: 'Billing', tag: 'Core' },
{ id: 'f3', name: 'Webhooks', tag: 'Add-on' },
{ id: 'f4', name: 'SSO', tag: 'Add-on' },
{ id: 'f5', name: 'Audit log', tag: 'Beta' },
],
target: [],
});
}Transfer only
Set reorder={false} to hide the up/down reorder controls (and within-list drag) —
leaving just the transfer between lists.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
const FEATURES = ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'];
export function Features() {
const [value, setValue] = useState({ source: FEATURES, target: [] as string[] });
return (
<PickList
value={value}
onChange={setValue}
itemKey={(s) => s}
renderItem={(s) => s}
sourceHeader="Available"
targetHeader="Enabled"
reorder={false}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmPickList } from '@bpdm/ng';
@Component({
selector: 'features',
imports: [BpdmPickList],
template: `
<bpdm-pick-list
[(value)]="value"
[itemKey]="itemKey"
[itemTemplate]="row"
sourceHeader="Available"
targetHeader="Enabled"
[reorder]="false"
>
<ng-template #row let-item>{{ item }}</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (s: string) => s;
readonly value = signal({
source: ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'],
target: [] as string[],
});
}Locked items
Pass isItemDisabled to mark items that can't be selected, transferred, or dragged
— e.g. a plan feature that's always on. Locked items stay put even on move-all,
and keyboard navigation skips over them.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
export function Features() {
const [value, setValue] = useState({
source: ['Billing', 'Webhooks', 'SSO'],
target: ['Analytics'],
});
return (
<PickList
value={value}
onChange={setValue}
itemKey={(s) => s}
renderItem={(s) => s}
sourceHeader="Available"
targetHeader="Enabled"
isItemDisabled={(s) => s === 'Analytics'}
onTransfer={(moved, to) => console.log(`Moved ${moved.length} to ${to}`)}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmPickList, type PickListValue } from '@bpdm/ng';
@Component({
selector: 'features',
imports: [BpdmPickList],
template: `
<bpdm-pick-list
[(value)]="value"
[itemKey]="itemKey"
[itemTemplate]="row"
sourceHeader="Available"
targetHeader="Enabled"
[isItemDisabled]="isLocked"
(transfer)="onTransfer($event)"
>
<ng-template #row let-item>{{ item }}</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (s: string) => s;
readonly isLocked = (s: string) => s === 'Analytics';
readonly value = signal<PickListValue<string>>({
source: ['Billing', 'Webhooks', 'SSO'],
target: ['Analytics'],
});
onTransfer(e: { moved: string[]; to: 'source' | 'target' }) {
console.log(`Moved ${e.moved.length} to ${e.to}`);
}
}Internationalization
Every user-facing and screen-reader string — the transfer-button labels, the
control-group label, the empty-state text, the filter placeholder, and the
live-region transfer announcement — routes through a single messages object.
Pass a Partial<PickListMessages>; anything you omit falls back to the English
default. The component is also fully RTL-aware: it uses logical layout
classes throughout, and the horizontal transfer arrows flip under dir="rtl" so
"toward the target" always points at the target pane.
import { useState } from 'react';
import { PickList } from '@bpdm/ui/pick-list';
const FEATURES = ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'];
export function Features() {
const [value, setValue] = useState({ source: FEATURES, target: [] as string[] });
return (
<PickList
value={value}
onChange={setValue}
itemKey={(s) => s}
renderItem={(s) => s}
sourceHeader="Verfügbar"
targetHeader="Aktiviert"
messages={{
transferGroup: 'Zwischen Listen verschieben',
moveToTarget: 'Zum Ziel verschieben',
moveAllToTarget: 'Alle zum Ziel verschieben',
moveToSource: 'Zur Quelle verschieben',
moveAllToSource: 'Alle zur Quelle verschieben',
targetEmpty: 'Noch nichts hier',
filterPlaceholder: 'Filtern',
transferAnnouncement: (count, list) => `${count} nach ${list} verschoben`,
}}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmPickList, type PickListMessages, type PickListValue } from '@bpdm/ng';
@Component({
selector: 'features',
imports: [BpdmPickList],
template: `
<bpdm-pick-list
[(value)]="value"
[itemKey]="itemKey"
[itemTemplate]="row"
sourceHeader="Verfügbar"
targetHeader="Aktiviert"
[messages]="messages"
>
<ng-template #row let-item>{{ item }}</ng-template>
</bpdm-pick-list>
`,
})
export class Features {
readonly itemKey = (s: string) => s;
readonly value = signal<PickListValue<string>>({
source: ['Analytics', 'Billing', 'Webhooks', 'SSO', 'Audit log', 'API keys'],
target: [],
});
readonly messages: Partial<PickListMessages> = {
transferGroup: 'Zwischen Listen verschieben',
moveToTarget: 'Zum Ziel verschieben',
moveAllToTarget: 'Alle zum Ziel verschieben',
moveToSource: 'Zur Quelle verschieben',
moveAllToSource: 'Alle zur Quelle verschieben',
targetEmpty: 'Noch nichts hier',
filterPlaceholder: 'Filtern',
transferAnnouncement: (count, list) => `${count} nach ${list} verschoben`,
};
}PickListMessages
| Key | Type | Default | Description |
|---|---|---|---|
transferGroup | string | Transfer between lists | aria-label for the middle control group. |
moveToTarget | string | Move to target | Move-selected → target button. |
moveAllToTarget | string | Move all to target | Move-all ⇒ target button. |
moveToSource | string | Move to source | Move-selected ← source button. |
moveAllToSource | string | Move all to source | Move-all ⇐ source button. |
sourceEmpty / targetEmpty | string | No items / Nothing here yet | Empty-state text per list. |
filterPlaceholder | string | Filter | Filter-box placeholder + aria-label. |
sourceLabel / targetLabel | string | source list / target list | Fallback accessible name for a list with no visible header. |
transferAnnouncement | (count, listLabel) => string | "{count} item(s) moved to {listLabel}" | Polite live-region text after a transfer. |
API
PickList / <bpdm-pick-list>
| Prop | Type | Default | Description |
|---|---|---|---|
value / defaultValue / onChange | { source: T[]; target: T[] } | — | The two lists — controlled or uncontrolled. Angular: [(value)]. |
itemKey | (item) => string | number | — | Required. Stable id per item. |
renderItem (Angular itemTemplate) | (item) => ReactNode / TemplateRef | — | Required. How each row renders. |
sourceHeader / targetHeader | ReactNode / string | — | Headers above each list. |
filterBy / filterPlaceholder | (item) => string / string | — | Show a filter box on both lists. |
reorder | boolean | true | Show per-list reorder controls + within-list drag. |
scrollHeight | string | 18rem | Max body height before a list scrolls. |
sourceEmptyText / targetEmptyText | string | — | Empty-state text per list. Overrides messages.sourceEmpty / targetEmpty. |
isItemDisabled | (item) => boolean | — | Lock items — not selectable, transferable, or draggable; kept in place on move-all. |
onTransfer (Angular transfer) | (moved, to) => void / { moved, to } | — | Fires after a transfer with the moved items and destination ("source" | "target"). |
messages | Partial<PickListMessages> | — | Override any user-facing / screen-reader string — see Internationalization. |
className | string (Angular class) | — | Extra classes. |
filterPlaceholder likewise overrides messages.filterPlaceholder when set.
Accessibility
- Drag is an enhancement — every transfer and reorder is reachable with the buttons, so keyboard-only users can operate both lists fully.
- Each list is a WAI-ARIA
listbox(single tab stop, rovingaria-activedescendant, ↑/↓/Home/End to move, Enter/Space to select), witharia-multiselectableand per-optionaria-selected. - Each pane is named for screen readers: by its
sourceHeader/targetHeaderwhen present, otherwise by a distinct fallback (source list/target list) so the two panes never share a name. - The transfer controls are a labelled
groupof real<button>s, each with anaria-label; they disable when there's nothing to move. Decorative arrow icons arearia-hidden. - Every transfer is announced through a polite live region (e.g. "2 items moved to Enabled"), and focus is kept inside the transfer group if a button disables.
- Locked items (
isItemDisabled) exposearia-disabledand are skipped by keyboard navigation, so they never trap focus or appear selectable. - RTL: layout uses logical classes and the horizontal transfer arrows flip
under
dir="rtl", so direction stays correct in right-to-left locales. - All labels and the announcement are overridable via
messages.
Order List
Reorder a collection — select items and move them with the control column or drag-and-drop; filterable, in React (@bpdm/ui) and Angular (@bpdm/ng).
Status Timeline
A vertical status timeline for lifecycles — complete / current / pending / failed steps with timestamps, in React (@bpdm/ui) and Angular (@bpdm/ng).