feat: add field component

This commit is contained in:
2026-11-03 19:07:15 +07:00
parent 7399d92f36
commit 0c8d7f05d4
6 changed files with 404 additions and 7 deletions
+2
View File
@@ -33,6 +33,7 @@ full = [
"scroll_area",
"segmented",
"spinner",
"field",
"tooltip",
"menu",
"select",
@@ -57,6 +58,7 @@ popover = []
scroll_area = []
segmented = []
spinner = []
field = []
tooltip = []
menu = ["scroll_area"]
select = ["menu"]
+175
View File
@@ -0,0 +1,175 @@
//! The field: a label, description, and error wrapped around a control.
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
};
use gpui_native_theme::ActiveTheme as _;
use crate::common::{StyledExt, h_flex, v_flex};
/// The arrangement of a [`Field`]'s label and control.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FieldVariant {
/// The label block sits above the control, full width.
#[default]
Block,
/// The label block leads and the control trails — the settings row.
Row,
}
/// A labeled control: the label, description, and error around any element.
#[derive(IntoElement)]
pub struct Field {
id: ElementId,
label: SharedString,
description: Option<SharedString>,
error: Option<SharedString>,
variant: FieldVariant,
control: Vec<AnyElement>,
style: StyleRefinement,
}
impl Field {
/// Creates a field naming its control.
pub fn new<I, L>(id: I, label: L) -> Self
where
I: Into<ElementId>,
L: Into<SharedString>,
{
Self {
id: id.into(),
label: label.into(),
description: None,
error: None,
variant: FieldVariant::default(),
control: Vec::new(),
style: StyleRefinement::default(),
}
}
/// The helper text under the label.
pub fn description<D>(mut self, description: D) -> Self
where
D: Into<SharedString>,
{
self.description = Some(description.into());
self
}
/// The error under the control; `None` clears it.
pub fn error<E>(mut self, error: Option<E>) -> Self
where
E: Into<SharedString>,
{
self.error = error.map(Into::into);
self
}
/// Sets the field's arrangement.
pub fn variant(mut self, variant: FieldVariant) -> Self {
self.variant = variant;
self
}
}
impl ParentElement for Field {
fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
self.control.extend(children);
}
}
impl Styled for Field {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Field {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let metrics = theme.sizes.medium;
let error_color = theme.ramps.destructive.base;
let id = self.id.clone();
let title = div()
.debug_selector({
let name = format!("{id}-label");
move || name
})
.text_size(metrics.text_size)
.child(self.label.clone());
let control = h_flex()
.items_center()
.gap_2()
.debug_selector({
let name = format!("{id}-control");
move || name
})
.children(self.control);
let error = self.error.map(|error| {
div()
.debug_selector({
let name = format!("{id}-error");
move || name
})
.text_size(metrics.caption_size)
.text_color(error_color)
.child(error)
});
let description_text = |cx: &App| {
div()
.text_size(metrics.caption_size)
.text_color(cx.theme().colors.muted_foreground)
};
let content = if self.variant == FieldVariant::Row {
div()
.flex()
.flex_col()
.gap_1()
.child(
h_flex()
.items_center()
.justify_between()
.gap_4()
.child(title.flex_1().min_w_0())
.child(control.flex_none()),
)
.when_some(self.description.clone(), |field, description| {
field.child(description_text(cx).child(description))
})
.when_some(error, |field, error| field.child(error))
} else {
div()
.flex()
.flex_col()
.gap_2()
.child(
v_flex()
.gap_0p5()
.child(title)
.when_some(self.description.clone(), |slot, description| {
slot.child(description_text(cx).child(description))
}),
)
.child(control)
.when_some(error, |field, error| field.child(error))
};
div()
.id(self.id)
.w_full()
.role(Role::Group)
.aria_label(self.label.clone())
.when_some(self.description, |field, description| {
field.aria_description(description)
})
.refine_style(&self.style)
.child(content)
}
}
+3
View File
@@ -69,6 +69,9 @@ pub mod segmented;
#[cfg(feature = "spinner")]
pub mod spinner;
#[cfg(feature = "field")]
pub mod field;
#[cfg(feature = "toast")]
pub mod toast;
+115
View File
@@ -0,0 +1,115 @@
use gpui::{
AppContext as _, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext,
VisualTestContext, Window, div,
};
use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::field::{Field, FieldVariant};
use gpui_native_theme::Theme;
struct FieldHost {
variant: FieldVariant,
with_error: bool,
}
impl Render for FieldHost {
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
let mut field = Field::new("test-field", "Server URL")
.description("Where patches are sent.")
.variant(self.variant)
.child(
Button::new("test-field-button")
.label("Edit")
.variant(ButtonVariant::Ghost),
);
if self.with_error {
field = field.error(Some("Required"));
}
div().size_full().p_4().child(field)
}
}
fn field_window(
variant: FieldVariant,
with_error: bool,
) -> (TestAppContext, gpui::AnyWindowHandle) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let window = cx.add_window(move |_, _| FieldHost {
variant,
with_error,
});
let window: gpui::AnyWindowHandle = window.into();
cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
})
.unwrap();
(cx, window)
}
#[test]
fn block_stacks_the_label_above_the_control() {
let (cx, window) = field_window(FieldVariant::Block, false);
let mut visual = VisualTestContext::from_window(window, &cx);
let label = visual
.debug_bounds("test-field-label")
.expect("label missing");
let control = visual
.debug_bounds("test-field-control")
.expect("control missing");
assert!(
label.bottom() <= control.top(),
"label must sit above the control: {label:?} vs {control:?}"
);
assert!(
(label.left() - control.left()).abs() < gpui::px(1.),
"label and control must share the left edge"
);
}
#[test]
fn row_leads_with_the_label_and_trails_the_control() {
let (cx, window) = field_window(FieldVariant::Row, false);
let mut visual = VisualTestContext::from_window(window, &cx);
let label = visual
.debug_bounds("test-field-label")
.expect("label missing");
let control = visual
.debug_bounds("test-field-control")
.expect("control missing");
assert!(
control.left() >= label.right(),
"the control must trail the label: {label:?} vs {control:?}"
);
let label_center = (label.top() + label.bottom()) / 2.;
let control_center = (control.top() + control.bottom()) / 2.;
assert!(
(label_center - control_center).abs() < gpui::px(1.5),
"the control must center on the title line: {label:?} vs {control:?}"
);
}
#[test]
fn the_error_renders_under_the_control_only_when_set() {
let (cx, window) = field_window(FieldVariant::Block, false);
let mut visual = VisualTestContext::from_window(window, &cx);
assert!(
visual.debug_bounds("test-field-error").is_none(),
"no error must render when none is set"
);
let (cx, window) = field_window(FieldVariant::Block, true);
let mut visual = VisualTestContext::from_window(window, &cx);
let error = visual
.debug_bounds("test-field-error")
.expect("error missing");
let control = visual
.debug_bounds("test-field-control")
.expect("control missing");
assert!(
error.top() >= control.bottom(),
"the error must sit under the control: {control:?} vs {error:?}"
);
}