# Pick list

Two lists side by side: people move items from one to the other with buttons or by dragging, and put each in order.

- **Import:** `import { PickList } from "@booleanpress/ui/pick-list"`
- **Also install:** `@dnd-kit/core`, `@dnd-kit/sortable`
- **APG Listbox:** <https://www.w3.org/WAI/ARIA/apg/patterns/listbox/>
- **Page:** <https://ui.booleanpress.com/components/pick-list> · @booleanpress/ui 0.2.0

## Usage

`PickList` puts two [order lists](/components/order-list) side by side, the source and the target, with four buttons between them: Move to target, Move all to target, Move to source and Move all to source. Each list is chosen from, filtered and put in order on its own; with `draggable` an item is also dragged within a list or across to the other one. Install the peers first: `npm install @dnd-kit/core @dnd-kit/sortable`.

```tsx
import { useState } from "react"
import { PickList } from "@booleanpress/ui/pick-list"

export function BillingAgents({ agents }: { agents: { id: string; name: string }[] }) {
  const [available, setAvailable] = useState(agents)
  const [assigned, setAssigned] = useState<typeof agents>([])
  return (
    <PickList
      sourceHeader="Available agents"
      targetHeader="Assigned to Billing"
      source={available}
      onSourceChange={setAvailable}
      target={assigned}
      onTargetChange={setAssigned}
      draggable
    />
  )
}
```

Each list is controlled with `source` and `onSourceChange`, `target` and `onTargetChange`, or uncontrolled with `defaultSource` and `defaultTarget`. Items are keyed and labelled as in `OrderList`: strings are their own key, objects use `id` (then `value`), or pass `getItemKey` and `getItemLabel`; `renderItem` is told which `list` the item is in. Moved items go to the end of the other list, and `transferItems(from, to, keys)` does the same for buttons of your own.

`sourceHeader` and `targetHeader` are titles that name the lists; without them the lists are named Source and Target from the provider. `orderControls={false}` hides each list's Move up and down buttons. Both lists are `h-60` (240px) tall; `listClassName` changes that. With less than 576px of room the lists stack, the transfer buttons in a row between them.

## Examples

### Basic

Choose agents, then assign them with the buttons between the lists; each list has its own Move buttons.

```tsx
import { useState } from "react"
import { PickList } from "@booleanpress/ui/pick-list"

const AGENTS = [
  { id: "amy", name: "Amy Elsner" },
  { id: "asiya", name: "Asiya Javayant" },
  { id: "onyama", name: "Onyama Limba" },
  { id: "anna", name: "Anna Fali" },
  { id: "bernardo", name: "Bernardo Dominic" },
  { id: "elwin", name: "Elwin Sharvill" },
  { id: "ioni", name: "Ioni Bowcher" },
  { id: "stephen", name: "Stephen Shaw" },
]

export default function PickListBasic() {
  const [available, setAvailable] = useState(AGENTS)
  const [assigned, setAssigned] = useState<typeof AGENTS>([])

  return (
    <PickList
      sourceHeader="Available agents"
      targetHeader="Assigned to Billing"
      source={available}
      onSourceChange={setAvailable}
      target={assigned}
      onTargetChange={setAssigned}
      className="w-full max-w-2xl"
    />
  )
}
```

### Drag and drop

`draggable`: drag an event within a list or across to the other; Enter picks it up from the keyboard.

```tsx
import { PickList } from "@booleanpress/ui/pick-list"

const EVENTS = ["Delivered", "Opened", "Clicked", "Bounced", "Complained", "Unsubscribed", "Deferred", "Rejected"]

export default function PickListDragAndDrop() {
  return (
    <PickList
      sourceHeader="Webhook events"
      targetHeader="Sent to your endpoint"
      defaultSource={EVENTS.slice(2)}
      defaultTarget={EVENTS.slice(0, 2)}
      draggable
      className="w-full max-w-2xl"
    />
  )
}
```

### Checkboxes

`indicator="checkbox"` with `selectAll`: tick scopes in either list, or all of them at once.

```tsx
import { PickList } from "@booleanpress/ui/pick-list"

const SCOPES = [
  { id: "mail.send", label: "Send email" },
  { id: "mail.read", label: "Read delivery logs" },
  { id: "contacts.read", label: "Read contacts" },
  { id: "contacts.write", label: "Edit contacts" },
  { id: "templates.read", label: "Read templates" },
  { id: "templates.write", label: "Edit templates" },
  { id: "webhooks.manage", label: "Manage webhooks" },
]

export default function PickListCheckbox() {
  return (
    <PickList
      sourceHeader="Available scopes"
      targetHeader="Granted to this API key"
      defaultSource={SCOPES.slice(1)}
      defaultTarget={SCOPES.slice(0, 1)}
      indicator="checkbox"
      selectAll
      className="w-full max-w-2xl"
    />
  )
}
```

### Filter

`filter` adds a field above each list; Move all moves the items in view, and chosen items it hides stay chosen.

```tsx
import { PickList } from "@booleanpress/ui/pick-list"

const COUNTRIES = [
  "Austria", "Bangladesh", "Belgium", "Canada", "Denmark", "Finland", "France", "Germany",
  "Ireland", "Italy", "Japan", "Netherlands", "Norway", "Portugal", "Spain", "Sweden",
]

export default function PickListFilter() {
  return (
    <PickList
      sourceHeader="Countries"
      targetHeader="Allowed to sign up"
      defaultSource={COUNTRIES}
      defaultTarget={[]}
      filter
      filterPlaceholder="Search countries"
      className="w-full max-w-2xl"
    />
  )
}
```

### Empty placeholders

An empty list says so, in `sourceEmpty`, `targetEmpty` or the provider's `noItems` text.

```tsx
import { PickList } from "@booleanpress/ui/pick-list"

const TAGS = ["VIP", "Refund requested", "Bug report", "Feature request"]

export default function PickListEmpty() {
  return (
    <PickList
      sourceHeader="Ticket tags"
      targetHeader="Tags that page the on-call agent"
      defaultSource={TAGS}
      defaultTarget={[]}
      targetEmpty="No tags page anyone yet"
      listClassName="h-40"
      className="w-full max-w-2xl"
    />
  )
}
```

### Disabled

`disabled` greys both lists and every button, so no item moves.

```tsx
import { PickList } from "@booleanpress/ui/pick-list"

const AVAILABLE = [
  { id: "anna", name: "Anna Fali" },
  { id: "bernardo", name: "Bernardo Dominic" },
  { id: "elwin", name: "Elwin Sharvill" },
]

const ASSIGNED = [
  { id: "amy", name: "Amy Elsner" },
  { id: "asiya", name: "Asiya Javayant" },
]

export default function PickListDisabled() {
  return (
    <PickList
      disabled
      sourceHeader="Available agents"
      targetHeader="Assigned to Billing"
      defaultSource={AVAILABLE}
      defaultTarget={ASSIGNED}
      className="w-full max-w-2xl"
    />
  )
}
```

## Accessibility

**Semantics.** Each list is a `role="listbox"` with `aria-multiselectable="true"` of `role="option"` items with `aria-selected`, exactly as `OrderList`. The transfer buttons are buttons named from the provider; one with nothing to move is `aria-disabled`, so the focus stays on it. Every transfer, reorder and drag is read out in a polite live region: "Amy Elsner moved to position 1 of 1".

**Labels.** Name the lists with `sourceHeader` and `targetHeader`; otherwise they are named Source and Target. The transfer buttons are named Move to target, Move all to target, Move to source and Move all to source, from the provider, in either direction of text.

**Focus.** Tab moves through the source's buttons, the source list, the transfer buttons, the target list and the target's buttons, in the order they are drawn. A transfer leaves the focus on its button. A keyboard drag into the other list takes the focus with the item.

**Known limits.**

- Moved items go to the end of the other list; drag one to put it somewhere in particular.
- A keyboard drag into the other list lands before the item it is over; to land last, drop it and press Alt and End.
- Items are always in the page. A key press redraws only the items it changes, so a list of 5,000 keeps up with the keyboard; choosing every item, and each move in a draggable list, redraws them all. Beyond a few thousand, page or filter them on the server. A `renderItem` written inline is new on each render of its parent and redraws every item then: in a long list, define it outside the component or with `useCallback`.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↓ or ↑ | In a list, move the highlight to the next or the previous item. |
| Home or End | In a list, move the highlight to the first or the last item. |
| Space | In a list, chooses the highlighted item, or un-chooses it; with Shift, chooses the range from the last one chosen. |
| Ctrl + A | In a list, chooses every item in view, or none when all are chosen (⌘ and A on a Mac). |
| Alt + ↑ | In a list, moves the chosen items one place up; Alt and ArrowDown one place down, Alt and Home or End to the top or the bottom. |
| Enter | With `draggable`, picks the highlighted item up. ArrowUp and ArrowDown move it in its list; ArrowRight and ArrowLeft take it to the other list; Space or Enter drops it and Escape puts it back. |
| Enter or Space | On a transfer button, moves the chosen items (or, for Move all, every item in view) to the end of the other list. |

## API

### PickList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultSource` | `readonly T[]` |  | The items the first list starts with, when the pick list controls itself. |
| `defaultTarget` | `readonly T[]` |  | The items the second list starts with, when the pick list controls itself. |
| `disabled` | `boolean` | `false` | Greys both lists and every button. |
| `draggable` | `boolean` | `false` | Lets people drag items within a list and from one list into the other. |
| `dragHandle` | `boolean` | `false` | With `draggable`, shows a grip on each item; only the grip starts a pointer drag. |
| `filter` | `boolean` | `false` | Shows a filter field above each list. "Move all" then moves the items in view. |
| `filterPlaceholder` | `string` |  | The filter fields' placeholder. |
| `getItemKey` | `((item: T) => string)` |  | A stable, unique key for an item. Defaults to the item itself for strings, else its `id`, then its `value`. |
| `getItemLabel` | `((item: T) => string)` |  | The item's text, for type-ahead, the filters and the announcements. Defaults to the string, `label`, `name` or `title`. |
| `indicator` | `"none" \| "checkbox"` | `none` | `checkbox` draws a checkbox on every item, ticked once chosen; a click then toggles an item. |
| `listClassName` | `string` | `h-60` | Classes for both scrolling lists. Defaults to `h-60`, so the two lists are the same height. |
| `onSourceChange` | `((source: T[]) => void)` |  | Called with the first list's items after every move. |
| `onTargetChange` | `((target: T[]) => void)` |  | Called with the second list's items after every move. |
| `orderControls` | `boolean` | `true` | Shows each list's Move up and down buttons, on its outer side. |
| `renderItem` | `((item: T, state: PickListItemState) => ReactNode)` |  | Draws an item's content in either list. Defaults to its label. |
| `selectAll` | `boolean` | `false` | Shows a "Select all" checkbox above each list. |
| `source` | `readonly T[]` |  | The items in the first list, in order, when you control them. Pair it with `onSourceChange`. |
| `sourceEmpty` | `ReactNode` |  | What the first list says when it is empty. Defaults to the provider's `noItems` string. |
| `sourceHeader` | `ReactNode` |  | A title above the first list that names it. Without one the list is named by the provider's `sourceList` string. |
| `target` | `readonly T[]` |  | The items in the second list, in order, when you control them. Pair it with `onTargetChange`. |
| `targetEmpty` | `ReactNode` |  | What the second list says when it is empty. Defaults to the provider's `noItems` string. |
| `targetHeader` | `ReactNode` |  | A title above the second list that names it. Without one the list is named by the provider's `targetList` string. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="pick-list"` (PickList).

**Provider strings:** `itemMoved`, `itemsMoved`, `moveToTarget`, `moveAllToTarget`, `moveToSource`, `moveAllToSource`, `sourceList`, `targetList` (`BooleanUIProvider`'s `strings`).

## Theming

The lists take OrderList's tokens: the field fill and edges, `--accent` while highlighted, `--highlight` once chosen. The transfer and Move buttons are the small secondary icon buttons. A list an item is dragged over from the other one turns its edge `--ring`, and a 2px `--primary` line marks where the item will land.

| Token | Used for |
| --- | --- |
| `--secondary` | background |
| `--secondary-foreground` | text |
