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
+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;