gpuicn Components

Component 43 of 60

Select

Displays a list of options for the user to pick from.

site/src/previews/select.rs

Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.

Select, running in the browser. The second tab shows the preview's own source, which includes its layout and demo state. For the smallest working example, see Usage below. Download the whole showcase for its imports and helpers.

Installation

After the one-time setup, run the command from your app root. The CLI writes the modules and keeps src/ui/mod.rs in step.

gpuicn add select

If you prefer to copy the files by hand, declare them yourself:

// src/ui/mod.rs
pub mod theme;
pub mod input;
pub mod field;
pub mod select;

// src/main.rs
mod ui;

Usage

Put this in a view module and return example(window, cx) from its render method.

use crate::ui::select::*;
use crate::ui::select::{SelectItem, SelectState};
use gpui_kit::{App, IntoElement, Window};

fn example(window: &mut Window, cx: &mut App) -> impl IntoElement {
    let state = window.use_keyed_state("fruit", cx, |window, cx| {
        SelectState::new(
            [
                SelectItem::new("apple", "Apple"),
                SelectItem::new("pear", "Pear"),
            ],
            window,
            cx,
        )
    });
    Select::new(&state)
        .aria_label("Fruit")
        .placeholder("Choose a fruit…")
}

API reference

pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self

Creates an enabled option from its stable value and visible label.

pub fn disabled(mut self, disabled: bool) -> Self

Disables interaction and applies the disabled appearance.

pub fn new(
        items: impl IntoIterator<Item = SelectItem>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Self

Creates selector state and its Kit editor in the current window.

pub fn value(&self) -> Option<&SharedString>

Returns the currently committed value.

pub fn input(&self) -> &Entity<InputState>

Returns the retained Kit text editor used by searchable presentations.

pub fn is_open(&self) -> bool

Returns whether the popup is open.

pub fn set_value(
        &mut self,
        value: Option<SharedString>,
        window: &mut Window,
        cx: &mut Context<Self>,
    )

Updates the committed selection and synchronizes the editor text.

pub fn new(state: &Entity<SelectState>) -> Self

Creates a single-choice trigger for retained selector state.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Sets the accessible name of the control.

pub fn placeholder(mut self, value: impl Into<SharedString>) -> Self

Sets the text shown when no value is selected.

pub fn disabled(mut self, disabled: bool) -> Self

Disables interaction and applies the disabled appearance.

pub fn invalid(mut self, invalid: bool) -> Self

Applies validation error colors and announces invalidity in the accessible name.

Installed source, 687 lines
//! Nova selection controls with Kit editor state, semantics and popup positioning.
use super::{
    field::FieldControl,
    input::{Input, InputEvent, InputState},
    theme::{UiTheme, apply_style},
};
use gpui_icons::{LucideIcon, lucide};
use gpui_kit::base::{
    Button, ElementExt as _, Placement, Positioner, Select as BaseSelect,
    actions::{SelectDown, SelectUp},
};
use gpui_kit::{
    AnyElement, App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle,
    Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderOnce, Role,
    ScrollHandle, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
    Subscription, Window, div, prelude::FluentBuilder as _,
};

#[derive(Clone)]
/// One selectable value and its visible label.
pub struct SelectItem {
    /// The stable value emitted when selected.
    pub value: SharedString,
    /// The visible option label.
    pub label: SharedString,
    /// Whether this option is excluded from selection.
    pub disabled: bool,
}
impl SelectItem {
    /// Creates an enabled option from its stable value and visible label.
    pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        Self {
            value: value.into(),
            label: label.into(),
            disabled: false,
        }
    }
    /// Disables interaction and applies the disabled appearance.
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}
#[derive(Clone, Debug)]
/// Notifications emitted when a selector commits a value.
pub enum SelectEvent {
    /// The newly committed value, or `None` after clearing the selection.
    Change(Option<SharedString>),
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum Mode {
    Select,
    Combobox,
    Autocomplete,
}
/// Own the state and subscribe to `SelectEvent::Change` for committed values.
pub struct SelectState {
    items: Vec<SelectItem>,
    value: Option<SharedString>,
    input: Entity<InputState>,
    focus: FocusHandle,
    bounds: gpui_kit::Bounds<gpui_kit::Pixels>,
    open: bool,
    highlighted: Option<usize>,
    scroll: ScrollHandle,
    mode: Mode,
    presented: bool,
    editor_ready: bool,
    disabled: bool,
    invalid: bool,
    label: SharedString,
    placeholder: SharedString,
    style: StyleRefinement,
    _subscription: Subscription,
}
impl SelectState {
    /// Creates selector state and its Kit editor in the current window.
    pub fn new(
        items: impl IntoIterator<Item = SelectItem>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Self {
        let input = cx.new(|cx| InputState::new(window, cx));
        let subscription =
            cx.subscribe_in(&input, window, |this, _, event, window, cx| match event {
                InputEvent::Change if !this.disabled => {
                    this.open = true;
                    this.highlighted = this
                        .visible(cx)
                        .into_iter()
                        .find(|&i| !this.items[i].disabled);
                    cx.notify();
                }
                InputEvent::Blur => this.set_open(false, window, cx),
                _ => {}
            });
        Self {
            items: items.into_iter().collect(),
            value: None,
            input,
            focus: cx.focus_handle(),
            bounds: Default::default(),
            open: false,
            highlighted: None,
            scroll: ScrollHandle::new(),
            mode: Mode::Select,
            presented: false,
            editor_ready: false,
            disabled: false,
            invalid: false,
            label: "Selection".into(),
            placeholder: "Select…".into(),
            style: Default::default(),
            _subscription: subscription,
        }
    }
    /// Returns the currently committed value.
    pub fn value(&self) -> Option<&SharedString> {
        self.value.as_ref()
    }
    /// Returns the retained Kit text editor used by searchable presentations.
    pub fn input(&self) -> &Entity<InputState> {
        &self.input
    }
    /// Returns whether the popup is open.
    pub fn is_open(&self) -> bool {
        self.open
    }
    /// Updates the committed selection and synchronizes the editor text.
    pub fn set_value(
        &mut self,
        value: Option<SharedString>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.value = value;
        self.normalize_value();
        self.sync_editor(window, cx);
        cx.notify();
    }
    fn normalize_value(&mut self) {
        if self.presented
            && self.mode != Mode::Autocomplete
            && self
                .value
                .as_ref()
                .is_some_and(|value| !self.items.iter().any(|item| &item.value == value))
        {
            self.value = None;
        }
    }
    fn sync_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        // Plain selects have no rendered editor or initialized editor font.
        if self.mode == Mode::Select || !self.editor_ready {
            return;
        }
        let text = self
            .value
            .as_ref()
            .and_then(|value| self.items.iter().find(|item| &item.value == value))
            .map(|item| item.label.clone())
            .or_else(|| {
                (self.mode == Mode::Autocomplete)
                    .then(|| self.value.clone())
                    .flatten()
            })
            .unwrap_or_default();
        if self.input.read(cx).value() != text {
            self.input
                .update(cx, |input, cx| input.set_value(text, window, cx));
        }
    }
    fn visible(&self, cx: &App) -> Vec<usize> {
        let query = if self.mode == Mode::Select {
            String::new()
        } else {
            self.input.read(cx).value().to_lowercase()
        };
        self.items
            .iter()
            .enumerate()
            .filter_map(|(i, item)| item.label.to_lowercase().contains(&query).then_some(i))
            .collect()
    }
    fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
        if self.disabled && open {
            return;
        }
        if self.open == open {
            return;
        }
        self.open = open;
        if open {
            if self.mode != Mode::Select {
                self.input
                    .update(cx, |input, cx| input.set_value("", window, cx));
            }
            let visible = self.visible(cx);
            self.highlighted = visible
                .iter()
                .copied()
                .find(|&i| {
                    !self.items[i].disabled && Some(&self.items[i].value) == self.value.as_ref()
                })
                .or_else(|| visible.into_iter().find(|&i| !self.items[i].disabled));
        } else {
            self.sync_editor(window, cx);
        }
        cx.notify();
    }
    fn navigate(&mut self, direction: isize, window: &mut Window, cx: &mut Context<Self>) {
        if self.disabled {
            return;
        }
        if !self.open {
            self.set_open(true, window, cx);
            return;
        }
        let visible = self.visible(cx);
        let enabled: Vec<_> = visible
            .iter()
            .copied()
            .filter(|&i| !self.items[i].disabled)
            .collect();
        if enabled.is_empty() {
            return;
        }
        let current = enabled
            .iter()
            .position(|i| Some(*i) == self.highlighted)
            .unwrap_or(0);
        let next = match direction {
            -2 => 0,
            2 => enabled.len() - 1,
            _ => (current as isize + direction).rem_euclid(enabled.len() as isize) as usize,
        };
        self.highlighted = Some(enabled[next]);
        if let Some(row) = visible.iter().position(|i| *i == enabled[next]) {
            self.scroll.scroll_to_item(row);
        }
        cx.notify();
    }
    fn choose(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
        if self.disabled || self.items[index].disabled {
            return;
        }
        self.set_value(Some(self.items[index].value.clone()), window, cx);
        self.open = false;
        self.focus_handle(cx).focus(window, cx);
        cx.emit(SelectEvent::Change(self.value.clone()));
    }
    fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if !self.open {
            self.set_open(true, window, cx);
            return;
        }
        if let Some(index) = self.highlighted {
            self.choose(index, window, cx);
        } else if self.mode == Mode::Autocomplete {
            self.value = Some(self.input.read(cx).value());
            self.open = false;
            cx.emit(SelectEvent::Change(self.value.clone()));
            cx.notify();
        }
    }
    fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.set_value(None, window, cx);
        self.set_open(true, window, cx);
        self.input.read(cx).focus_handle(cx).focus(window, cx);
        cx.emit(SelectEvent::Change(None));
    }
}
impl EventEmitter<SelectEvent> for SelectState {}

#[cfg(test)]
mod tests {
    use super::*;
    #[gpui_kit::test]
    fn choosing_an_option_without_a_rendered_editor(cx: &mut gpui_kit::TestAppContext) {
        let window = cx.add_empty_window();
        window.update(|window, cx| {
            super::super::theme::init(cx);
            let state =
                cx.new(|cx| SelectState::new([SelectItem::new("pear", "Pear")], window, cx));
            state.update(cx, |state, cx| state.choose(0, window, cx));
            assert_eq!(state.read(cx).value().map(|v| v.as_ref()), Some("pear"));
            assert!(state.read(cx).input.read(cx).value().is_empty());
        });
    }

    #[gpui_kit::test]
    fn reopening_combobox_starts_with_all_options(cx: &mut gpui_kit::TestAppContext) {
        let window = cx.add_empty_window();
        window.update(|window, cx| {
            super::super::theme::init(cx);
            let state = cx.new(|cx| {
                SelectState::new(
                    [
                        SelectItem::new("apple", "Apple"),
                        SelectItem::new("pear", "Pear"),
                    ],
                    window,
                    cx,
                )
            });
            state.update(cx, |state, cx| {
                state.mode = Mode::Combobox;
                state.editor_ready = true;
                state.set_value(Some("pear".into()), window, cx);
                assert_eq!(state.input.read(cx).value().as_ref(), "Pear");

                state.set_open(true, window, cx);
                assert_eq!(state.visible(cx), vec![0, 1]);
                state
                    .input
                    .update(cx, |input, cx| input.set_value("app", window, cx));
                assert_eq!(state.visible(cx), vec![0]);

                state.set_open(false, window, cx);
                assert_eq!(state.input.read(cx).value().as_ref(), "Pear");
                state.set_open(true, window, cx);
                assert_eq!(state.visible(cx), vec![0, 1]);
            });
        });
    }
}
impl Focusable for SelectState {
    fn focus_handle(&self, cx: &App) -> FocusHandle {
        if self.mode == Mode::Select {
            self.focus.clone()
        } else {
            self.input.read(cx).focus_handle(cx)
        }
    }
}
impl Render for SelectState {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = UiTheme::read(cx).clone();
        let colors = theme.colors;
        let id = ElementId::from(("select", cx.entity_id()));
        let editing = self.mode != Mode::Select;
        let focused = self.focus_handle(cx).is_focused(window) && !self.disabled;
        let accessibility_label = if self.invalid {
            format!("{} (invalid value)", self.label).into()
        } else {
            self.label.clone()
        };
        let state = cx.entity();
        let open_state = state.clone();
        let measured = state.clone();
        let trigger = if editing {
            let clear = Button::new((id.clone(), "clear"))
                .focusable(false)
                .disabled(self.disabled)
                .accessibility_label("Clear selection")
                .size(theme.space(6.))
                .on_click(cx.listener(|this, _, window, cx| this.clear(window, cx)))
                .child(
                    lucide(LucideIcon::X)
                        .size(theme.space(3.5))
                        .text_color(colors.muted_foreground),
                );
            let toggle = Button::new((id.clone(), "toggle"))
                .focusable(false)
                .disabled(self.disabled)
                .accessibility_label("Show options")
                .size(theme.space(6.))
                .on_click(cx.listener(|this, _, window, cx| this.set_open(!this.open, window, cx)))
                .child(
                    lucide(LucideIcon::ChevronDown)
                        .size(theme.space(4.))
                        .text_color(colors.muted_foreground),
                );
            div()
                .flex()
                .items_center()
                .w_full()
                .rounded(theme.radius.lg)
                .border_1()
                .border_color(if self.invalid {
                    colors.destructive
                } else {
                    colors.input
                })
                .when(focused, |d| {
                    d.border_color(if self.invalid {
                        colors.destructive
                    } else {
                        colors.ring
                    })
                })
                .bg(colors.background)
                .pr(theme.space(1.))
                .child(
                    Input::new(&self.input)
                        .aria_label(self.label.clone())
                        .disabled(self.disabled)
                        .invalid(self.invalid)
                        .bordered(false),
                )
                .child(clear)
                .child(toggle)
                .into_any_element()
        } else {
            let text = self
                .value
                .as_ref()
                .and_then(|value| self.items.iter().find(|i| &i.value == value))
                .map(|i| i.label.clone())
                .unwrap_or_else(|| self.placeholder.clone());
            div()
                .id((id.clone(), "trigger"))
                .flex()
                .items_center()
                .justify_between()
                .w_full()
                .h(theme.space(8.))
                .rounded(theme.radius.lg)
                .border_1()
                .border_color(if self.invalid {
                    colors.destructive
                } else {
                    colors.input
                })
                .when(focused, |d| {
                    d.border_color(if self.invalid {
                        colors.destructive
                    } else {
                        colors.ring
                    })
                })
                .px(theme.space(2.5))
                .bg(colors.background)
                .text_color(if self.value.is_some() {
                    colors.foreground
                } else {
                    colors.muted_foreground
                })
                .when(!self.disabled, |d| {
                    d.cursor_pointer()
                        .on_click(cx.listener(|this, _, window, cx| {
                            this.focus.focus(window, cx);
                            this.set_open(!this.open, window, cx);
                        }))
                })
                .child(text)
                .child(
                    lucide(LucideIcon::ChevronDown)
                        .size(theme.space(4.))
                        .text_color(colors.muted_foreground),
                )
                .into_any_element()
        };
        let mut content = div().w_full().child(trigger);
        if self.open && !self.disabled {
            let mut list = div()
                .id((id.clone(), "list"))
                .role(Role::ListBox)
                .aria_label(self.label.clone())
                .w(self.bounds.size.width.max(theme.space(48.)))
                .max_h(theme.space(60.))
                .overflow_y_scroll()
                .track_scroll(&self.scroll)
                .rounded(theme.radius.lg)
                .border_1()
                .border_color(colors.border)
                .bg(colors.popover)
                .text_color(colors.popover_foreground)
                .p(theme.space(1.))
                .shadow(theme.shadows.md.to_vec())
                .on_mouse_down_out(cx.listener(|this, _, window, cx| {
                    this.set_open(false, window, cx);
                }));
            let visible = self.visible(cx);
            if visible.is_empty() {
                list = list.child(
                    div()
                        .p(theme.space(3.))
                        .text_color(colors.muted_foreground)
                        .child("No results found."),
                );
            }
            for i in visible {
                let item = &self.items[i];
                list = list.child(
                    Button::new((id.clone(), item.value.clone()))
                        .role(Role::ListBoxOption)
                        .focusable(false)
                        .disabled(item.disabled)
                        .accessibility_label(item.label.clone())
                        .aria_selected(Some(&item.value) == self.value.as_ref())
                        .w_full()
                        .justify_start()
                        .h(theme.space(8.))
                        .px(theme.space(1.5))
                        .rounded(theme.radius.sm)
                        .when(Some(i) == self.highlighted, |b| b.bg(colors.muted))
                        .when(item.disabled, |b| b.opacity(0.5).cursor_not_allowed())
                        .on_hover(cx.listener(move |this, hovered, _, cx| {
                            if *hovered && !this.items[i].disabled {
                                this.highlighted = Some(i);
                                cx.notify();
                            }
                        }))
                        .on_click(
                            cx.listener(move |this, _, window, cx| this.choose(i, window, cx)),
                        )
                        .child(item.label.clone()),
                );
            }
            content = content.child(
                gpui_kit::deferred(
                    Positioner::side(self.bounds)
                        .placement(Placement::Bottom)
                        .offset(theme.space(1.))
                        .occlude()
                        .child(list),
                )
                .with_priority(gpui_kit::base::POPUP_PRIORITY),
            );
        }
        let root = BaseSelect::new(id.clone())
            .key_context(if editing { "Combobox" } else { "Select" })
            .open(self.open)
            .disabled(self.disabled)
            .accessibility_label(accessibility_label)
            .accessibility_value(
                self.value
                    .as_ref()
                    .and_then(|value| self.items.iter().find(|i| &i.value == value))
                    .map(|i| i.label.clone())
                    .unwrap_or_default(),
            )
            .when(!editing, |s| s.focus_handle(&self.focus))
            .on_open_change(move |open, window, cx| {
                open_state.update(cx, |state, cx| state.set_open(open, window, cx))
            })
            .on_confirm(move |window, cx| {
                state.update(cx, |this, cx| this.confirm(window, cx));
                cx.stop_propagation();
            })
            .child(content);
        apply_style(
            div()
                .id((id, "keyboard"))
                .relative()
                .font_family(theme.fonts.body.clone())
                .text_size(theme.text(14.))
                .when(self.disabled, |d| d.opacity(0.5))
                .on_prepaint(move |bounds, window, cx| {
                    measured.update(cx, |this, _| {
                        if this.bounds != bounds {
                            this.bounds = bounds;
                            window.request_animation_frame();
                        }
                    })
                })
                .capture_action(cx.listener(|this, _: &SelectUp, window, cx| {
                    this.navigate(-1, window, cx);
                    cx.stop_propagation();
                }))
                .capture_action(cx.listener(|this, _: &SelectDown, window, cx| {
                    this.navigate(1, window, cx);
                    cx.stop_propagation();
                }))
                .on_key_down(cx.listener(
                    move |this, event: &gpui_kit::KeyDownEvent, window, cx| {
                        if !editing && !event.keystroke.modifiers.modified() {
                            match event.keystroke.key.as_str() {
                                "home" => this.navigate(-2, window, cx),
                                "end" => this.navigate(2, window, cx),
                                _ => return,
                            }
                            cx.stop_propagation();
                        }
                    },
                ))
                .child(root),
            &self.style,
        )
    }
}

#[derive(IntoElement)]
/// A single-choice popup backed by retained selector state.
pub struct Select {
    state: Entity<SelectState>,
    #[doc(hidden)]
    pub mode: Mode,
    label: SharedString,
    placeholder: SharedString,
    disabled: bool,
    invalid: bool,
    style: StyleRefinement,
}
impl Select {
    /// Creates a single-choice trigger for retained selector state.
    pub fn new(state: &Entity<SelectState>) -> Self {
        Self {
            state: state.clone(),
            mode: Mode::Select,
            label: "Selection".into(),
            placeholder: "Select…".into(),
            disabled: false,
            invalid: false,
            style: Default::default(),
        }
    }
    /// Sets the accessible name of the control.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = label.into();
        self
    }
    /// Sets the text shown when no value is selected.
    pub fn placeholder(mut self, value: impl Into<SharedString>) -> Self {
        self.placeholder = value.into();
        self
    }
    /// Disables interaction and applies the disabled appearance.
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
    /// Applies validation error colors and announces invalidity in the accessible name.
    pub fn invalid(mut self, invalid: bool) -> Self {
        self.invalid = invalid;
        self
    }
}
impl Styled for Select {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}
impl RenderOnce for Select {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        self.state.update(cx, |state, cx| {
            let mode_changed = !state.presented || state.mode != self.mode;
            state.mode = self.mode;
            state.presented = true;
            if mode_changed {
                state.normalize_value();
                if state.mode != Mode::Select && !state.editor_ready {
                    // Kit resolves the inherited editor font during first paint; sync after it.
                    cx.on_next_frame(window, |state, window, cx| {
                        state.editor_ready = true;
                        state.sync_editor(window, cx);
                        cx.notify();
                    });
                } else {
                    state.sync_editor(window, cx);
                }
            }
            state.disabled = self.disabled;
            state.invalid = self.invalid;
            state.label = self.label;
            state.placeholder = self.placeholder;
            state.style = self.style;
            if state.input.read(cx).presentation().placeholder() != &state.placeholder {
                state.input.update(cx, |input, cx| {
                    input.set_placeholder(state.placeholder.clone(), window, cx)
                });
            }
        });
        self.state
    }
}

impl FieldControl for Select {
    fn field_focus_handle(&self, cx: &App) -> FocusHandle {
        let state = self.state.read(cx);
        if self.mode == Mode::Select {
            state.focus.clone()
        } else {
            state.input.read(cx).focus_handle(cx)
        }
    }

    fn into_field_control(self, label: SharedString, disabled: bool, invalid: bool) -> AnyElement {
        self.aria_label(label)
            .disabled(disabled)
            .invalid(invalid)
            .into_any_element()
    }
}

Platform notes

Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation: crates/ui/registry/select/select.rs, using GPUI Kit 0.6.4.

Select takes retained SelectState with stable option values and labels. It opens a single-choice list, skips disabled options during keyboard navigation and commits on Enter. Escape closes and returns focus. Searchable and free-text presentations use the same state through Combobox and Autocomplete. The invalid builder applies the destructive border directly or through Field::from_control. The pinned Base Select has no accessibility-description seam, so the control's accessible name also says “invalid value”; searchable presentations expose the same state on their text editor.

The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.