Skip to main content

tablo_ui/components/primitives/
field.rs

1// SYNC: topcoat-ui-registry@0.12.0 sha256:96793d367b56bf2c314572104207c890174db4a7fed621769bf4b2cbe7b13802 — do not hand-edit. Sync via `cargo xtask sync-topcoat-ui` (ADR-0007).
2use topcoat::{
3    Result,
4    context::Cx,
5    view::{Attributes, Child, StaticClass, View, attributes, class, component, view},
6};
7
8use super::label::label;
9
10/// The layout of a [`field`].
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12#[allow(dead_code)]
13pub enum FieldOrientation {
14    /// Stack the label, control, and supporting text.
15    #[default]
16    Vertical,
17    /// Place a control beside its label or [`field_content`].
18    Horizontal,
19    /// Switch to a row when the enclosing [`field_group`] is wide enough.
20    Responsive,
21}
22
23impl FieldOrientation {
24    fn classes(self) -> StaticClass {
25        match self {
26            Self::Vertical => class!("flex-col gap-2"),
27            Self::Horizontal => {
28                class!("flex-row items-center gap-3 [&>[data-slot=field-label]]:flex-1")
29            }
30            Self::Responsive => class!(
31                "flex-col gap-2 @md/field-group:flex-row @md/field-group:items-start",
32                "@md/field-group:gap-4 @md/field-group:[&>[data-slot=field-label]]:w-1/3",
33                "@md/field-group:[&>[data-slot=field-label]]:shrink-0",
34            ),
35        }
36    }
37}
38
39/// The text size of a [`field_legend`].
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41#[allow(dead_code)]
42pub enum FieldLegendVariant {
43    /// A heading for a section of the form.
44    #[default]
45    Legend,
46    /// A smaller heading matching a field label.
47    Label,
48}
49
50impl FieldLegendVariant {
51    fn classes(self) -> StaticClass {
52        match self {
53            Self::Legend => class!("text-base font-semibold"),
54            Self::Label => class!("text-sm font-medium"),
55        }
56    }
57}
58
59/// A semantic group of related controls, named by a [`field_legend`].
60///
61/// Attributes are forwarded to the `<fieldset>`. Pass `disabled` to disable
62/// its controls together. Classes are appended to the component's classes,
63/// as with the other field components.
64#[component]
65pub async fn field_set(
66    cx: &Cx,
67    #[default] mut attrs: Attributes,
68    #[default] child: Child<'_>,
69) -> Result<impl View> {
70    Ok(view! {
71        <fieldset
72            class=(class!("flex min-w-0 flex-col gap-5", attrs.remove("class")))
73            (attrs)
74        >
75            (child)
76        </fieldset>
77    })
78}
79
80/// The accessible heading of a [`field_set`]. Place it first in the set.
81#[component]
82pub async fn field_legend(
83    cx: &Cx,
84    #[default] variant: FieldLegendVariant,
85    #[default] mut attrs: Attributes,
86    #[default] child: Child<'_>,
87) -> Result<impl View> {
88    Ok(view! {
89        <legend
90            class=(class!("mb-3", variant.classes(), attrs.remove("class")))
91            (attrs)
92        >
93            (child)
94        </legend>
95    })
96}
97
98/// A stack of fields, with a container for responsive field layouts.
99#[component]
100pub async fn field_group(
101    cx: &Cx,
102    #[default] mut attrs: Attributes,
103    #[default] child: Child<'_>,
104) -> Result<impl View> {
105    Ok(view! {
106        <div
107            class=(class!(
108                "@container/field-group flex flex-col gap-5",
109                attrs.remove("class"),
110            ))
111            (attrs)
112        >
113            (child)
114        </div>
115    })
116}
117
118/// A control and its label, description, and optional error message.
119///
120/// Compose it with existing inputs, selects, checkboxes, or other controls.
121/// Set `for` on [`field_label`] to the control's `id`, and connect descriptions
122/// and errors through the control's `aria-describedby`. Set `aria-invalid`
123/// to `"true"` on an invalid control; the field then colors its label too.
124/// A `data-invalid="true"` attribute on the field also colors its label.
125/// Validation and the visibility of error messages belong to the caller.
126/// Attributes are forwarded to the wrapper and classes are appended.
127#[component]
128pub async fn field(
129    cx: &Cx,
130    #[default] orientation: FieldOrientation,
131    #[default] mut attrs: Attributes,
132    #[default] child: Child<'_>,
133) -> Result<impl View> {
134    Ok(view! {
135        <div
136            role="group"
137            data-slot="field"
138            class=(class!(
139                "group/field flex min-w-0",
140                orientation.classes(),
141                attrs.remove("class"),
142            ))
143            (attrs)
144        >
145            (child)
146        </div>
147    })
148}
149
150/// A flexible column grouping a field's label, control, or supporting text.
151#[component]
152pub async fn field_content(
153    cx: &Cx,
154    #[default] mut attrs: Attributes,
155    #[default] child: Child<'_>,
156) -> Result<impl View> {
157    Ok(view! {
158        <div
159            class=(class!("flex min-w-0 flex-1 flex-col gap-1.5", attrs.remove("class")))
160            (attrs)
161        >
162            (child)
163        </div>
164    })
165}
166
167/// A [`label`] that follows its field's disabled and invalid states.
168///
169/// Pass `for` to associate the label with a control's `id`.
170#[component]
171pub async fn field_label(
172    cx: &Cx,
173    #[default] mut attrs: Attributes,
174    #[default] child: Child<'_>,
175) -> Result<impl View> {
176    Ok(view! {
177        label(
178            attrs: attributes! {
179                data-slot="field-label"
180                class=(class!(
181                    "leading-snug group-has-[:disabled]/field:opacity-50",
182                    "group-has-[[aria-invalid=true]]/field:text-destructive",
183                    "group-data-[invalid=true]/field:text-destructive",
184                    attrs.remove("class"),
185                ))
186                (attrs)
187            },
188            (child)
189        )
190    })
191}
192
193/// Label-sized text that does not label a control. Use [`field_label`] for that.
194#[component]
195pub async fn field_title(
196    cx: &Cx,
197    #[default] mut attrs: Attributes,
198    #[default] child: Child<'_>,
199) -> Result<impl View> {
200    Ok(view! {
201        <div
202            class=(class!("text-sm leading-snug font-medium", attrs.remove("class")))
203            (attrs)
204        >
205            (child)
206        </div>
207    })
208}
209
210/// Supporting text. Give it an `id` referenced by the control's `aria-describedby`.
211#[component]
212pub async fn field_description(
213    cx: &Cx,
214    #[default] mut attrs: Attributes,
215    #[default] child: Child<'_>,
216) -> Result<impl View> {
217    Ok(view! {
218        <p
219            class=(class!(
220                "text-sm leading-relaxed text-muted-foreground [&_a]:underline [&_a]:underline-offset-4",
221                attrs.remove("class"),
222            ))
223            (attrs)
224        >
225            (child)
226        </p>
227    })
228}
229
230/// A decorative divider with optional text between sections of a form.
231#[component]
232pub async fn field_separator(
233    cx: &Cx,
234    #[default] mut attrs: Attributes,
235    #[default] child: Child<'_>,
236) -> Result<impl View> {
237    Ok(view! {
238        <div
239            class=(class!(
240                "flex items-center gap-3 text-xs text-muted-foreground has-[>span:empty]:gap-0 before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border",
241                attrs.remove("class"),
242            ))
243            (attrs)
244        >
245            <span class="empty:hidden">(child)</span>
246        </div>
247    })
248}
249
250/// An error message for a field, announced when it appears.
251///
252/// Render it only when there is an error. Give it an `id` referenced by the
253/// control's `aria-describedby`, and set `aria-invalid="true"` on the control.
254/// Child content can be a message or a list of messages.
255#[component]
256pub async fn field_error(
257    cx: &Cx,
258    #[default] mut attrs: Attributes,
259    #[default] child: Child<'_>,
260) -> Result<impl View> {
261    Ok(view! {
262        <div
263            role="alert"
264            class=(class!("text-sm text-destructive", attrs.remove("class")))
265            (attrs)
266        >
267            (child)
268        </div>
269    })
270}