# Order list

A list people put in order: they choose items, then move them with buttons, Alt and the arrow keys, or by dragging.

- **Import:** `import { OrderList, OrderListGroup } from "@booleanpress/ui/order-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/order-list> · @booleanpress/ui 0.2.0

## Usage

`OrderList` is a [listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) of items with four Move buttons beside it. People choose one or several items, then move them up, down, to the top or to the bottom; with `draggable` they can also drag an item, by mouse, by touch (press and hold) or by keyboard. Install the peers first: `npm install @dnd-kit/core @dnd-kit/sortable`.

```tsx
import { useState } from "react"
import { OrderList } from "@booleanpress/ui/order-list"

export function RoutingRules({ initial }: { initial: { id: string; name: string }[] }) {
  const [rules, setRules] = useState(initial)
  return <OrderList header="Routing rules" value={rules} onValueChange={setRules} draggable />
}
```

The value is the items themselves, in order: controlled with `value` and `onValueChange`, or uncontrolled with `defaultValue`. Each item needs a stable key: strings are their own key, objects use `id` (then `value`), or pass `getItemKey`. `getItemLabel` gives the text that type-ahead, the filter and the announcements use (by default the string itself, else `label`, `name` or `title`); `renderItem` draws anything richer and is told the item's `index`, whether it is `selected`, and whether it is the `dragging` copy.

The chosen items are keys, controlled with `selected` and `onSelectedChange` or not. A click chooses one item; Ctrl or ⌘ adds or removes one, Shift chooses a range. `indicator="checkbox"` draws a checkbox on every item, and a click then toggles; `selectAll` adds a "Select all" checkbox above the list. `filter` adds a field that narrows the items in view; moves then act on the items in view and leave the hidden ones in place.

Name the list with `header` (a title above it), `aria-label` or `aria-labelledby`. The frame is as tall as its items; `listClassName="h-60"` fixes the height and makes the list scroll. `controls="end"` puts the buttons after the list, `controls="none"` hides them. `moveItems(items, keys, move)` is the same move as a function, for buttons of your own. To drag items between lists, put the lists inside one `OrderListGroup`; `PickList` does this for two.

## Examples

### Basic

Choose a rule, then move it with the buttons or with Alt and the arrow keys; `renderItem` shows its priority.

```tsx
import { useState } from "react"
import { OrderList } from "@booleanpress/ui/order-list"

const RULES = [
  { id: "password-resets", name: "Password resets → Postmark" },
  { id: "receipts", name: "Receipts → Amazon SES" },
  { id: "invoices", name: "Invoices → Amazon SES" },
  { id: "sign-in-codes", name: "Sign-in codes → Postmark" },
  { id: "newsletters", name: "Newsletters → Mailgun" },
  { id: "digests", name: "Weekly digests → Mailgun" },
  { id: "staff-alerts", name: "Staff alerts → SMTP relay" },
  { id: "fallback", name: "Everything else → SMTP relay" },
]

export default function OrderListBasic() {
  const [rules, setRules] = useState(RULES)

  return (
    <OrderList
      header="Routing rules, first match wins"
      value={rules}
      onValueChange={setRules}
      renderItem={(rule, { index }) => (
        <span className="flex min-w-0 items-center gap-3 text-sm">
          <span className="w-4 shrink-0 text-end text-muted-foreground tabular-nums in-data-selected:text-current">{index + 1}</span>
          <span className="truncate">{rule.name}</span>
        </span>
      )}
      className="w-full max-w-sm"
    />
  )
}
```

### Drag and drop

`draggable`: drag an item to a new place, or press Enter on it, move it with the arrow keys and drop it with Space.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

const MAILERS = ["Amazon SES", "Postmark", "Mailgun", "SendGrid", "Brevo", "SMTP relay"]

export default function OrderListDragAndDrop() {
  return (
    <OrderList
      header="Mailer fallback order"
      defaultValue={MAILERS}
      draggable
      className="w-full max-w-xs"
    />
  )
}
```

### Drag handle

`dragHandle` shows a grip on each item; only the grip starts a pointer drag.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

const STEPS = [
  { id: "verify", label: "Verify the sender domain" },
  { id: "warm-up", label: "Warm up the dedicated IP" },
  { id: "import", label: "Import the contact list" },
  { id: "segment", label: "Build the first segment" },
  { id: "send", label: "Send the welcome series" },
]

export default function OrderListDragHandle() {
  return (
    <OrderList
      header="Onboarding checklist"
      defaultValue={STEPS}
      draggable
      dragHandle
      className="w-full max-w-sm"
    />
  )
}
```

### Multiple selection with checkboxes

`indicator="checkbox"`: a click toggles an item, and the Move buttons move every chosen item together.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

const COLUMNS = [
  { id: "recipient", label: "Recipient" },
  { id: "subject", label: "Subject" },
  { id: "status", label: "Status" },
  { id: "mailer", label: "Mailer" },
  { id: "opened", label: "Opened" },
  { id: "clicked", label: "Clicked" },
  { id: "sent-at", label: "Sent at" },
]

export default function OrderListCheckbox() {
  return (
    <OrderList
      header="Delivery log columns"
      defaultValue={COLUMNS}
      defaultSelected={["status", "mailer"]}
      indicator="checkbox"
      draggable
      className="w-full max-w-xs"
    />
  )
}
```

### Filter

`filter` narrows the items in view; moves act on them and leave the hidden ones in place.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

const SITES = [
  "shop.example.com",
  "blog.example.com",
  "docs.example.com",
  "status.example.com",
  "help.example.com",
  "careers.example.com",
  "events.example.com",
  "partners.example.com",
]

export default function OrderListFilter() {
  return (
    <OrderList
      header="Sites, in sending order"
      defaultValue={SITES}
      filter
      filterPlaceholder="Search sites"
      listClassName="max-h-56"
      className="w-full max-w-xs"
    />
  )
}
```

### Empty placeholder

With no items the list says so, in `empty` or the provider's `noItems` text.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

export default function OrderListEmpty() {
  return (
    <OrderList
      header="Escalation steps"
      defaultValue={[]}
      empty="No escalation steps yet"
      listClassName="h-40"
      className="w-full max-w-xs"
    />
  )
}
```

### Disabled

`disabled` greys the list and its buttons, keeps the order and takes the list out of the tab order.

```tsx
import { OrderList } from "@booleanpress/ui/order-list"

const QUEUES = ["Billing", "Technical support", "Sales", "Partnerships"]

export default function OrderListDisabled() {
  return (
    <OrderList
      header="Ticket queues (locked by your plan)"
      defaultValue={QUEUES}
      defaultSelected={["Sales"]}
      disabled
      className="w-full max-w-xs"
    />
  )
}
```

### Invalid

`aria-invalid` with the reason in a message that `aria-describedby` points to.

```tsx
import { useState } from "react"
import { OrderList } from "@booleanpress/ui/order-list"

const QUEUES = ["Billing", "Technical support", "Sales", "Partnerships"]

export default function OrderListInvalid() {
  const [moved, setMoved] = useState(false)

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <OrderList
        header="Ticket queues, first to last"
        defaultValue={QUEUES}
        onValueChange={() => setMoved(true)}
        aria-invalid={!moved}
        aria-describedby="order-list-invalid-error"
        className="w-full"
      />
      <p id="order-list-invalid-error" className="min-h-4 text-xs text-destructive-strong">
        {moved ? "" : "Put Billing last before you save the routing."}
      </p>
    </div>
  )
}
```

## Accessibility

**Semantics.** The list has `role="listbox"` with `aria-multiselectable="true"`; each item is a `role="option"` with `aria-selected`. The highlighted item is the list's `aria-activedescendant`. The Move buttons are buttons named from the provider; a move that cannot happen is `aria-disabled`, so the focus stays on its button. Every move, by button, by key or by drag, is read out in a polite live region: "Password resets moved to position 2 of 8".

**Labels.** Name the list with `header`, `aria-label` or `aria-labelledby`. The buttons are named Move to top, Move up, Move down and Move to bottom; the filter field Filter options and the header checkbox Select all; all from the provider.

**Focus.** The buttons, the filter field and the list are tab stops of their own. Focus on the list highlights the first chosen item, else the first; the highlight shows only while the list has focus. A click chooses and leaves focus on the list. A drag never moves the focus.

**Known limits.**

- A pointer drag moves one item; the buttons and Alt with the arrows move every chosen item.
- 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`.
- Alt with the arrow keys can be taken by a screen reader or the browser in some set-ups; the buttons always work (WCAG 2.5.7).

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↓ or ↑ | Move the highlight to the next or the previous item, stopping at the ends. |
| Home or End | Move the highlight to the first or the last item. |
| Space | Chooses the highlighted item, or un-chooses it. |
| Shift + ↓ | Moves the highlight and chooses every item from the last one chosen to it (Shift and ArrowUp, Home or End too). |
| Shift + Space | Chooses every item from the last one chosen to the highlighted one. |
| Ctrl + A | Chooses every item in view, or none when all are chosen (⌘ and A on a Mac). |
| Alt + ↑ | Moves the chosen items (or the highlighted one) one place up; Alt and ArrowDown one place down. |
| Alt + Home | Moves the chosen items to the top; Alt and End to the bottom. |
| Enter | With `draggable`, picks the highlighted item up; the arrow keys then move it, Space or Enter drops it and Escape puts it back. |
| A–Z | Type-ahead: moves the highlight to the next item whose label starts with the letters typed. |
| ↓ | In the filter field, moves the focus to the list. |

## API

### OrderList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `controls` | `"none" \| "start" \| "end"` | `start` | Where the Move buttons sit: before the list (default), after it, or nowhere. |
| `defaultSelected` | `string[]` |  | The items chosen at first, when the list controls them. |
| `defaultValue` | `readonly T[]` |  | The items in the order they start in, when the list controls itself. |
| `disabled` | `boolean` | `false` | Greys the list and its buttons, keeps the order, and takes the list out of the tab order. |
| `draggable` | `boolean` | `false` | Lets people drag items to a new place, with a mouse, a finger (press and hold) or the keyboard (Enter). |
| `dragHandle` | `boolean` | `false` | With `draggable`, shows a grip at the start of each item; only the grip starts a pointer drag. |
| `empty` | `ReactNode` |  | What the list says when it has no items. Defaults to the provider's `noItems` string. |
| `filter` | `boolean` | `false` | Shows a field above the list that keeps the items whose label contains its text. |
| `filterPlaceholder` | `string` |  | The filter field's placeholder. |
| `filterValue` | `string` |  | The filter's text, when you control it. |
| `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: what type-ahead and the filter match and what the announcements say. Defaults to the item itself for strings, else its `label`, `name` or `title`. |
| `header` | `ReactNode` |  | A title above the list that names it. |
| `indicator` | `"none" \| "checkbox"` | `none` | `checkbox` draws a checkbox on every item, ticked once chosen; a click then toggles an item. |
| `listClassName` | `string` |  | Classes for the scrolling list inside the frame, such as a height (`h-60`) or a maximum height. |
| `onFilterValueChange` | `((value: string) => void)` |  | Called with the filter's text as it is typed. |
| `onSelectedChange` | `((selected: string[]) => void)` |  | Called with the chosen items' keys, in list order. |
| `onValueChange` | `((value: T[]) => void)` |  | Called with the items in their new order after every move. |
| `renderItem` | `((item: T, state: OrderListItemState) => ReactNode)` |  | Draws an item's content. Defaults to its label. |
| `selectAll` | `boolean` | `false` | Shows a "Select all" checkbox above the list that chooses every item in view, or none. |
| `selected` | `string[]` |  | The chosen items' keys, when you control them. |
| `value` | `readonly T[]` |  | The items in their order, when you control it. Pair it with `onValueChange`. |

### OrderListGroup

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

**Data attributes:** `data-slot="order-list"` (OrderList), `data-slot="order-list-status"` (OrderListGroup), and `data-controls`, `data-disabled`, `data-drop-target`, `data-selected`, `data-highlighted`, `data-dragging`, `data-draggable`, `data-drop`.

**Provider strings:** `itemMoved`, `dragStarted`, `dragCancelled`, `itemsMoved`, `noItems`, `noResults`, `moveToTop`, `moveUp`, `moveDown`, `moveToBottom`, `selectAll`, `filterOptions`, `dragHandle` (`BooleanUIProvider`'s `strings`).

## Theming

The frame takes the field tokens: `--field`, `--control` for the edge, `--ring` while the list has focus, `--invalid` when invalid and `--field-disabled` when disabled. Items use `--accent` under the pointer and while highlighted, and `--highlight` once chosen (`--highlight-focus` both). The buttons are the small secondary icon buttons. A dragged item lifts onto `--popover` with the overlay shadow; its place stays at half strength.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--control` | border, background |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--foreground` | text |
| `--highlight` | background |
| `--highlight-focus` | background |
| `--highlight-foreground` | text, border |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--popover` | background |
| `--popover-foreground` | text |
| `--primary` | background, border |
| `--primary-foreground` | text |
| `--ring` | border |
| `--secondary` | background |
| `--secondary-foreground` | text |
