Skip to main content

ui/components/
form_field.rs

1use gpui::{AnyElement, FontWeight, IntoElement, ParentElement, Styled};
2
3use crate::prelude::*;
4
5/// A label + input + help/error/success/warning wrapper for form layouts.
6///
7/// Note: `FormField` renders label/help/validation text around an opaque
8/// `AnyElement` child; it cannot retroactively restyle that child's internal
9/// focus ring. When `.error(...)`/`.success(...)`/`.warning(...)` is set,
10/// pass a child that already reflects that state itself (e.g.
11/// `TextInput::invalid(true)`, which already routes to `focus_ring_error`
12/// colors) — this is the caller's responsibility, matching how `TextInput`'s
13/// own validation flags work.
14#[derive(IntoElement, RegisterComponent)]
15pub struct FormField {
16    label: Option<SharedString>,
17    content: AnyElement,
18    help: Option<SharedString>,
19    error: Option<SharedString>,
20    success: Option<SharedString>,
21    warning: Option<SharedString>,
22}
23
24impl FormField {
25    pub fn new(content: impl IntoElement) -> Self {
26        Self {
27            label: None,
28            content: content.into_any_element(),
29            help: None,
30            error: None,
31            success: None,
32            warning: None,
33        }
34    }
35
36    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
37        self.label = Some(label.into());
38        self
39    }
40
41    pub fn help(mut self, help: impl Into<SharedString>) -> Self {
42        self.help = Some(help.into());
43        self
44    }
45
46    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
47        self.error = Some(error.into());
48        self
49    }
50
51    /// Shows a success message with a check-circle icon below the field.
52    /// Takes precedence over `.help(...)` but not `.error(...)`/`.warning(...)`.
53    pub fn success(mut self, success: impl Into<SharedString>) -> Self {
54        self.success = Some(success.into());
55        self
56    }
57
58    /// Shows a warning message with a warning icon below the field.
59    /// Takes precedence over `.help(...)`/`.success(...)` but not `.error(...)`.
60    pub fn warning(mut self, warning: impl Into<SharedString>) -> Self {
61        self.warning = Some(warning.into());
62        self
63    }
64}
65
66impl RenderOnce for FormField {
67    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
68        // Precedence: error > warning > success > help.
69        let validation_message = self
70            .error
71            .as_ref()
72            .map(|msg| (msg.clone(), IconName::XCircle, palette::danger(600)))
73            .or_else(|| {
74                self.warning
75                    .as_ref()
76                    .map(|msg| (msg.clone(), IconName::Warning, palette::warning(600)))
77            })
78            .or_else(|| {
79                self.success
80                    .as_ref()
81                    .map(|msg| (msg.clone(), IconName::CheckCircle, palette::success(600)))
82            });
83        let has_validation_message = validation_message.is_some();
84
85        v_flex()
86            .gap_1()
87            .when_some(self.label, |this, label| {
88                this.child(
89                    Label::new(label)
90                        .size(LabelSize::Small)
91                        .weight(FontWeight::MEDIUM),
92                )
93            })
94            .child(self.content)
95            .when_some(validation_message, |this, (message, icon, color)| {
96                this.child(
97                    h_flex()
98                        .gap_1()
99                        .items_center()
100                        .child(
101                            Icon::new(icon)
102                                .size(IconSize::XSmall)
103                                .color(Color::Custom(color)),
104                        )
105                        .child(
106                            Label::new(message)
107                                .size(LabelSize::XSmall)
108                                .color(Color::Custom(color)),
109                        ),
110                )
111            })
112            .when(!has_validation_message, |this| {
113                this.when_some(self.help, |this, help| {
114                    this.child(Label::new(help).size(LabelSize::XSmall).color(Color::Muted))
115                })
116            })
117    }
118}
119
120impl Component for FormField {
121    fn scope() -> ComponentScope {
122        ComponentScope::Input
123    }
124
125    fn description() -> Option<&'static str> {
126        Some("Label + input + optional help/error/success/warning text for form layouts.")
127    }
128
129    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
130        Some(
131            v_flex()
132                .gap_6()
133                .child(
134                    FormField::new(Label::new("you@example.com").color(Color::Placeholder))
135                        .label("Email")
136                        .help("We'll never share your email.")
137                        .into_any_element(),
138                )
139                .child(
140                    FormField::new(Label::new("").color(Color::Placeholder))
141                        .label("Password")
142                        .error("Password must be at least 8 characters.")
143                        .into_any_element(),
144                )
145                .child(
146                    FormField::new(Label::new("acme-corp").color(Color::Default))
147                        .label("Workspace slug")
148                        .success("This slug is available.")
149                        .into_any_element(),
150                )
151                .child(
152                    FormField::new(Label::new("admin@example.com").color(Color::Default))
153                        .label("Recovery email")
154                        .warning("This email is not yet verified.")
155                        .into_any_element(),
156                )
157                .into_any_element(),
158        )
159    }
160}