Skip to main content

tablo_ui/components/primitives/
field.rs

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