Skip to main content

herogpui_components/
field.rs

1//! Form field primitives — port of `@heroui/label`, `@heroui/description`,
2//! `@heroui/error-message`, `@heroui/field-error` and `@heroui/fieldset`.
3//!
4//! These are the composition-friendly slots that HeroUI v3 field components
5//! assemble internally, exposed here so applications can build custom fields
6//! with the same typography and states.
7//!
8//! `FieldsetRoot` in v3.2.4 also accepts the native `disabled` attribute and
9//! forwards it to descendants through React Aria contexts. GPUI has no
10//! ancestor context propagation and no DOM attribute graph, so this port
11//! deliberately ships no disabled state on [`Fieldset`]: a field composed
12//! inside one keeps every interaction
13//! (`text_fields::fieldset_disabled_disables_its_children` proves it).
14
15use gpui::{
16    div, px, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
17    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
18};
19
20use crate::a11y::{self, A11y as _};
21use herogpui_theme::ActiveTheme;
22
23/// HeroUI Label — `slot="label"`.
24///
25/// Mirrors the React API: `isRequired`, `isDisabled`, `isInvalid`. The
26/// `htmlFor` prop has no gpui analogue (there is no DOM id graph), so labels
27/// are associated by composition instead.
28#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
29#[derive(IntoElement)]
30pub struct Label {
31    text: SharedString,
32    is_required: bool,
33    is_disabled: bool,
34    is_invalid: bool,
35    /// `htmlFor` — the field this label names.
36    label_for: Option<(ElementId, gpui::FocusHandle)>,
37}
38
39impl Label {
40    /// Creates a label with the given text.
41    pub fn new(text: impl Into<SharedString>) -> Self {
42        Self {
43            text: text.into(),
44            is_required: false,
45            is_disabled: false,
46            is_invalid: false,
47            label_for: None,
48        }
49    }
50
51    /// `htmlFor` — associates the label with a field.
52    ///
53    /// In HTML this is an id reference; here it is the field's focus handle,
54    /// which is what makes the association do the one thing it does visibly:
55    /// clicking the label focuses the field. Pass a distinct `id` per label so
56    /// the click target has one.
57    pub fn label_for(mut self, id: impl Into<ElementId>, handle: gpui::FocusHandle) -> Self {
58        self.label_for = Some((id.into(), handle));
59        self
60    }
61
62    /// Sets the required state (v3 `isRequired`).
63    pub fn is_required(mut self, v: bool) -> Self {
64        self.is_required = v;
65        self
66    }
67
68    /// Sets the disabled state (v3 `isDisabled`).
69    pub fn is_disabled(mut self, v: bool) -> Self {
70        self.is_disabled = v;
71        self
72    }
73
74    /// Sets the invalid state (v3 `isInvalid`).
75    pub fn is_invalid(mut self, v: bool) -> Self {
76        self.is_invalid = v;
77        self
78    }
79}
80
81impl RenderOnce for Label {
82    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
83        let colors = cx.colors();
84        let mut el = div()
85            .flex()
86            .items_center()
87            .gap(px(2.))
88            .text_size(px(14.))
89            .line_height(px(20.))
90            .font_weight(gpui::FontWeight::MEDIUM)
91            .text_color(if self.is_invalid {
92                colors.danger.color
93            } else {
94                colors.foreground
95            })
96            .child(self.text.to_string());
97
98        if self.is_required {
99            el = el.child(div().text_color(colors.danger.color).child("*".to_owned()));
100        }
101
102        if self.is_disabled {
103            el = el.opacity(cx.layout().disabled_opacity);
104        }
105
106        // `htmlFor`: clicking the label focuses the field it names.
107        match self.label_for {
108            Some((id, handle)) if !self.is_disabled => el
109                .id(id)
110                .cursor(crate::util::interactive_cursor(cx))
111                .on_click(move |_: &gpui::ClickEvent, window: &mut Window, cx| {
112                    window.focus(&handle, cx);
113                })
114                .into_any_element(),
115            _ => el.into_any_element(),
116        }
117    }
118}
119
120/// HeroUI Description — `slot="description"`. De-emphasised helper copy shown
121/// beneath a field.
122#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
123#[derive(IntoElement)]
124pub struct Description {
125    text: SharedString,
126}
127
128impl Description {
129    /// Creates a description with the given text.
130    pub fn new(text: impl Into<SharedString>) -> Self {
131        Self { text: text.into() }
132    }
133}
134
135impl RenderOnce for Description {
136    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
137        div()
138            .text_size(px(12.))
139            .line_height(px(16.))
140            .text_color(cx.colors().muted)
141            .child(self.text.to_string())
142    }
143}
144
145/// HeroUI ErrorMessage — `slot="errorMessage"`. Always rendered when present.
146#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
147#[derive(IntoElement)]
148pub struct ErrorMessage {
149    text: SharedString,
150}
151
152impl ErrorMessage {
153    /// Creates an error message with the given text.
154    pub fn new(text: impl Into<SharedString>) -> Self {
155        Self { text: text.into() }
156    }
157}
158
159impl RenderOnce for ErrorMessage {
160    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
161        div()
162            .text_size(px(12.))
163            .line_height(px(16.))
164            .text_color(cx.colors().danger.color)
165            .child(self.text.to_string())
166    }
167}
168
169/// HeroUI FieldError — validation-driven error text.
170///
171/// Unlike [`ErrorMessage`], a `FieldError` manages its own visibility from the
172/// validation state: it renders nothing unless the field is invalid and a
173/// message is present.
174#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
175#[derive(IntoElement)]
176pub struct FieldError {
177    text: Option<SharedString>,
178    is_invalid: bool,
179    /// Optional stable identity used to retain and animate the row across
180    /// validation updates. Without an id the standalone slot keeps its
181    /// allocation-free static behavior for one-shot compositions.
182    id: Option<ElementId>,
183}
184
185impl FieldError {
186    /// Creates an empty field error.
187    pub fn new() -> Self {
188        Self {
189            text: None,
190            is_invalid: false,
191            id: None,
192        }
193    }
194
195    /// The validation message. Setting a message also marks the field invalid,
196    /// matching React Aria's `ValidationResult` behaviour.
197    pub fn message(mut self, text: impl Into<SharedString>) -> Self {
198        self.text = Some(text.into());
199        self.is_invalid = true;
200        self
201    }
202
203    /// Overrides visibility independently of the message.
204    pub fn is_invalid(mut self, v: bool) -> Self {
205        self.is_invalid = v;
206        self
207    }
208
209    /// Supplies the stable element identity needed for the v3 height/opacity
210    /// transition when a standalone FieldError is toggled across renders.
211    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
212        self.id = Some(id.into());
213        self
214    }
215}
216
217impl Default for FieldError {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl RenderOnce for FieldError {
224    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
225        let message = self.text.clone().filter(|_| self.is_invalid);
226        if let Some(id) = self.id {
227            return crate::anim::field_error_panel(&id, message, window, cx)
228                .unwrap_or_else(|| div().into_any_element());
229        }
230        match (self.is_invalid, self.text) {
231            // `.field-error` is `px-1`, which `.error-message` is not.
232            (true, Some(text)) => div()
233                .px(px(4.))
234                .child(ErrorMessage::new(text))
235                .into_any_element(),
236            _ => div().into_any_element(),
237        }
238    }
239}
240
241/// HeroUI Fieldset — groups related form controls under a legend.
242///
243/// Compose with [`FieldsetLegend`], [`FieldGroup`] and [`FieldsetActions`],
244/// mirroring `Fieldset.Legend` / `.Group` / `.Actions` in React.
245#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
246#[derive(IntoElement)]
247pub struct Fieldset {
248    gap: Pixels,
249    children: Vec<AnyElement>,
250    id: Option<ElementId>,
251}
252
253impl Fieldset {
254    /// Creates an empty fieldset.
255    pub fn new() -> Self {
256        Self {
257            gap: px(24.),
258            children: Vec::new(),
259            id: None,
260        }
261    }
262
263    /// Names this fieldset so it can report `role="group"`. Unnamed fieldsets
264    /// produce no AccessKit node.
265    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
266        self.id = Some(id.into());
267        self
268    }
269
270    /// Sets the gap between children.
271    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
272        self.gap = gap.into();
273        self
274    }
275}
276
277impl Default for Fieldset {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl ParentElement for Fieldset {
284    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
285        self.children.extend(elements);
286    }
287}
288
289impl RenderOnce for Fieldset {
290    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
291        let el = div()
292            .flex()
293            .flex_col()
294            .gap(self.gap)
295            // `.fieldset` also carries `shrink grow basis-0` so it fills a flex
296            // parent like any other section.
297            .flex_shrink(1.)
298            .flex_grow(1.)
299            .flex_basis(px(0.))
300            .text_color(cx.colors().foreground)
301            .children(self.children);
302        match self.id {
303            Some(id) => el.id(id).a11y(a11y::Role::Group).into_any_element(),
304            None => el.into_any_element(),
305        }
306    }
307}
308
309/// `Fieldset.Legend` — the group's caption.
310#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
311#[derive(IntoElement)]
312pub struct FieldsetLegend {
313    text: SharedString,
314}
315
316impl FieldsetLegend {
317    /// Creates a legend with the given text.
318    pub fn new(text: impl Into<SharedString>) -> Self {
319        Self { text: text.into() }
320    }
321}
322
323impl RenderOnce for FieldsetLegend {
324    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
325        div()
326            .text_size(px(16.))
327            .line_height(px(24.))
328            // `.fieldset__legend` is `text-base font-medium`, not semibold.
329            .font_weight(gpui::FontWeight::MEDIUM)
330            .text_color(cx.colors().foreground)
331            .child(self.text.to_string())
332    }
333}
334
335/// `Fieldset.Group` — layout wrapper for the grouped controls.
336#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
337#[derive(IntoElement)]
338pub struct FieldGroup {
339    gap: Pixels,
340    children: Vec<AnyElement>,
341}
342
343impl FieldGroup {
344    /// Creates an empty field group.
345    pub fn new() -> Self {
346        Self {
347            gap: px(16.),
348            children: Vec::new(),
349        }
350    }
351
352    /// Sets the gap between children.
353    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
354        self.gap = gap.into();
355        self
356    }
357}
358
359impl Default for FieldGroup {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365impl ParentElement for FieldGroup {
366    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
367        self.children.extend(elements);
368    }
369}
370
371/// `.fieldset__field-group` is `w-full space-y-4`: the stack of fields, 16px
372/// apart (`--spacing` × 4).
373impl RenderOnce for FieldGroup {
374    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
375        div()
376            .flex()
377            .flex_col()
378            .w_full()
379            .gap(self.gap)
380            .children(self.children)
381    }
382}
383
384/// `Fieldset.Actions` — trailing row for submit/cancel controls.
385#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
386#[derive(IntoElement)]
387pub struct FieldsetActions {
388    gap: Pixels,
389    children: Vec<AnyElement>,
390}
391
392impl FieldsetActions {
393    /// Creates an empty actions row.
394    pub fn new() -> Self {
395        Self {
396            gap: px(8.),
397            children: Vec::new(),
398        }
399    }
400
401    /// Sets the gap between children.
402    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
403        self.gap = gap.into();
404        self
405    }
406}
407
408impl Default for FieldsetActions {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414impl ParentElement for FieldsetActions {
415    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
416        self.children.extend(elements);
417    }
418}
419
420impl RenderOnce for FieldsetActions {
421    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
422        div()
423            .flex()
424            .flex_row()
425            .items_center()
426            // `.fieldset__actions` is `gap-2 pt-1`.
427            .pt(px(4.))
428            .gap(self.gap)
429            .children(self.children)
430    }
431}