<bpdm/ui />
Data

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.

Available
Analytics
Billing
Webhooks
SSO
Audit log
API keys
Enabled
Nothing here yet
pick-list-usage.tsx
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.

pick-list-usage.ts
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.

Available
AnalyticsCore
BillingCore
WebhooksAdd-on
SSOAdd-on
Audit logBeta
Enabled
Nothing here yet
pick-list-custom.tsx
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>
      )}
    />
  );
}
pick-list-custom.ts
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.

Available
AnalyticsCore
BillingCore
WebhooksAdd-on
SSOAdd-on
Audit logBeta
Enabled
Nothing here yet
pick-list-filter.tsx
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>
      )}
    />
  );
}
pick-list-filter.ts
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.

Available
Analytics
Billing
Webhooks
SSO
Audit log
API keys
Enabled
Nothing here yet
pick-list-transfer.tsx
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}
    />
  );
}
pick-list-transfer.ts
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.

Available
Billing
Webhooks
SSO
Enabled
Analytics
pick-list-locked.tsx
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}`)}
    />
  );
}
pick-list-locked.ts
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.

Verfügbar
Analytics
Billing
Webhooks
SSO
Audit log
API keys
Aktiviert
Noch nichts hier
pick-list-i18n.tsx
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`,
      }}
    />
  );
}
pick-list-i18n.ts
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

KeyTypeDefaultDescription
transferGroupstringTransfer between listsaria-label for the middle control group.
moveToTargetstringMove to targetMove-selected → target button.
moveAllToTargetstringMove all to targetMove-all ⇒ target button.
moveToSourcestringMove to sourceMove-selected ← source button.
moveAllToSourcestringMove all to sourceMove-all ⇐ source button.
sourceEmpty / targetEmptystringNo items / Nothing here yetEmpty-state text per list.
filterPlaceholderstringFilterFilter-box placeholder + aria-label.
sourceLabel / targetLabelstringsource list / target listFallback 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>

PropTypeDefaultDescription
value / defaultValue / onChange{ source: T[]; target: T[] }The two lists — controlled or uncontrolled. Angular: [(value)].
itemKey(item) => string | numberRequired. Stable id per item.
renderItem (Angular itemTemplate)(item) => ReactNode / TemplateRefRequired. How each row renders.
sourceHeader / targetHeaderReactNode / stringHeaders above each list.
filterBy / filterPlaceholder(item) => string / stringShow a filter box on both lists.
reorderbooleantrueShow per-list reorder controls + within-list drag.
scrollHeightstring18remMax body height before a list scrolls.
sourceEmptyText / targetEmptyTextstringEmpty-state text per list. Overrides messages.sourceEmpty / targetEmpty.
isItemDisabled(item) => booleanLock 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").
messagesPartial<PickListMessages>Override any user-facing / screen-reader string — see Internationalization.
classNamestring (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, roving aria-activedescendant, ↑/↓/Home/End to move, Enter/Space to select), with aria-multiselectable and per-option aria-selected.
  • Each pane is named for screen readers: by its sourceHeader / targetHeader when present, otherwise by a distinct fallback (source list / target list) so the two panes never share a name.
  • The transfer controls are a labelled group of real <button>s, each with an aria-label; they disable when there's nothing to move. Decorative arrow icons are aria-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) expose aria-disabled and 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.

On this page