Component 25 of 60
Input Group
Combines an input or textarea with inline addons and actions.
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 input_group_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let url = demo_input("input-group.url", "", "example.com", window, cx);
let search = demo_input("input-group.search", "", "Search", window, cx);
let message = window.use_keyed_state("input-group.message", cx, |window, cx| {
TextareaState::new(window, cx)
.rows(4)
.placeholder("Write a message")
});
div()
.w(px(420.))
.flex()
.flex_col()
.gap(px(12.))
.child(
InputGroup::new("url-group", InputGroupInput::new(&url))
.aria_label("Website address")
.addon(
InputGroupAddon::new("scheme")
.child(InputGroupText::new().child("https://")),
)
.addon(
InputGroupAddon::new("url-action")
.align(InputGroupAddonAlignment::InlineEnd)
.button(InputGroupButton::new("copy-url").label("Copy")),
),
)
.child(
InputGroup::new("search-group", InputGroupInput::new(&search))
.aria_label("Search")
.disabled(true)
.addon(
InputGroupAddon::new("search-status")
.align(InputGroupAddonAlignment::InlineEnd)
.child(InputGroupText::new().child("⌘ K")),
),
)
.child(
InputGroup::new("message-group", InputGroupTextarea::new(&message))
.aria_label("Message")
.addon(
InputGroupAddon::new("message-header")
.align(InputGroupAddonAlignment::BlockStart)
.child(InputGroupText::new().child("Message")),
)
.addon(
InputGroupAddon::new("message-toolbar")
.align(InputGroupAddonAlignment::BlockEnd)
.child(InputGroupText::new().child("Markdown supported"))
.button(InputGroupButton::new("send-message").label("Send")),
),
)
}
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 input-group
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod input;
pub mod button;
pub mod field;
pub mod input_group;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(window, cx) from its render method.
use crate::ui::input::InputState;
use crate::ui::input_group::{
InputGroup, InputGroupAddon, InputGroupAddonAlignment, InputGroupButton, InputGroupInput,
InputGroupText,
};
use gpui_kit::{App, IntoElement, ParentElement as _, Window};
fn example(window: &mut Window, cx: &mut App) -> impl IntoElement {
let url = window.use_keyed_state("input-group.url", cx, |window, cx| {
InputState::new(window, cx).placeholder("example.com")
});
InputGroup::new("url", InputGroupInput::new(&url))
.aria_label("Website address")
.addon(InputGroupAddon::new("scheme").child(InputGroupText::new().child("https://")))
.addon(
InputGroupAddon::new("copy")
.align(InputGroupAddonAlignment::InlineEnd)
.button(InputGroupButton::new("copy-url").label("Copy")),
)
}
API reference
pub fn new(id: impl Into<ElementId>, control: impl Into<InputGroupControl>) -> Self
Creates a group around its required text control.
pub fn input(mut self, control: impl Into<InputGroupControl>) -> Self
Replaces the group's text control.
pub fn addon(mut self, addon: InputGroupAddon) -> Self
Appends an explicitly aligned addon.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self
Sets the accessible name of the text control.
pub fn disabled(mut self, value: bool) -> Self
Disables the editor and addon actions.
pub fn read_only(mut self, value: bool) -> Self
Prevents edits while preserving selection, copying and addon actions.
pub fn invalid(mut self, value: bool) -> Self
Applies validation error colors and semantics to the editor.
pub fn new(id: impl Into<ElementId>) -> Self
Creates an inline-start addon with a stable ID.
pub fn align(mut self, alignment: InputGroupAddonAlignment) -> Self
Sets the addon's position around the text control.
pub fn button(mut self, button: InputGroupButton) -> Self
Adds a compact action that inherits the group's disabled state.
pub fn new(id: impl Into<ElementId>) -> Self
Creates a compact ghost button with a stable ID.
pub fn label(mut self, label: impl Into<SharedString>) -> Self
Sets the visible button label.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self
Sets the name announced to accessibility clients.
pub fn disabled(mut self, value: bool) -> Self
Disables the action. A group-level disabled state is combined with this value.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self
Runs a callback when the enabled button is activated.
pub fn new() -> Self
Creates an empty muted text container.
Installed source, 497 lines
//! Nova input-group composition around the existing Kit-backed text controls.
use super::{
button::{Button, ButtonSize, ButtonVariant},
field::FieldControl,
input::{Input, Textarea},
theme::{
UiTheme,
primitives::{control, disabled, focus},
},
};
use gpui_kit::{
AnyElement, App, ClickEvent, ElementId, FocusHandle, InteractiveElement as _, IntoElement,
MouseButton, ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
};
/// The logical side of an addon relative to the text control.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum InputGroupAddonAlignment {
/// Places the addon before a single-line control.
#[default]
InlineStart,
/// Places the addon after a single-line control.
InlineEnd,
/// Places the addon above the control.
BlockStart,
/// Places the addon below the control.
BlockEnd,
}
/// A single-line input or multi-line textarea accepted by [`InputGroup`].
pub enum InputGroupControl {
/// A single-line input.
Input(Input),
/// A multi-line textarea.
Textarea(Textarea),
}
impl From<Input> for InputGroupControl {
fn from(value: Input) -> Self {
Self::Input(value)
}
}
impl From<Textarea> for InputGroupControl {
fn from(value: Textarea) -> Self {
Self::Textarea(value)
}
}
impl InputGroupControl {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self {
Self::Input(input) => input.field_focus_handle(cx),
Self::Textarea(textarea) => textarea.field_focus_handle(cx),
}
}
fn is_multiline(&self) -> bool {
matches!(self, Self::Textarea(_))
}
fn is_disabled(&self) -> bool {
match self {
Self::Input(input) => input.is_disabled(),
Self::Textarea(textarea) => textarea.is_disabled(),
}
}
fn is_read_only(&self) -> bool {
match self {
Self::Input(input) => input.is_read_only(),
Self::Textarea(textarea) => textarea.is_read_only(),
}
}
fn is_invalid(&self) -> bool {
match self {
Self::Input(input) => input.is_invalid(),
Self::Textarea(textarea) => textarea.is_invalid(),
}
}
fn render(
self,
label: Option<SharedString>,
disabled: bool,
read_only: bool,
invalid: bool,
) -> AnyElement {
match self {
Self::Input(input) => input
.when_some(label, |input, label| input.aria_label(label))
.when(disabled, |input| input.disabled(true))
.when(read_only, |input| input.read_only(true))
.when(invalid, |input| input.invalid(true))
.bordered(false)
.into_any_element(),
Self::Textarea(textarea) => textarea
.when_some(label, |textarea, label| textarea.aria_label(label))
.when(disabled, |textarea| textarea.disabled(true))
.when(read_only, |textarea| textarea.read_only(true))
.when(invalid, |textarea| textarea.invalid(true))
.bordered(false)
.into_any_element(),
}
}
}
/// A shared Nova frame around one text control and its addons.
#[derive(IntoElement)]
pub struct InputGroup {
id: ElementId,
control: InputGroupControl,
addons: Vec<InputGroupAddon>,
style: StyleRefinement,
label: Option<SharedString>,
disabled: bool,
read_only: bool,
invalid: bool,
}
impl InputGroup {
/// Creates a group around its required text control.
pub fn new(id: impl Into<ElementId>, control: impl Into<InputGroupControl>) -> Self {
Self {
id: id.into(),
control: control.into(),
addons: Vec::new(),
style: Default::default(),
label: None,
disabled: false,
read_only: false,
invalid: false,
}
}
/// Replaces the group's text control.
pub fn input(mut self, control: impl Into<InputGroupControl>) -> Self {
self.control = control.into();
self
}
/// Appends an explicitly aligned addon.
pub fn addon(mut self, addon: InputGroupAddon) -> Self {
self.addons.push(addon);
self
}
/// Sets the accessible name of the text control.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Disables the editor and addon actions.
pub fn disabled(mut self, value: bool) -> Self {
self.disabled = value;
self
}
/// Prevents edits while preserving selection, copying and addon actions.
pub fn read_only(mut self, value: bool) -> Self {
self.read_only = value;
self
}
/// Applies validation error colors and semantics to the editor.
pub fn invalid(mut self, value: bool) -> Self {
self.invalid = value;
self
}
}
impl Styled for InputGroup {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl FieldControl for InputGroup {
fn field_focus_handle(&self, cx: &App) -> FocusHandle {
self.control.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()
}
}
impl RenderOnce for InputGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = UiTheme::read(cx).clone();
let colors = theme.colors;
let focus = self.control.focus_handle(cx);
let disabled = self.disabled || self.control.is_disabled();
let read_only = self.read_only || self.control.is_read_only();
let invalid = self.invalid || self.control.is_invalid();
let focused = focus.is_focused(window) && !disabled;
let multiline = self.control.is_multiline();
let has_block = self.addons.iter().any(|addon| {
matches!(
addon.alignment,
InputGroupAddonAlignment::BlockStart | InputGroupAddonAlignment::BlockEnd
)
});
let mut inline_start = Vec::new();
let mut inline_end = Vec::new();
let mut block_start = Vec::new();
let mut block_end = Vec::new();
for addon in self.addons {
let rendered = addon.render_in_group(disabled, &theme);
match rendered.0 {
InputGroupAddonAlignment::InlineStart => inline_start.push(rendered.1),
InputGroupAddonAlignment::InlineEnd => inline_end.push(rendered.1),
InputGroupAddonAlignment::BlockStart => block_start.push(rendered.1),
InputGroupAddonAlignment::BlockEnd => block_end.push(rendered.1),
}
}
let control = self
.control
.render(self.label.clone(), disabled, read_only, invalid);
let click_focus = focus.clone();
let root = div()
.id(self.id)
.role(Role::Group)
.w_full()
.min_w_0()
.flex()
.flex_col()
.text_color(colors.foreground);
let root = focus::shown_for(
control::frame(root, &theme, invalid),
&theme,
focused,
invalid,
)
.when(!multiline && !has_block, |root| root.h(theme.space(8.)))
.when(disabled, |root| {
disabled::style(root)
.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
.capture_key_down(|event, _, cx| {
if event.keystroke.key != "tab" {
cx.stop_propagation();
}
})
})
.when(!disabled, |root| {
root.on_mouse_down(MouseButton::Left, move |_, window, cx| {
if !window.default_prevented() {
click_focus.focus(window, cx);
window.prevent_default();
}
})
})
.children(block_start)
.child(
div()
.flex()
.w_full()
.min_w_0()
.items_center()
.when(!multiline && !has_block, |row| row.h_full())
.children(inline_start)
.child(div().flex_1().min_w_0().child(control))
.children(inline_end),
)
.children(block_end);
super::theme::apply_style(root, &self.style)
}
}
enum InputGroupAddonChild {
Element(AnyElement),
Button(Box<InputGroupButton>),
}
/// Text, icons, buttons or custom content on one side of an input group.
#[derive(IntoElement)]
pub struct InputGroupAddon {
id: ElementId,
alignment: InputGroupAddonAlignment,
children: Vec<InputGroupAddonChild>,
style: StyleRefinement,
}
impl InputGroupAddon {
/// Creates an inline-start addon with a stable ID.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
alignment: Default::default(),
children: Vec::new(),
style: Default::default(),
}
}
/// Sets the addon's position around the text control.
pub fn align(mut self, alignment: InputGroupAddonAlignment) -> Self {
self.alignment = alignment;
self
}
/// Adds a compact action that inherits the group's disabled state.
pub fn button(mut self, button: InputGroupButton) -> Self {
self.children
.push(InputGroupAddonChild::Button(Box::new(button)));
self
}
fn render_in_group(
self,
disabled: bool,
theme: &UiTheme,
) -> (InputGroupAddonAlignment, AnyElement) {
let alignment = self.alignment;
let children = self.children.into_iter().map(|child| match child {
InputGroupAddonChild::Element(child) => child,
InputGroupAddonChild::Button(button) => (*button).disabled(disabled).into_any_element(),
});
let root = div()
.id(self.id)
.flex()
.flex_none()
.items_center()
.gap(theme.space(2.))
.font_family(theme.fonts.body.clone())
.text_size(theme.text(14.))
.text_color(theme.colors.muted_foreground)
.cursor_text()
.when(
matches!(alignment, InputGroupAddonAlignment::InlineStart),
|root| root.pl(theme.space(1.5)),
)
.when(
matches!(alignment, InputGroupAddonAlignment::InlineEnd),
|root| root.pr(theme.space(1.5)),
)
.when(
matches!(alignment, InputGroupAddonAlignment::BlockStart),
|root| {
root.w_full()
.justify_start()
.px(theme.space(2.5))
.pt(theme.space(2.))
},
)
.when(
matches!(alignment, InputGroupAddonAlignment::BlockEnd),
|root| {
root.w_full()
.justify_start()
.px(theme.space(2.5))
.pb(theme.space(2.))
},
)
.children(children);
(
alignment,
super::theme::apply_style(root, &self.style).into_any_element(),
)
}
}
impl ParentElement for InputGroupAddon {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children
.extend(elements.into_iter().map(InputGroupAddonChild::Element));
}
}
impl Styled for InputGroupAddon {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for InputGroupAddon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.render_in_group(false, UiTheme::read(cx)).1
}
}
/// The single-line control used by an input group.
pub type InputGroupInput = Input;
/// The multi-line control used by an input group.
pub type InputGroupTextarea = Textarea;
/// A compact input-group action backed by gpuicn's Button.
#[derive(IntoElement)]
pub struct InputGroupButton {
button: Button,
disabled: bool,
}
impl InputGroupButton {
/// Creates a compact ghost button with a stable ID.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
button: Button::new(id)
.variant(ButtonVariant::Ghost)
.size(ButtonSize::Xs),
disabled: false,
}
}
/// Sets the visible button label.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.button = self.button.label(label);
self
}
/// Sets the name announced to accessibility clients.
pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
self.button = self.button.aria_label(label);
self
}
/// Disables the action. A group-level disabled state is combined with this value.
pub fn disabled(mut self, value: bool) -> Self {
self.disabled |= value;
self
}
/// Runs a callback when the enabled button is activated.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.button = self.button.on_click(handler);
self
}
}
impl ParentElement for InputGroupButton {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.button.extend(elements);
}
}
impl Styled for InputGroupButton {
fn style(&mut self) -> &mut StyleRefinement {
self.button.style()
}
}
impl RenderOnce for InputGroupButton {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.button.disabled(self.disabled)
}
}
/// Muted helper text inside an input group.
#[derive(IntoElement, Default)]
pub struct InputGroupText {
style: StyleRefinement,
children: Vec<AnyElement>,
}
impl InputGroupText {
/// Creates an empty muted text container.
pub fn new() -> Self {
Self::default()
}
}
impl ParentElement for InputGroupText {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for InputGroupText {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for InputGroupText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = UiTheme::read(cx);
let root = div()
.flex()
.items_center()
.gap(theme.space(2.))
.text_size(theme.text(14.))
.text_color(theme.colors.muted_foreground)
.children(self.children);
super::theme::apply_style(root, &self.style)
}
}
Platform notes
Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation:
crates/ui/registry/input_group/input_group.rs, using GPUI Kit 0.6.4 editing states through
gpuicn's Input and Textarea.
Input Group provides one shared frame for a single-line input or textarea, with
inline start/end addons and block start/end toolbars. InputGroupButton keeps
gpuicn Button keyboard and accessibility behavior and inherits the group's
disabled state. The caller retains the editing state and owns validation and
submission. Input and Textarea paste hooks remain available inside the group.
Field::from_control can label and validate the whole composition.
The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, arbitrary nested addon controls and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.