Component 43 of 60
Select
Displays a list of options for the user to pick from.
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.
fn select_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = window.use_keyed_state("select_preview", cx, |window, cx| {
SelectState::new(
[
SelectItem::new("apple", "Apple"),
SelectItem::new("banana", "Banana"),
SelectItem::new("orange", "Orange").disabled(true),
SelectItem::new("pear", "Pear"),
],
window,
cx,
)
});
Select::new(&state)
.aria_label("Fruit")
.placeholder("Select a fruit…")
.w(px(240.))
}
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.