Skip to main content

tablo_core/schema/
layouts.rs

1//! Holds the `Section`, `Group`, `Grid`, and `Repeater` containers that compose form layout.
2
3use tablo_ui::{
4    card_content, card_header, card_title, field_error as ui_field_error,
5    field_group as ui_field_group,
6};
7use topcoat::{
8    Result,
9    context::Cx,
10    view::{StaticClass, class, *},
11};
12
13use super::{
14    Schema,
15    fields::Field,
16    tree::{IntoSchema, Mode, Source, render_nodes},
17};
18
19/// Renders the one titled-group panel that `Section` and `Repeater` share.
20const PANEL: StaticClass = class!(
21    "flex flex-col gap-5 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-sm"
22);
23
24/// Holds a titled container with an optional child `Schema`.
25#[derive(Debug)]
26pub struct Section {
27    title: String,
28    pub(crate) children: Schema,
29    extra_class: Option<String>,
30}
31
32impl Section {
33    pub fn new(title: impl Into<String>) -> Self {
34        Self {
35            title: title.into(),
36            children: Schema::empty(),
37            extra_class: None,
38        }
39    }
40
41    pub fn schema(mut self, children: impl IntoSchema) -> Self {
42        self.children = children.into_schema();
43        self
44    }
45
46    /// Adds an additive `class` hook on the panel container.
47    pub fn class(mut self, class: impl Into<String>) -> Self {
48        self.extra_class = Some(class.into());
49        self
50    }
51
52    pub(crate) async fn render<'a>(
53        &self,
54        cx: &'a Cx,
55        fields: &[Field],
56        source: &Source<'_>,
57    ) -> Result<BoxView<'a>> {
58        let title = self.title.clone();
59        let extra = self.extra_class.clone();
60        if !self.children.nodes.is_empty() {
61            let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
62            Ok(view! {
63                cx =>
64                <div class=(class!(PANEL, extra.clone()))>
65                    card_header(card_title((title)))
66                    card_content(
67                        attrs: attributes! { class="flex flex-col gap-6" },
68                        (child_view)
69                    )
70                </div>
71            }
72            .boxed())
73        } else {
74            Ok(view! {
75                cx =>
76                <div class=(class!(PANEL, extra.clone()))>
77                    card_header(card_title((title)))
78                </div>
79            }
80            .boxed())
81        }
82    }
83}
84
85/// Holds an unlabelled container for grouping fields.
86#[derive(Debug, Default)]
87pub struct Group {
88    pub(crate) children: Schema,
89}
90
91impl Group {
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    pub fn schema(mut self, children: impl IntoSchema) -> Self {
97        self.children = children.into_schema();
98        self
99    }
100
101    pub(crate) async fn render<'a>(
102        &self,
103        cx: &'a Cx,
104        fields: &[Field],
105        source: &Source<'_>,
106    ) -> Result<BoxView<'a>> {
107        let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
108        Ok(view! { cx => ui_field_group((child_view)) }.boxed())
109    }
110}
111
112/// Holds a column container with `cols` clamped to 1..12.
113#[derive(Debug)]
114pub struct Grid {
115    cols: u8,
116    pub(crate) children: Schema,
117}
118
119impl Grid {
120    pub fn new(cols: u8) -> Self {
121        Self {
122            cols: cols.clamp(1, 12),
123            children: Schema::empty(),
124        }
125    }
126
127    pub fn schema(mut self, children: impl IntoSchema) -> Self {
128        self.children = children.into_schema();
129        self
130    }
131
132    pub(crate) async fn render<'a>(
133        &self,
134        cx: &'a Cx,
135        fields: &[Field],
136        source: &Source<'_>,
137    ) -> Result<BoxView<'a>> {
138        // Static literals for Tailwind scanner — `format!("grid grid-cols-{}")` would be
139        // purged because Tailwind only sees literal substrings.
140        let class: &'static str = match self.cols {
141            1 => "grid grid-cols-1 gap-4",
142            2 => "grid grid-cols-2 gap-4",
143            3 => "grid grid-cols-3 gap-4",
144            4 => "grid grid-cols-4 gap-4",
145            5 => "grid grid-cols-5 gap-4",
146            6 => "grid grid-cols-6 gap-4",
147            7 => "grid grid-cols-7 gap-4",
148            8 => "grid grid-cols-8 gap-4",
149            9 => "grid grid-cols-9 gap-4",
150            10 => "grid grid-cols-10 gap-4",
151            11 => "grid grid-cols-11 gap-4",
152            _ => "grid grid-cols-12 gap-4",
153        };
154        let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
155        Ok(view! { cx => <div class=(class)>(child_view)</div> }.boxed())
156    }
157}
158
159/// Holds a nested `Schema` rendered once as a titled group and reports a `required` empty group
160/// under its label.
161#[derive(Debug)]
162pub struct Repeater {
163    pub(crate) label: String,
164    pub(crate) children: Schema,
165    pub(crate) required: bool,
166}
167
168impl Repeater {
169    pub fn new(label: impl Into<String>) -> Self {
170        Self {
171            label: label.into(),
172            children: Schema::empty(),
173            required: false,
174        }
175    }
176
177    pub fn schema(mut self, children: impl IntoSchema) -> Self {
178        self.children = children.into_schema();
179        self
180    }
181
182    pub fn required(mut self) -> Self {
183        self.required = true;
184        self
185    }
186
187    pub fn label(&self) -> &str {
188        &self.label
189    }
190
191    pub(crate) async fn render<'a>(
192        &self,
193        cx: &'a Cx,
194        fields: &[Field],
195        source: &Source<'_>,
196    ) -> Result<BoxView<'a>> {
197        let title = self.label.clone();
198        let title_id = repeater_title_id(&self.label);
199        let has_children = !self.children.nodes.is_empty();
200        if source.mode() == Mode::View {
201            let child_view = if has_children {
202                Some(render_nodes(cx, &self.children.nodes, fields, source).await?)
203            } else {
204                None
205            };
206            return Ok(view! {
207                cx =>
208                <div
209                    class=(class!(PANEL, "ac-field"))
210                    role="group"
211                    aria-labelledby=(title_id.clone())
212                >
213                    card_header(
214                        card_title(
215                            attrs: attributes! { id=(title_id.clone()) },
216                            (title)
217                        )
218                    )
219                    if let Some(child_view) = child_view {
220                        card_content(
221                            attrs: attributes! { class="flex flex-col gap-6" },
222                            <div class="grid gap-4">(child_view)</div>
223                        )
224                    }
225                </div>
226            }
227            .boxed());
228        }
229        let required = self.required;
230        let own_error = source.errors_for(&self.label);
231        let has_error = own_error.is_some();
232        let error_text = own_error.unwrap_or_default().to_string();
233        let error_id = repeater_error_id(&self.label);
234        let container_class = if has_error {
235            "ac-field ac-field--error"
236        } else {
237            "ac-field"
238        };
239        let title_class = if has_error { "text-destructive" } else { "" };
240        if has_children {
241            let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
242            Ok(view! {
243                cx =>
244                <div
245                    class=(class!(PANEL, container_class))
246                    role="group"
247                    aria-labelledby=(title_id.clone())
248                    data-invalid=(has_error.then_some("true"))
249                    aria-invalid=(if has_error { "true" } else { "false" })
250                    aria-describedby=(has_error.then_some(error_id.clone()))
251                >
252                    card_header(
253                        card_title(
254                            attrs: attributes! { id=(title_id.clone()) class=(title_class) },
255                            (title)
256                            if required {
257                                <span class="text-destructive" aria-hidden="true">
258                                    "*"
259                                </span>
260                            }
261                        )
262                    )
263                    card_content(
264                        attrs: attributes! { class="flex flex-col gap-6" },
265                        <div class="grid gap-4">(child_view)</div>
266                        if has_error {
267                            ui_field_error(
268                                attrs: attributes! {
269                                    id=(error_id.clone())
270                                    class="ac-error"
271                                    aria-live="polite"
272                                },
273                                (error_text)
274                            )
275                        }
276                    )
277                </div>
278            }
279            .boxed())
280        } else {
281            Ok(view! {
282                cx =>
283                <div
284                    class=(class!(PANEL, container_class))
285                    role="group"
286                    aria-labelledby=(title_id.clone())
287                    data-invalid=(has_error.then_some("true"))
288                    aria-invalid=(if has_error { "true" } else { "false" })
289                    aria-describedby=(has_error.then_some(error_id.clone()))
290                >
291                    card_header(
292                        card_title(
293                            attrs: attributes! { id=(title_id.clone()) class=(title_class) },
294                            (title)
295                            if required {
296                                <span class="text-destructive" aria-hidden="true">
297                                    "*"
298                                </span>
299                            }
300                        )
301                    )
302                    if has_error {
303                        card_content(
304                            attrs: attributes! { class="flex flex-col gap-6" },
305                            ui_field_error(
306                                attrs: attributes! {
307                                    id=(error_id.clone())
308                                    class="ac-error"
309                                    aria-live="polite"
310                                },
311                                (error_text)
312                            )
313                        )
314                    }
315                </div>
316            }
317            .boxed())
318        }
319    }
320}
321
322/// Returns the DOM id of a repeater's error node, slugging the label.
323fn repeater_error_id(label: &str) -> String {
324    format!("{}-error", repeater_slug(label))
325}
326
327/// Returns the DOM id of a repeater's title node.
328fn repeater_title_id(label: &str) -> String {
329    format!("{}-title", repeater_slug(label))
330}
331
332fn repeater_slug(label: &str) -> String {
333    let mut slug = String::with_capacity(label.len());
334    for character in label.chars() {
335        if character.is_ascii_alphanumeric() {
336            slug.push(character.to_ascii_lowercase());
337        } else if !slug.ends_with('-') {
338            slug.push('-');
339        }
340    }
341    slug.trim_matches('-').to_string()
342}
343
344#[cfg(test)]
345mod tests;