Skip to the content

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" 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

Keyboard
KeyBehaviour
โ†“orโ†‘In a list, move the highlight to the next or the previous item.
HomeorEndIn a list, move the highlight to the first or the last item.
SpaceIn a list, chooses the highlighted item, or un-chooses it; with Shift, chooses the range from the last one chosen.
CtrlAIn 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.
EnterWith 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.
EnterorSpaceOn a transfer button, moves the chosen items (or, for Move all, every item in view) to the end of the other list.

API

PickList

PickList props
PropTypeDefaultDescription
defaultSourcereadonly T[]The items the first list starts with, when the pick list controls itself.
defaultTargetreadonly T[]The items the second list starts with, when the pick list controls itself.
disabledbooleanfalseGreys both lists and every button.
draggablebooleanfalseLets people drag items within a list and from one list into the other.
dragHandlebooleanfalseWith draggable, shows a grip on each item; only the grip starts a pointer drag.
filterbooleanfalseShows a filter field above each list. "Move all" then moves the items in view.
filterPlaceholderstringThe 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"nonecheckbox draws a checkbox on every item, ticked once chosen; a click then toggles an item.
listClassNamestringh-60Classes 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.
orderControlsbooleantrueShows 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.
selectAllbooleanfalseShows a "Select all" checkbox above each list.
sourcereadonly T[]The items in the first list, in order, when you control them. Pair it with onSourceChange.
sourceEmptyReactNodeWhat the first list says when it is empty. Defaults to the provider's noItems string.
sourceHeaderReactNodeA title above the first list that names it. Without one the list is named by the provider's sourceList string.
targetreadonly T[]The items in the second list, in order, when you control them. Pair it with onTargetChange.
targetEmptyReactNodeWhat the second list says when it is empty. Defaults to the provider's noItems string.
targetHeaderReactNodeA 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.

Theme tokens
TokenUsed for
--secondarybackground
--secondary-foregroundtext