facebook / facebook/astryx

[RFC] Listbox + TransferList — inline selectable lists for complex multi-item selection

Open
#3,281 1 comment 0 reactions 1 assignee Claimed by @ernestt View on GitHub
component enhancement
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

> Follows the Astryx Component Lifecycle (Phase 1: Specification). This is a problem statement and demand signal with front-loaded research and a candidate API — final API shapes run through API Arbitration / vibe tests.

---

## Problem Statement

Builders need to expose a set of options **inline, always visible, in a bordered container**, and let users **select one or many** — and for the hardest cases, **manage an ordered set**: move items between an "available" pool and a "selected" list, reorder the selected list by dragging, lock specific items so they can't be removed or moved, and search across everything.

This maps directly to the [NN/g Listbox vs. Dropdown distinction](https://www.nngroup.com/articles/listbox-dropdown/): a **listbox exposes its options immediately and supports multi-selection**, whereas a dropdown hides options behind a click and is single-select. NN/g explicitly names the advanced forms — the **multiselect listbox** and the **multiselect dual listbox**, where users "make selections by moving items from one listbox to another" and "reorder the options by moving them up and down." That dual-listbox is precisely the "complex selection" pattern we lack.

Astryx today only offers *collapsed* selection or *flat* inline checkboxes:

- `Selector` / `MultiSelector` — options live behind a dropdown trigger; you must open them to see or change anything. Single value or a flat checkbox set. No reorder, no per-item affordances, no two-list transfer.
- `CheckboxList` — inline and always-visible, but flat: no reordering, no per-item controls, no add/remove-between-lists, no selection summary, no single-select mode.

There is **no primitive** for the always-visible selectable list, and **nothing** for the dual-list "configure an ordered set" editor. Teams rebuild the latter by hand every time — wiring a drag library, custom row chrome, ad-hoc keyboard handling, and `listbox`/`option` ARIA that is almost always wrong.

## Evidence of Demand

**The motivating pattern (from the attached reference):** a list-management editor in a dialog — a search field over two side-by-side panels, **"Selected items"** (with per-row drag handle + remove ×, plus a "Remove all" bulk action) and **"Available items"** (with per-row add +, plus an "Add all" bulk action), one row locked ("irremovable") so it can't be reordered or removed, and a staged **Cancel / Apply** footer. The trigger summarizes state as "7 items selected".

This is a generalized **dual/transfer listbox**. It is the standard way to configure an ordered subset of options — it recurs across:

- **Column / field settings editors** — choose which columns are shown, order them, lock required ones. (The motivating case; builders rebuild this per surface as bespoke editors in data-heavy products.)
- **Display / preference panels** — pick and order dashboard widgets, sidebar sections, notification channels.
- **Layer / track panels** — design and editor tools: a reorderable, toggleable list.
- **Permission / member pickers** — move people/roles between "available" and "granted", preserving order.

**External precedent (the industry ships these as distinct primitives):**

| System | Inline listbox | Dual / transfer |
|---|---|---|
| WAI-ARIA APG | `Listbox` (incl. scrollable, **rearrangeable** examples) | "Listbox with grouped options" + dual-listbox guidance |
| MUI | `List` + `ListItem` w/ `secondaryAction` | **Transfer List** demo |
| Ant Design | `List` w/ selection | **`Transfer`** component (first-class) |
| Angular CDK/Material | `cdk-listbox`, `cdkDropList` (drag-reorder) | composed transfer |
| Radix / shadcn | `ToggleGroup` + composed rows | composed |

**Frequency:** any settings/configuration surface that manages an ordered set of options — the same place `CheckboxList` shows up, but where flat checkboxes can't express order, transfer, or locking.

## Why Existing Components Don't Cover This

- **`MultiSelector` / `Selector`** — wrong interaction model per NN/g: options are *collapsed* behind a trigger. No inline exposure, no reorder, no transfer, no per-item controls.
- **`CheckboxList`** — closest inline primitive, but: no reorder, no per-item end-content controls (lock/remove/add), no add/remove-between-lists, no single-select, no selection summary or bulk select-all/clear as first-class API.
- **`CheckboxList` + a drag library + custom rows** — what teams do today. Re-implements selection state, ordered-set bookkeeping, keyboard nav, `listbox`/`option` ARIA, drag-and-drop a11y (the part hand-rolled versions consistently miss), bulk actions, search, and locked-item treatment by hand — none themed, none consistent.

The gap is two related primitives: a **`Listbox`** (inline, always-visible, single or multi, `role="listbox"`) and a **`TransferList`** (the dual-listbox the reference shows) built on it.

## Architecture (grounded in a proven implementation)

The reference editor's architecture is worth porting *as value*: one **ordered array of selected values is the single source of truth**. Everything derives from it:

- Toggling an item adds/removes its value from the selected array (which list it lives in is derived, not stored separately).
- Reordering calls a move (`from → to` index) that reorders the selected array.
- The available side can be flat or **grouped** (section labels + a remainder group).
- Disabled/locked items still render in their list but can't be toggled or dragged — shown with a distinct "locked" affordance instead of the remove control.
- A single search filters both panels.

Porting the *value, not the structure*: OSS gets a clean controlled `value`/`onChange` contract over that ordered array, rather than a multi-callback shape.

## Candidate API

Two components; `TransferList` composes `Listbox`. Naming (`Listbox` vs `SelectList` vs `OptionList`; `TransferList` vs `DualListbox`) defers to API Arbitration if contested.

### Shared option shape (mirrors the existing Selector family)

```ts
type ListOption = {
value: string;
label?: string; // defaults to value
sublabel?: string; // proven useful in the reference editor
icon?: ReactNode | IconType;
disabled?: boolean; // "locked / irremovable": stays visible, not toggleable/draggable
tooltip?: ReactNode;
};
// Sections/dividers reuse Selector's existing shapes — no new Separator.
type ListOptionType = string | ListOption | SelectorDivider | SelectorSection;
```

### `Listbox` — inline single/multi list (the base primitive)

```tsx
...} // omit if order isn't consumer-managed
density="balanced" // 'compact' | 'balanced' | 'spacious' (matches CheckboxList)
/>
```

`ListboxItem` is available for the composed/rich-row case (custom `endContent`, e.g. a lock toggle), following the `options`-vs-`children` split that `Selector` (data) and `CheckboxList` (children) already use:

```tsx

} />
{/* locked */}

```

### `TransferList` — dual listbox (the reference; the "complex selection" component)

```tsx
...}
onClear={() => ...}
onRestoreDefault={() => ...} // shows "Restore" on the selected panel

// disabled item => "irremovable": locked affordance, not removable/draggable
isOptionDisabled={(value) => requiredKeys.has(value)}

// empty-state copy (defaults provided)
selectedEmptyText="Select at least one item"
availableEmptyText="All items added"
noResultsText="No items found"
/>
```

**Behavioral notes baked into the API:**
- `value` is the **ordered** selected array; `onChange` fires for both toggles and reorders so the consumer always has the authoritative order. (No separate `onReorder` needed at the `TransferList` level — order lives in `value`.)
- Available-side **grouping** uses `SelectorSection` shapes; a remainder group label is configurable.
- **Locked items** (`isOptionDisabled` / `disabled`) render with a distinct locked icon in place of remove, and are excluded from drag — matching the reference's "irremovable item".
- The **trigger + dialog + Apply/Cancel** (staged commit, "N items selected" summary) is *composition*, not part of `TransferList` — show it as a composition story (`Popover`/`Dialog` + summary `Button`), so the component stays presentational and the host owns commit semantics.

## Use Case Enumeration (Spec Phase 4)

| Case | Listbox | TransferList |
|---|---|---|
| Simple/default | Multi-select inline list, `label`+`options` only | Pool + selected, defaults for all labels/empties |
| Configured | icons, sublabels, sections, density, single vs multi | grouped available side, custom labels, bulk actions |
| Controlled | consumer owns `value` (+ order) | consumer owns ordered `value` |
| Composed | inside `Card`, `AppShell` sidebar, `Dialog` | inside `Popover`/`Dialog` with summary trigger + Apply/Cancel |
| Edge/mixed | reorder + select; select-all/clear; locked items; empty | locked items, search-empties on both sides, all-added state |
| Migration | adopt over a hand-rolled CheckboxList | delete a bespoke drag+transfer editor rig |

## Surface Area Audit (Spec Phase 6 — preliminary)

- `Listbox` — new; no inline `role="listbox"` primitive exists (Selector/MultiSelector are dropdowns; CheckboxList is flat). Justified.
- `TransferList` — new; no dual-list primitive exists. Justified; composes `Listbox`.
- `ListboxItem` — overlaps `CheckboxListItem` / `ListItem`. **Open question:** should it *compose* `ListItem` (which already supports `image`/`endContent` slots) rather than be a new element?
- Dividers/sections — reuse `Selector`'s `{type:'divider'}` / `{type:'section'}`; **do not** add a `Separator`.
- Drag-reorder — should be a **shared capability** (e.g. a `useReorderable` hook) usable beyond these components, not bespoke. Note: a robust **keyboard reorder** (grab/move/drop with `aria-live` announcements) is a hard requirement and a known weak spot of existing drag implementations — call it out as core scope, not a nice-to-have.

## Accessibility Considerations

- **Pattern:** [WAI-ARIA `listbox`](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/). Container `role="listbox"` (`aria-multiselectable` when multi); items `role="option"` with `aria-selected`. `TransferList` is two labeled listboxes with move controls.
- **Keyboard:** Arrow Up/Down roving focus; Space toggles/moves the focused option between lists; multi-select extend per APG; **reorder must have a keyboard path** (focus handle → Space grab → Arrow move → Space drop) with `aria-live` position announcements.
- **Labeling:** required `label` (mirrors `Selector`/`MultiSelector`/`CheckboxList`); each panel labeled; locked items expose why they're locked (tooltip / `aria-disabled` + description).
- **Who benefits:** keyboard and screen-reader users get real, navigable, announce-on-change lists and transfers instead of mouse-only div grids.

## Performance Considerations

- Typical settings editor N ≈ 5–50: plain rendering is fine.
- Large pools (hundreds, e.g. big transfer lists): virtualization should be considered — flag as follow-up, not a v1 blocker.
- Keep rows lean (no nested wrapper divs per the authoring guide); drag should transform existing nodes, not remount the list.

## Open Questions for Maintainers

1. `Listbox` (`options` data) vs `children` (`ListboxItem`) vs both — match builder expectations via vibe test.
2. Is `TransferList` a separate component (recommended — matches the reference and external precedent) or a `variant="transfer"` of `Listbox`?
3. Should `ListboxItem` compose existing `ListItem` rather than introduce a new primitive?
4. Is single-select in scope for `Listbox` v1, or multi-only first?
5. Naming: `Listbox`/`SelectList`/`OptionList` and `TransferList`/`DualListbox` — defer to API Arbitration if contested.

## Pre-submission Checklist

- [x] Read the Contributing guide
- [x] Read the API Conventions
- [x] Checked that existing Astryx components cannot compose to solve this
- [x] This is a general-purpose UI pattern (not specific to one product)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.