# Dual listbox

Choosing several options from a long set and seeing both at once: what's available on one side, what's chosen on the other, with buttons between to move options across. For a few options, use checkboxes; for one, a select.

Each side is a listbox where several options can be selected before moving them: Up and Down move, Space selects or clears the option, Shift with Up or Down extends the selection, Home and End jump to the ends, and Enter moves the selected options (or, with none selected, the one under the cursor) to the other side. A double click moves one. The buttons say what they move and how many ("Add 3 to Chosen"), and one that can't act is locked with the reason ("Choose options in Available first", "Chosen is full: 5"). Nothing is chosen at first unless the app says so.

After a move, focus stays where it was: in the list, on the option after the last one moved (or the one before, at the end, or on the list itself when it is empty), or on the button pressed. The move is announced to screen readers ("Added 3 to Chosen. 5 chosen."). Options' names and descriptions are the app's or another tenant's text: drawn as text, never HTML, isolated, with bidi controls shown as marks. The buttons' arrows point from the available side to the chosen side, so they mirror right to left.

- Registry name: `dual-listbox` (`@candor/dual-listbox`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/dual-listbox

## Install

```bash
npx candor-ui add dual-listbox
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/dual-listbox`.
It brings in: bidi, button, input, locale, locked, tokens, use-announcer, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { DualListbox } from "@/components/candor/ui/dual-listbox"
import { TooltipProvider } from "@/components/candor/ui/tooltip"

const tools = [
  { id: "read", label: "Read files", description: "Open and search files in the project" },
  { id: "write", label: "Write files", description: "Create and change files in the project" },
  { id: "run", label: "Run commands", description: "Run shell commands in the sandbox" },
  { id: "net", label: "Use the network", description: "Fetch pages and call APIs" },
  { id: "mail", label: "Send email", description: "Send messages from the shared inbox" },
  { id: "tickets", label: "Update tickets", description: "Change a ticket's status and owner" },
  { id: "calendar", label: "Book meetings", description: "Add events to people's calendars" },
]

// Select options with Space (Shift with the arrows to extend), then Enter or a button to move them. The buttons say
// what they move and how many, and say why when they can't. Nothing is chosen until the person chooses.
export default function Example() {
  const [allowed, setAllowed] = React.useState<string[]>([])
  return (
    <TooltipProvider>
      <div className="w-full max-w-3xl">
        <DualListbox
          options={tools}
          value={allowed}
          onValueChange={setAllowed}
          labels={{ available: "Tools", selected: "Allowed" }}
          max={4}
          filter
        />
      </div>
    </TooltipProvider>
  )
}
```

## <DualListbox>

Props:

- `options: DualListboxOption[]`: Every option, in the order the available side lists them.
- `value?: string[] | undefined`: The chosen options' ids, in order, when the app controls them.
- `defaultValue?: string[] | undefined`: The ids chosen at first, when the list keeps its own state. Left out, nothing is chosen.
- `onValueChange?: ((ids: string[]) => void) | undefined`: Called with the chosen ids, in order, after each move.
- `labels?: { available?: string | undefined; selected?: string | undefined } | undefined`: The two sides' names. Default "Available" and "Chosen".
- `max?: number | undefined`: The most options that can be chosen. Adding past it is locked, and says why.
- `filter?: boolean | undefined` (default `false`): Shows a field that filters the available side by name.
- `locked?: string | null | undefined`: Why the choice can't be changed right now. Both lists stay readable and focusable; nothing moves, and the buttons say why.
- `className?: string | undefined`: Classes for the outer box.

## Strings (`dualListbox`)

The fixed words, English by default. Override any of them under `dualListbox` in `<LocaleProvider strings={{ dualListbox: { … } }}>`; the table is exported as `dualListboxStrings`.

- `available`: Available
- `selected`: Chosen
- `heading`: {list} ({count})
- `headingFiltered`: {list} ({count} of {total})
- `filter`: Filter {list}
- `add`: Add
- `addCount`: Add {count}
- `addAll`: Add all
- `addShown`: Add {count} shown
- `remove`: Remove
- `removeCount`: Remove {count}
- `removeAll`: Remove all
- `addName`: Add to {list}
- `addCountName`: Add {count} to {list}
- `addAllName`: Add all to {list}
- `addShownName`: Add {count} shown to {list}
- `removeName`: Remove from {list}
- `removeCountName`: Remove {count} from {list}
- `removeAllName`: Remove all from {list}
- `chooseInAvailable`: Choose options in {list} first
- `chooseInSelected`: Choose options in {list} first
- `availableEmpty`: Nothing is left in {list}
- `selectedEmpty`: Nothing is in {list}
- `full`: {list} is full: {max}
- `tooMany`: { one: "Only {count} more fits in {list}", other: "Only {count} more fit in {list}" }
- `added`: { one: "Added {count} to {list}.", other: "Added {count} to {list}." }
- `removed`: { one: "Removed {count} from {list}.", other: "Removed {count} from {list}." }
- `total`: { one: "{count} chosen.", other: "{count} chosen." }

## Types

### DualListboxOption

One option a `DualListbox` offers: an id the app keeps and the words the person reads.

```ts
{
  /** The option's id, reported in `onValueChange`. */
  id: string;
  /** The option's name, shown and read. Drawn as text, isolated, with bidi controls shown as marks. */
  label: string;
  /** A line under the name, read after it. Drawn as text the same way. */
  description?: string | undefined;
}
```

## Motion

- **tint** (tint): An option's background changes as it is selected or the pointer moves over it.

Set with `--cd-motion-dual-listbox-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
