Combobox
A searchable dropdown for selecting one or multiple values from a list.
#Select vs Combobox
| Feature | Select | Combobox |
|---|---|---|
| Searchable | ✓ (optional) | ✓ (optional) |
| Multi-select | — | ✓ (.multiple(true)) |
| Custom trigger rendering | — | ✓ |
| Custom item rendering | — | ✓ |
| Footer action slot | — | ✓ |
Use Select for simple single-value picking. Use Combobox when you need multi-select, a fully custom trigger, or custom item rendering.
#Import
use gpui_kit::component::combobox::{
Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx,
};
use gpui_kit::component::searchable_list::{
SearchableListItem, SearchableVec, SearchableGroup,
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ComboItem, ComboboxTrigger, ComboSearch, Combobox } from "combobox.slint";use iced::keyboard;
use iced::widget::{column, container, mouse_area, row, scrollable, svg, text};
use iced::{Alignment, Element, Length, Subscription, Task, Theme};#Usage
#Basic Single-Select
let state = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]),
vec![], // no initial selection
window,
cx,
)
.searchable(true)
});
Combobox::new(&state)
.placeholder("Select framework...")
.search_placeholder("Search...")
.w_full()default-box := Combobox {
items: root.frameworks;
placeholder: "Select framework...";
search-placeholder: "Search…";
}// The whole of a combobox: the items, the caller's single choice, open flag
// and free text, and the five reports. The trigger is the field's frame — the
// same height, inset and edge as `Input` — and the panel is the popover's one
// surface around a search row and the menu's rows. `Item` is the shape the
// menu, the dropdown button and the select already share.
let basic = body_box(
Combobox::new(items(slot::BASIC))
.selected(app.selected[slot::BASIC])
.query(app.query[slot::BASIC].clone())
.open(app.open[slot::BASIC])
.highlighted(app.highlighted_in(slot::BASIC))
.placeholder("Select framework...")
.search_placeholder("Search…")
.width(Length::Fixed(TRIGGER_WIDTH))
.on_open_change(|open| Message::Opened {
slot: slot::BASIC,
open,
})
.on_chosen(|index| Message::Chosen {
slot: slot::BASIC,
index,
})
.on_query(|query| Message::Queried {
slot: slot::BASIC,
query,
})
.on_highlight(|index| Message::Highlighted {
slot: slot::BASIC,
index,
})
.on_leave(|row| Message::Left {
slot: slot::BASIC,
row,
}),
);#Multi-Select
// `multiple(true)` makes a click toggle the row into `picked` instead of
// setting `selected`; the chosen rows carry the menu's check mark and the
// panel stays open until Escape or a click outside closes it.
let multiple = body_box(
combo(app, slot::MULTI)
.multiple(true)
.placeholder("Select frameworks..."),
);Pass .multiple(true) to enable multi-select mode. Clicking an item toggles it; the dropdown stays open until the user presses Escape or clicks outside.
let state = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(vec!["React", "Vue", "Angular"]),
vec![IndexPath::new(0)], // pre-selected
window,
cx,
)
.multiple(true)
.searchable(true)
});
Combobox::new(&state).placeholder("Select frameworks")multiple-box := Combobox {
items: root.frameworks;
multiple: true;
picked: [
false,
false,
false,
false,
false
];
placeholder: "Select frameworks...";
search-placeholder: "Search…";
}#Pre-selected Item
Pass index paths of items to pre-select:
let state = cx.new(|cx| {
ComboboxState::new(items, vec![IndexPath::new(0)], window, cx)
});#Grouped Items
Use SearchableGroup to group items under a heading:
let grouped = SearchableVec::new(vec![
SearchableGroup::new("Fruits").items(vec![
FoodItem::new("Apples"),
FoodItem::new("Bananas"),
]),
SearchableGroup::new("Vegetables").items(vec![
FoodItem::new("Carrots"),
FoodItem::new("Spinach"),
]),
]);
let state = cx.new(|cx| {
ComboboxState::new(grouped, vec![], window, cx).searchable(true)
});
Combobox::new(&state)grouped-box := Combobox {
selected: 0;
placeholder: "Select item...";
search-placeholder: "Search…";
items: [
{ label: "Apples", value: "apples", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
{ label: "Bananas", value: "bananas", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
{ label: "Cherries", value: "cherries", icon: "", group: "Fruits", badge: "", disabled: false, pinned: false },
{ label: "Carrots", value: "carrots", icon: "", group: "Vegetables", badge: "", disabled: false, pinned: false },
{ label: "Broccoli", value: "broccoli", icon: "", group: "Vegetables", badge: "", disabled: true, pinned: false },
{ label: "Spinach", value: "spinach", icon: "", group: "Vegetables", badge: "", disabled: false, pinned: false },
{ label: "Tea", value: "tea", icon: "", group: "Beverages", badge: "", disabled: false, pinned: false },
{ label: "Coffee", value: "coffee", icon: "", group: "Beverages", badge: "", disabled: true, pinned: false },
{ label: "Juice", value: "juice", icon: "", group: "Beverages", badge: "", disabled: false, pinned: false },
];
}#Implementing SearchableListItem
Built-in implementations exist for String, SharedString, and &'static str. For custom types implement the trait:
#[derive(Clone)]
struct Country {
name: SharedString,
code: SharedString,
}
impl SearchableListItem for Country {
type Value = SharedString;
fn title(&self) -> SharedString {
self.name.clone()
}
fn value(&self) -> &SharedString {
&self.code
}
fn matches(&self, query: &str) -> bool {
self.name.to_lowercase().contains(query)
|| self.code.to_lowercase().contains(query)
}
}#Disabled Items
// A row built with `disabled(true)` is drawn muted and answers no pointer or
// key, which is the Slint `dimmed` state.
let disabled_items = body_box(combo(app, slot::DISABLED_ITEMS).placeholder("Select item..."));Return true from disabled() on items that should not be selectable:
impl SearchableListItem for MyItem {
// ...
fn disabled(&self) -> bool {
self.is_unavailable
}
}Combobox {
placeholder: "Select item...";
search-placeholder: "Search…";
items: [
{ label: "Apples", value: "apples", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Bananas", value: "bananas", icon: "", group: "", badge: "", disabled: true, pinned: false },
{ label: "Cherries", value: "cherries", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Carrots", value: "carrots", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Broccoli", value: "broccoli", icon: "", group: "", badge: "", disabled: true, pinned: false },
];
}#Custom Check Icon
Combobox::new(&state)
.check_icon(Icon::new(IconName::CircleCheck))Combobox {
items: root.frameworks;
check-icon: "circle-check";
placeholder: "Select framework...";
search-placeholder: "Search…";
}#Footer Action
// The footer band is the caller's element: here the plus glyph and a label,
// wrapped in the click that appends the typed name to the list. The component
// draws the band's one-pixel top edge and nothing else.
let footer = body_box(
column![
combo(app, slot::FOOTER)
.placeholder("Select university")
.footer(footer_action("New university")),
muted_line("Type a name, then choose New university to add it to the list."),
]
.spacing(8.0),
);Render a persistent action at the bottom of the dropdown (e.g. an “Add new” button):
Combobox::new(&state)
.footer(|_, cx| {
Button::new("add-new")
.ghost()
.label("New item")
.icon(Icon::new(IconName::Plus))
.w_full()
.justify_start()
.into_any_element()
})Combobox {
items <=> root.schools;
query <=> root.school-query;
selected: 0;
footer: "New university";
placeholder: "Select university";
search-placeholder: "Search…";
footer-clicked => {
if root.school-query != "" {
root.add-school(root.school-query);
root.school-query = "";
}
}
}#Custom Trigger
Override the entire trigger element. ComboboxTriggerCtx exposes the current selection, open/disabled flags, and size:
Combobox::new(&state)
.render_trigger(|ctx, _, cx| {
h_flex()
.w_full()
.items_center()
.gap_2()
.when(ctx.selection.is_empty(), |this| {
this.text_color(cx.theme().muted_foreground)
.child("Select...")
})
.children(ctx.selection.iter().map(|(_, item)| {
div()
.bg(cx.theme().accent)
.rounded_sm()
.px_1p5()
.py_0p5()
.text_sm()
.child(item.title())
}))
.into_any_element()
})Combobox {
items: root.frameworks;
trigger: ComboboxTrigger.chip;
placeholder: "Select framework";
search-placeholder: "Search…";
}#Icons
Combobox::new(&state)
.placeholder("Select industry category")
.search_placeholder("Search…")Combobox {
trigger: ComboboxTrigger.icon;
placeholder: "Select industry category";
search-placeholder: "Search…";
items: [
{ label: "Information Technology", value: "it", icon: "cpu", group: "", badge: "", disabled: false, pinned: false },
{ label: "Healthcare", value: "health", icon: "heart", group: "", badge: "", disabled: false, pinned: false },
{ label: "Finance", value: "finance", icon: "globe", group: "", badge: "", disabled: false, pinned: false },
{ label: "Education", value: "education", icon: "book-open", group: "", badge: "", disabled: false, pinned: false },
{ label: "Entertainment", value: "entertainment", icon: "star", group: "", badge: "", disabled: false, pinned: false },
];
}// An `Item` carries the SVG the row draws in its leading slot; the bytes are
// the application's, as every icon in this crate is.
let icons = body_box(combo(app, slot::ICONS).placeholder("Select industry category"));#Badges
Combobox::new(&state)
.placeholder("Select frameworks")
.search_placeholder("Search…")badges-box := Combobox {
items: root.many;
multiple: true;
trigger: ComboboxTrigger.badges;
picked: [
true,
false,
true,
false,
false,
false,
false,
false,
false,
false
];
placeholder: "Select frameworks";
search-placeholder: "Search…";
}#Maximum selections
Combobox::new(&state)
.placeholder("Select up to 2 frameworks")
.search_placeholder("Search…")Combobox {
items: root.many;
multiple: true;
max-selected: 2;
picked: [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
];
placeholder: "Select up to 2 frameworks";
search-placeholder: "Search…";
}// `max_selected(2)` draws the rows that would exceed it muted and stops the
// click, the Slint `at-capacity` block.
let maximum = body_box(combo(app, slot::MAXIMUM).placeholder("Select up to 2 frameworks"));#Pinned items
Combobox::new(&state)
.placeholder("Select framework...")
.search_placeholder("Search…")Combobox {
placeholder: "Select framework...";
search-placeholder: "Search…";
items: [
{ label: "Next.js", value: "next", icon: "", group: "", badge: "", disabled: false, pinned: true },
{ label: "SvelteKit", value: "svelte", icon: "", group: "", badge: "", disabled: false, pinned: true },
{ label: "Nuxt.js", value: "nuxt", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Remix", value: "remix", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Astro", value: "astro", icon: "", group: "", badge: "", disabled: false, pinned: false },
];
}#Rich items
Combobox::new(&state)
.placeholder("Select framework...")
.search_placeholder("Search…")Combobox {
placeholder: "Select framework...";
search-placeholder: "Search…";
items: [
{ label: "Next.js", value: "next", icon: "", group: "", badge: "Featured", disabled: false, pinned: false },
{ label: "SvelteKit", value: "svelte", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Nuxt.js", value: "nuxt", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Remix", value: "remix", icon: "", group: "", badge: "", disabled: false, pinned: false },
{ label: "Astro", value: "astro", icon: "", group: "", badge: "", disabled: false, pinned: false },
];
}#Overflow
Combobox::new(&state)
.placeholder("Select frameworks")
.search_placeholder("Search…")Combobox {
items: root.many;
multiple: true;
trigger: ComboboxTrigger.overflow;
picked: [
true,
false,
true,
false,
false,
true,
false,
false,
true,
true
];
placeholder: "Select frameworks";
search-placeholder: "Search…";
}#Count
Combobox::new(&state)
.placeholder("Select frameworks")
.search_placeholder("Search…")count-box := Combobox {
items: root.many;
multiple: true;
trigger: ComboboxTrigger.count;
picked: [
true,
true,
true,
true,
true,
true,
false,
false,
false,
false
];
placeholder: "Select frameworks";
search-placeholder: "Search…";
}#Values
let basic = state.read(cx).selected_values();
let grouped = grouped.read(cx).selected_values();VerticalLayout {
spacing: 4px;
Text {
text: "basic: " + (default-box.current-label() == "" ? "none" : default-box.current-label());
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-family: Theme.mono-font-family;
}
Text {
text: "grouped: " + grouped-box.current-label();
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-family: Theme.mono-font-family;
}
Text {
text: "badges: " + (badges-box.current-label() == "" ? "none" : badges-box.current-label());
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-family: Theme.mono-font-family;
}
Text {
text: "count: " + count-box.current-label();
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-family: Theme.mono-font-family;
}
}// The choice read back the way the trigger reads it: the single label, or
// the multiple one joined with commas — the Slint `current-label()`.
let values = body_box(
column![
muted_line(format!(
"basic: {}",
current_label(app, slot::BASIC).unwrap_or_else(|| "none".to_string())
)),
muted_line(format!(
"multiple: {}",
current_label(app, slot::MULTI).unwrap_or_else(|| "none".to_string())
)),
muted_line(format!(
"maximum: {}",
current_label(app, slot::MAXIMUM).unwrap_or_else(|| "none".to_string())
)),
]
.spacing(4.0),
);#Sizes
Combobox::new(&state).large()
Combobox::new(&state) // medium (default)
Combobox::new(&state).small()// The size picks the height, the inset, the type and the chevron together,
// the way the Slint component's `size` picks `Theme.input-height`,
// `input-padding-x`, `input-font-size` and `icon-size`.
let sizes = body_box(
column![
combo(app, slot::SIZE_SMALL)
.size(theme::ControlSize::Small)
.placeholder("Small"),
combo(app, slot::SIZE_MEDIUM).placeholder("Medium"),
combo(app, slot::SIZE_LARGE)
.size(theme::ControlSize::Large)
.placeholder("Large"),
]
.spacing(12.0),
);#Cleanable
Combobox::new(&state).cleanable(true) // show clear button when a value is selected#Disabled
Combobox::new(&state).disabled(true)// A disabled combobox answers nothing: the trigger is drawn muted and the
// popover is given no open state, so the panel cannot be opened at all.
let disabled = body_box(
combo(app, slot::DISABLED)
.placeholder("Select framework...")
.disabled(true),
);#Events
// `on_chosen` reports the row by index; the caller owns the items, asks
// itself what index 2 means, and decides whether to close by answering
// `open`. The line under the combobox is the report made visible.
let events = body_box(
column![
combo(app, slot::EVENTS).placeholder("Select framework..."),
muted_line(format!(
"Last choice: {}",
label_of(app, slot::EVENTS, app.last[slot::EVENTS])
.unwrap_or_else(|| "nothing yet".to_string())
)),
]
.spacing(8.0),
);Both Change (fired on every toggle) and Confirm (fired when the dropdown closes) carry the full selection as Vec<Value>.
cx.subscribe_in(&state, window, |view, _, event, window, cx| {
match event {
ComboboxEvent::Change(values) => {
// fired on every toggle
}
ComboboxEvent::Confirm(values) => {
// fired when the dropdown closes
}
}
});#Mutating Programmatically
Values are resolved through the current delegate. Values that cannot be found are ignored.
set_selected_values clears the search query first, so an active search never decides which
values can be selected. Index paths address the list as it is currently displayed, so
set_selected_indices, add_selected_index and remove_selected_index act on the visible rows
and leave the query alone.
// Replace the entire selection by value
state.update(cx, |s, cx| {
s.set_selected_values(&["React", "Angular"], window, cx);
});
// Replace the entire selection by index path
state.update(cx, |s, cx| {
s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx);
});
// Add / remove individual items
state.update(cx, |s, cx| {
s.add_selected_index(IndexPath::new(1), cx);
s.remove_selected_index(IndexPath::new(0), cx);
});
// Clear all selections
state.update(cx, |s, cx| {
s.clear_selection(cx);
});
// Read all selected values (multi-select)
let values = state.read(cx).selected_values(); // Vec<Value>
// Read the first selected value (single-select convenience)
let value = state.read(cx).selected_value(); // Option<Value>#Keyboard Shortcuts
// `↑`/`↓` step to the previous and next actionable row the query leaves
// visible, `Home`/`End` jump to the first and last, and `Enter` and `Space`
// choose the one highlighted. Escape is the popover's, and closes the panel
// from anywhere in it.
let keyboard = body_box(
column![
combo(app, slot::KEYBOARD).placeholder("Open with the keyboard"),
muted_line("↑ ↓ Home End move the highlight; Enter or Space chooses; Escape closes."),
]
.spacing(8.0),
);| Key | Action |
|---|---|
Tab | Focus trigger |
Enter | Open menu or confirm highlighted item |
Up/Down | Navigate options (opens menu if closed) |
Escape | Close menu |
#Theming
background— Dropdown input backgroundinput— Trigger border colorforeground— Text colormuted_foreground— Placeholder and disabled textborder— Menu borderradius— Border radius