Skip to content

[listbox] Stripped-back Listbox preview (do not merge) - #6173

Draft
jjenzz wants to merge 35 commits into
mui:masterfrom
jjenzz:listbox-strip-back
Draft

jjenzz wants to merge 35 commits into
mui:masterfrom
jjenzz:listbox-strip-back

Conversation

@jjenzz

@jjenzz jjenzz commented Nov 4, 2026

Copy link
Copy Markdown
Member

Draft PR to get Netlify and pkg.pr.new previews of a stripped-back Listbox. Not for merging.

It's the Listbox from mui/base-ui#5115 with my DX review changes on top. The changes, and the reasoning for each, are in michaldudak/base-ui#4, so please review there. This PR only exists for the previews and will be closed once they've been shared.

Docs preview: the Listbox page under Components.

🤖 Generated with Claude Code

michaldudak and others added 13 commits June 24, 2026 13:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Import the Vitest globals explicitly, stop shadowing the global `test`, and
remove the `flushMicrotasks()` calls that directly follow `await render()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use top-level type-only imports, replace the inline `import()` type in the
store, and wait for a single condition in the drag-and-drop focus test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Master's API docs generator no longer reads enum data attributes, which dropped
the List and Item data attribute tables. Use ESM constants and export the
namespaces from the entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strip Listbox back to a core selection component. Drag-and-drop and
keyboard reordering will ship separately.

- Remove Listbox.DragAndDropProvider and Listbox.ItemDragHandle
- Remove Alt+Arrow keyboard reordering and its Item state
  (dragging, dropTarget, dropTargetEdge) and data attributes
- Remove the delayed highlight reporting that only existed for reorders
- Drop the @atlaskit/pragmatic-drag-and-drop dependencies
- Remove the drag-and-drop, drag-within-groups and custom-shortcuts demos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strip Listbox back to a core selection component. Infinite loading
will ship separately.

- Remove Listbox.LoadingTrigger
- Remove the loading and onLoadMore props from Listbox.Root
- Remove the lazy-loading demo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace `selectionMode` with the `multiple` prop used by Select,
Combobox, ToggleGroup and Accordion, and type the value the same way
as Select: a single value by default, an array when `multiple` is set.

`multiple` accepts `boolean | 'opt-in'`:

- `true`: clicking an item (or pressing Space) toggles it and
  Shift+Click selects a range, following the APG listbox's recommended
  multiple selection model.
- `'opt-in'`: the former 'explicit-multiple' behavior. Pressing an item
  replaces the selection, Ctrl/Cmd+Click and Ctrl+Space add or remove an
  item, Shift+Click selects a range, and Ctrl/Cmd+Shift+Click adds a
  range. Arrow keys behave as in `true`, so this doesn't yet implement
  the full APG alternative multiple selection model.

Also removes the unprefixed `SelectionMode` type export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Match the highlight callback used by Combobox and Autocomplete:
`onItemHighlighted(itemValue, eventDetails)` with `undefined` when no
item is highlighted, and event details carrying the `reason`
('keyboard' | 'pointer' | 'none'), the native `event` and the item
`index`.

The highlighted item's DOM element is no longer passed to the callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
@pkg-pr-new

pkg-pr-new Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

commit: 2e16cac

@code-infra-dashboard

code-infra-dashboard Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+15.9KB(+3.07%) 🔺+4.59KB(+2.74%)

Details of bundle changes

Performance

Total duration: 433.61 ms -19.27 ms(-4.3%) | Renders: 76 (+0) | Paint: 806.64 ms -18.34 ms(-2.2%)

Test Duration Renders
Select open (500 options) 32.64 ms 🔺+7.16 ms(+28.1%) 14 (+0)
Scroll Area mount (300 instances) 35.96 ms ▼-12.83 ms(-26.3%) 3 (+0)

13 tests within noise — details

Metric alarms

Test Metric Change
Select open (500 options) bench:paint 🔺 +16.21 ms
Select open (500 options) bench:paint#select-open 🔺 +16.21 ms

Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 95114a3
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6aedd60925d9a23bfaf25bbc
😎 Deploy Preview https://deploy-preview-6173--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@jjenzz
jjenzz force-pushed the listbox-strip-back branch 11 times, most recently from cb646b2 to 9846025 Compare November 4, 2026 21:09
@jjenzz
jjenzz force-pushed the listbox-strip-back branch from 9846025 to 6652cce Compare November 4, 2026 21:17
@jjenzz
jjenzz force-pushed the listbox-strip-back branch 3 times, most recently from bc2ae57 to 87b012a Compare November 5, 2026 17:08
jjenzz and others added 5 commits November 5, 2026 17:30
`highlightValue(value, element)` restored the highlight after an
external reorder, where the index-based highlight would otherwise stay
on the old position. Reordering is no longer part of Listbox, and
nothing else needs to move the highlight imperatively, so remove the
action and the DOM-settle helper it relied on.

A later commit, "Keep the highlight on the focused item when items are
reordered", handles external reorders without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Follow the APG listbox pattern, which describes a selection widget and
says nothing about form submission or validation. Remove the hidden
inputs, Field and Form integration, browser autofill handling, and the
`name`, `required`, `inputRef` and `itemToStringValue` props.
`Listbox.Label` keeps labelling the list but no longer exposes Field
validity state.

Form support can be added back later without a breaking change.

The usage guidelines now point to Select for selection in forms,
instead of describing Listbox as "unlike Select".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The APG listbox pattern moves focus to the next or previous option
without wrapping, unlike the menu pattern, which allows it as an
option. Select doesn't wrap either, and native list boxes on macOS
stop at the first and last items. Remove the prop and keep focus at
the ends of the list. An opt-in can be added later if needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
In Listbox, the part's only effect was to narrow the text used for
typeahead, which the Item's `label` prop already overrides. Select
needs its ItemText to align the selected item with the trigger, but
Listbox has no trigger. Like Menu, typeahead now matches the item's
rendered text unless `label` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Root renders no element, so its `id` only prefixed the ids generated
for the List and Label. Select and Combobox have a Root `id` for Field
and form integration, which Listbox no longer has. Like Dialog, Popover
and Menu, set the id on the part that renders the element instead:
`Listbox.List` accepts an `id`.

To keep a custom List id working, `Listbox.Label` now focuses the list
element the Root already holds when it's clicked, instead of looking
the list up by an id read when the Label rendered. That lookup broke
click-to-focus when the List's `id` changed after mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
@jjenzz
jjenzz force-pushed the listbox-strip-back branch 4 times, most recently from 6044128 to 8627d21 Compare November 6, 2026 13:13
jjenzz and others added 17 commits November 6, 2026 13:26
Give each selection mode its own example:

- The hero shows single selection as a "Move to" list of folders,
  where the selection is the target of an action rather than a value
  submitted with a form.
- "Multiple selection" lets you choose your favorite animals.
- A new "Opt-in multiple selection" section and demo shows `multiple="opt-in"`
  with a playlist of songs and artists. The description says to use it
  when selecting more than one item requires a modifier key, without
  comparing it to a particular app. Selected tracks get a row
  background instead of a check icon, one grey step darker than the
  highlight, following ToggleGroup's hover and press greys, with a small
  gap between rows. The section
  also says that on touch devices, which have no modifier keys, a tap
  adds or removes an item like `multiple`. That's the planned
  behavior; the implementation follows separately.

The hero and multiple selection demos show one line of text per item,
with no groups.

The lists' focus outline now uses the dark outline from the other
demos (white in dark mode) instead of a blue that looked like the
browser default.

The docs no longer compare `multiple="opt-in"` to a file manager. File
explorers can need buttons or menus inside items, which listbox options
can't contain, and we may build higher-level primitives for those use
cases, so the docs shouldn't point people at Listbox for them.

Both selection sections are kept to a sentence or two, like the
`multiple` sections in Select, Combobox, ToggleGroup and Accordion. The
demos show the interaction, and the `multiple` prop's API reference
describes the keys in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Touch has no modifier keys, so with `multiple="opt-in"` every tap
replaced the selection and touch users could never select more than
one item. A tap from a touch pointer now adds or removes the item, the
same as `multiple`, as the docs describe. Mouse clicks and keyboard
presses keep the opt-in behavior.

The Item records the pointer type of the last press and treats a click
that follows a touch press as a toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
With `multiple="opt-in"`, moving focus with an arrow key and no
modifier now moves the selection to the focused item, and Ctrl/Cmd plus
an arrow key moves focus without changing the selection. Together with
the existing Ctrl+Space toggle and Shift+arrow range behavior, this
matches the required keys of the APG listbox's alternative multiple
selection model.

The APG doesn't say what happens when the focused item is disabled.
Following its wording, moving focus unselects everything else, and a
disabled item can't be selected, so moving onto one clears the
selection.

Home, End and typeahead, which the APG lists as optional or
recommended, keep their existing behavior and don't change the
selection. Plain `multiple` and single selection are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Shift+Arrow toggled the item focus moved to, so reversing direction
deselected the items you came back to and left the far end selected.
It now works like the APG listbox's multi-select example: the item
focused when Shift is pressed is the anchor, and Shift+Arrow selects
the range from the anchor to the focused item, replacing the rest of
the selection, so the range grows and shrinks as focus moves.

Releasing Shift and moving focus, or pressing an item with the
pointer, starts a new range from wherever focus is when Shift+Arrow is
next pressed. This applies to `multiple` and `multiple="opt-in"`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The APG's Control modifier for multiple selection doesn't work as-is on
a Mac: macOS reserves Ctrl+Space (switch input source), Cmd+Space
(Spotlight) and Ctrl+arrows (Mission Control and Spaces), and Cmd+arrows
conventionally jump to the start or end. The APG recommends swapping in
macOS key assignments to avoid system conflicts, and React Aria uses
Option for these keys.

On macOS:
- Option+Space adds or removes the focused item in multiple selection,
  matching on the physical Space key because Option+Space types a
  non-breaking space.
- In `multiple="opt-in"`, Option+arrows move focus without changing the
  selection, so the move and the toggle use the same modifier. Cmd+arrows
  no longer do this.

Cmd+click, Cmd+Shift+click, Cmd+Shift+Home/End and Cmd+A keep using
Cmd, the Mac's usual command modifier. Other platforms are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The highlight followed hover and keyboard events separately, so it could
stay on an item after focus left the list, disappear from the focused item
when the pointer left it, and hovering took focus from other controls.

Following the APG, the highlighted item is now the focused item:
- When focus leaves the list, nothing stays highlighted. The list still
  keeps the item as the tab stop.
- Moving the pointer off the list no longer clears the highlight from the
  focused item.
- Hovering only moves focus and the highlight once focus is in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The tab stop stayed on the last focused item, so tabbing back into the list
could land on an unselected item. The APG listbox pattern says focus goes
to the first selected option, or the first option when nothing is selected.

When focus leaves the list, the tab stop now moves to the first selected
item, or to the first item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Hovering stopped highlighting items when focus was elsewhere, since it no
longer takes focus from other controls. The hovered item is now highlighted
without moving focus until focus enters the list, and the highlight clears
when the pointer leaves the list. Once an item has focus, hovering moves
focus and the highlight together.

When focus enters the list, the highlight moves to the focused item. When
focus leaves, nothing stays highlighted. In both cases the item under the
pointer is highlighted again once the pointer moves.

In opt-in mode, hovering never moves focus, since plain arrow keys move the
selection from the focused item. A key press returns the highlight from the
hovered item to the focused item until the pointer moves again, so only one
item is highlighted at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The list container and the active item were both tab stops, and no item was
one until the list had focus. Tabbing in landed on the container with
nothing highlighted, so Home did nothing and the first ArrowDown skipped the
first item, and Tab then stopped again on the item.

Following the APG, the first selected item (or the first item) is now the
only tab stop from the start, and it follows the selection while the list
doesn't have focus. The container leaves the tab order but can still receive
focus programmatically, such as from a Listbox.Label click, and passes focus
on to that item. An empty list keeps focus on the container.

The demos now show the list outline with :has(:focus-visible), since the
container itself no longer holds focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
…ered

Reordering the items while one has focus left the tab stop at the focused
item's old index. The highlight moved to whichever item took that index,
and the next arrow key started from there, so it could seem to do nothing.
The tab stop now follows the focused item to its new index.

Found while checking that the Listbox composes with dnd-kit. Adds dnd-kit
as a dev dependency for Chromium tests that drag an item with the pointer
and move one with Alt+Arrow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Escape reported `list-navigation`, the reason for arrow-key moves.
Combobox reports `escape-key` when Escape clears its selection, and the
popups use it when Escape closes them, so use it here too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Ctrl/Cmd+A deselected everything when every item was already selected.
That felt unexpected: Ctrl/Cmd+A should select everything, which is
also what React Aria's listbox does. The APG makes the toggle optional,
and Escape already clears the selection, so Ctrl/Cmd+A now always
selects every item. We can think about deselecting separately if people
need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Range selections reported `item-press` or `list-navigation`, the same as
selecting one item, so consumers couldn't tell them apart. A virtualized
list needs to, to remove disabled items it hasn't rendered.

Range selections keep the reason for what caused them, `item-press` for
Shift+Click and `list-navigation` for Shift+Arrow, Ctrl/Cmd+A and
Ctrl+Shift+Home/End, and add an optional `range` field, like Combobox's
optional `isItemPress`:

- `from` and `to` are the first and last items in the range, inclusive,
  whichever way the user extended it, so consumers don't need to sort
  them. Ctrl/Cmd+A reports `0` to the last index.
- `mode` is `'replace'` when the range becomes the selection (Shift+Click,
  Shift+Arrow and Ctrl/Cmd+A), and `'add'` when it's added to the
  selection (Ctrl/Cmd+Shift+Click in opt-in mode, and
  Ctrl+Shift+Home/End). A string rather than a boolean leaves room for
  other modes, such as deselecting a range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
`useTypeahead` came from Floating UI, which builds it for popups: it
read `open` from the popup's store to stop typing keys while open, reset
the typed string when the popup opened or closed, and kept the typing
session while focus moved between the trigger and the popup. So a
component without a popup, like Listbox, couldn't use it.

- `useTypeahead` now holds only the matching: the typed string, the
  reset timer, where each search starts, and cycling through items that
  start with the same letter. It takes plain props and returns the
  handler.
- `usePopupTypeahead` wraps it with the popup behaviour above, and
  returns the same `{ reference, floating }` props as before. Select,
  Menu and the Combobox trigger use it, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
Each key searched from the item after the focused one, so a search
could skip the item it should land on: typing "ba" moved past "Banana"
to "Bacon". Typing the same letter twice searched for "bb" instead of
cycling, and Space always selected, even mid-search.

The Listbox now uses the shared `useTypeahead`, like Select and Menu:

- Keys typed in quick succession build one search from where it began,
  and it resets after a pause.
- Typing the same letter again cycles through the items that start with
  it.
- Space continues a search in progress instead of selecting.
- Typeahead lands on disabled items, the same as arrow keys, since it
  only moves focus here. Select skips them because typeahead there
  changes the value.

Typeahead runs in the capture phase on the list, so a typed key doesn't
reach the item it moved focus away from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
The Listbox only knew the items that were rendered and counted their
indexes from the first rendered item. With a virtualizer such as
TanStack Virtual, arrow keys and End stopped at the last rendered row,
typeahead couldn't find rows that weren't rendered, and Ctrl+A or a
Shift range only selected the rendered rows.

Pass every item to `Listbox.Root` with `items`, like Combobox, and use
each item as its `Listbox.Item` value:

- Each rendered item finds its position by looking up its value in
  `items`, so positions don't depend on what's rendered.
- Arrow keys, Home and End move through every item. When the next item
  isn't rendered, `onItemHighlighted` reports it so the consumer can
  scroll to it. The list holds focus until the item renders, then the
  item takes it. In opt-in mode, it's also selected then, so the
  selection still follows focus.
- Typeahead matches the labels of every item. `itemToStringLabel` was
  stored but never read; it now labels object items, with the same
  `{ value, label }` default as Combobox.
- Ctrl+A and ranges select across every item, so `onValueChange={setValue}`
  is enough. The listbox only knows which rendered items are disabled,
  so a list with disabled items filters them out of changes with a
  `range`.
- Tab reaches the selected item even when it isn't rendered. While the
  item holding the tab stop isn't rendered, the list takes its place in
  the tab order.

Unlike Combobox, there's no `virtualized` prop: positions come from
`items` whenever it's passed, and the Listbox has no popup scroll to
reset. There's no `index` prop on items either, since the position comes
from the item's value.

`aria-setsize` and `aria-posinset` are left to the consumer: browsers
work them out when every item is rendered, and the right values in a
grouped list depend on the groups.

Adds `@tanstack/react-virtual` as a dev dependency for the Chromium
tests, and a Virtualized demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
`onValueChange` fired whenever a key or press could change the
selection, even when it didn't: arrowing onto a disabled item in opt-in
mode, or Ctrl+A on a list that's already fully selected. It now only
fires when the selection changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4ErZTmoV8Xv9HpXPSHqQu
@jjenzz
jjenzz force-pushed the listbox-strip-back branch from 8627d21 to 95114a3 Compare November 6, 2026 13:28

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: listbox poc PR: out-of-date The pull request has merge conflicts and can't be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants