List Box

A keyed, selectable flat collection.

Supports drag and drop. See the shared DnD guide for lifecycle, callbacks, and transaction rules.

Try it

Interact with the control below. This preview runs the exact code sample shown on this page.

Open example separately ↗

Basic use

Radix · integration snippet
use gpui::{Context, Render, Window, div, prelude::*};
use xgpui_luma::controls::listbox::{
    ListBoxControl, ListBoxInput, ListBoxState, ListBoxError, SelectionMode, make_listbox_item_template,
};
use xgpui_luma_look_radix as radix;

struct Example {
    list: ListBoxControl<Self, String, String>,
    selected: Vec<String>,
    last_opened: Option<String>,
}

impl Example {
    fn initial_state() -> Result<ListBoxState<String, String>, ListBoxError> {
        ListBoxState::try_new(
            ["Draft".to_string(), "Published".to_string()],
            |row| row.clone(),
            SelectionMode::SingleRequired,
        )
    }

    fn new(state: ListBoxState<String, String>, cx: &mut Context<Self>) -> Self {
        let list = ListBoxControl::new(state, Self::on_input, |key| key.clone().into(), |row| row.clone().into(), cx)
            .on_selection_changed(cx, |owner: &mut Self, selected, _active, cx| {
                owner.selected = selected.to_vec();
                cx.notify();
            })
            .on_item_activated(cx, |owner, key, cx| {
                owner.last_opened = Some(key.clone());
                cx.notify();
            });
        Self { list, selected: Vec::new(), last_opened: None }
    }

    fn on_input(&mut self, input: ListBoxInput<String>, _: &mut Window, cx: &mut Context<Self>) {
        self.list.apply(input, cx);
        cx.notify();
    }
}

impl Render for Example {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let template = make_listbox_item_template(
            |model: &xgpui_luma::controls::listbox::ListBoxItemRenderModel<'_, String>, _| {
                div().child(model.item.clone())
            },
        );
        radix::ListBoxBuilder::new("items", &mut self.list, template).build(window, cx)
    }
}

SDK behavior & customization

Keep ListBoxControl state persistent. Hosts compose content, viewport, and event delivery.

Relevant contracts: ListBoxItemRenderModel, ListBoxItemTemplate.

Use the module’s template contract for structural changes. Builder modifiers and item templates vary by control.

Events

Register named handlers on the persistent ListBoxControl. Each receives the owner, the payload below, and the owner context. Use on_event for the full ListBoxEvent stream.

Control methods commit state and queue callbacks automatically. Delivery runs after the current owner borrow ends, in event order and handler registration order; callbacks can safely update the list. Owners are held weakly, and handlers live with the control.

EventCallback payloadHandler / use
SelectionPolicyChangedpolicy: SelectionPolicyon_selection_policy_changed
Selection policy changed.
SelectionChangedselected: &[K], active: Option<&K>on_selection_changed
Update the selected keys.
ActiveItemChangedactive: Option<&K>on_active_item_changed
Track the active item.
ItemActivatedkey: &Kon_item_activated
Open or execute an item.
ProjectionChangedvisible_count: usizeon_projection_changed
Track visible item count.
FocusChangedis_focused: boolon_focus_changed
React to focus changes.

The host still routes input through list.apply(input, cx). Returned ListBoxUpdate.events remain available for manual handling. After a direct list.state mutation, call list.handle_update(&update, cx) once to deliver effects and callbacks; control methods already do this. Avoid processing the same action in both a named callback and manual event handling.

Drag/drop

See Drag and drop for the lifecycle, proposal/completion distinction, ordering, scopes, and callback ownership shared by these controls.

Register drag/drop callbacks directly on ListBoxControl and opt in with drag_drop(ListBoxDragDrop::new(scope, on_drop)). Its ListBoxEvent::DragDrop carries DragDropEvent<EntityId, K>; the exact named callback arguments are in the shared payload table. drag_drop_id() returns this persistent list’s identity.

The handler receives ListBoxDropProposal<K>. Call validate() and check current keys/application policy, prepare and apply all collection/selection changes, then call committed(changed, ordered_keys, cx) or rejected(error, cx). The SDK routes the returned lifecycle automatically; no separate dispatcher or manual completion-event forwarding is required.

Shadcn ListBoxBuilder::from_control, ShadcnLook::render_listbox, and Radix ListBoxBuilder::new consume that configuration automatically. The example below shows both Looks using the same control.

Compatibility: the borrowed Shadcn ListBoxBuilder::new with drag_and_drop(list, on_drop, on_event, preview) remains available for raw host composition with arbitrary source tags and custom previews. Its raw DropProposal returns batches that the host must deliver, outside ListBoxEvent. To migrate, retain a ListBoxControl, configure ListBoxDragDrop, register handlers on the control, use the control-based Look adapter, and remove manual completion delivery.

Validate proposals and observe completion

Compiled by scripts/check_control_docs.py.

mod drag_drop_example {
    use gpui::{Context, Render, Window, prelude::*};
    use xgpui_luma::controls::listbox::{
        ListBoxControl, ListBoxDragDrop, ListBoxDropProposal, ListBoxError, ListBoxInput,
        ListBoxSnapshot, ListBoxState, SelectionMode,
    };
    use xgpui_luma_look_shadcn::ListBoxBuilder;

    struct Queue {
        list: ListBoxControl<Self, String, String>,
        dirty: bool,
        error: Option<String>,
    }
    impl Queue {
        fn new(cx: &mut Context<Self>) -> Result<Self, ListBoxError> {
            let state = ListBoxState::try_new(
                ["Draft".to_string(), "Review".to_string()], |row| row.clone(), SelectionMode::Multiple,
            )?;
            let list = ListBoxControl::new(state, Self::input, |key| key.clone().into(), |row| row.clone().into(), cx)
                .drag_drop(ListBoxDragDrop::new(cx.entity_id(), Self::on_drop))
                .on_dropped(cx, |owner: &mut Self, _, _, _, _, changed, cx| {
                    owner.dirty |= changed;
                    owner.error = None;
                    cx.notify();
                })
                .on_drop_rejected(cx, |owner, _, _, _, _, error, cx| {
                    owner.error = Some(error.to_string());
                    cx.notify();
                });
            Ok(Self { list, dirty: false, error: None })
        }
        fn input(&mut self, input: ListBoxInput<String>, _: &mut Window, cx: &mut Context<Self>) {
            self.list.apply(input, cx);
        }
        fn on_drop(&mut self, proposal: ListBoxDropProposal<String>, _: &mut Window, cx: &mut Context<Self>) {
            match proposal.validate().map_err(|error| error.to_string()).and_then(|()| self.reorder(&proposal, cx)) {
                Ok((changed, keys)) => proposal.committed(changed, keys, cx),
                Err(error) => proposal.rejected(error, cx),
            }
            cx.notify();
        }
        fn reorder(&mut self, p: &ListBoxDropProposal<String>, cx: &mut Context<Self>) -> Result<(bool, Vec<String>), String> {
            let old = self.list.state.snapshot().items().to_vec();
            if p.source() != self.list.drag_drop_id() || p.target() != self.list.drag_drop_id()
                || p.keys().iter().any(|key| !old.contains(key) || key == "Published") {
                return Err("Queue changed or a row is protected".into());
            }
            let gap = match p.before() {
                Some(key) => old.iter().position(|item| item == key).ok_or("Destination disappeared")?,
                None => old.len(),
            };
            let moving: Vec<_> = old.iter().filter(|key| p.keys().contains(key)).cloned().collect();
            let index = old[..gap].iter().filter(|key| !p.keys().contains(key)).count();
            let mut next: Vec<_> = old.iter().filter(|key| !p.keys().contains(key)).cloned().collect();
            next.splice(index..index, moving.iter().cloned());
            let changed = next != old;
            if changed {
                let snapshot = ListBoxSnapshot::try_new(next, |row| row.clone()).map_err(|error| error.to_string())?;
                let update = self.list.state.replace_snapshot(snapshot);
                self.list.handle_update(&update, cx);
            }
            Ok((changed, moving))
        }
    }
    impl Render for Queue {
        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
            ListBoxBuilder::from_control("queue", &mut self.list,
                |surface, item, _| surface.child(item.item.clone()))
                .build(window, cx)
        }
    }
    // The same persistent control, configuration, and callbacks work in Radix.
    fn render_radix(queue: &mut Queue, window: &mut Window, cx: &mut Context<Queue>) -> impl IntoElement {
        xgpui_luma_look_radix::ListBoxBuilder::new("queue", &mut queue.list,
            xgpui_luma_look_radix::listbox_template(|row: &String| row.clone().into()))
            .build(window, cx)
    }
}

Committed changes also support shared drop-layout motion, including duration settings, reduced motion and documented virtualization/transfer limits.

Source

SDK module · Radix adapter