ComponentsData
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: pnpm add @dnd-kit/core @dnd-kit/sortable
Usage
PickList puts two order lists 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.
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.
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.
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.
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.
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.
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.
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"witharia-multiselectable="true"ofrole="option"items witharia-selected, exactly asOrderList. The transfer buttons are buttons named from the provider; one with nothing to move isaria-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
sourceHeaderandtargetHeader; 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
renderItemwritten inline is new on each render of its parent and redraws every item then: in a long list, define it outside the component or withuseCallback.
Keyboard
| Key | Behaviour |
|---|---|
| โorโ | In a list, move the highlight to the next or the previous item. |
| HomeorEnd | 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. |
| CtrlA | 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. |
| EnterorSpace | 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.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--secondary | background |
--secondary-foreground | text |