Skip to main content

tablo_core/schema/
layouts.rs

1//! Holds the `Section`, `Group`, `Grid` and `Tabs` blocks that lay out a form's fields or a detail
2//! page's columns.
3
4use tablo_ui::{card_content, card_header, card_title, field_group as ui_field_group};
5use topcoat::{
6    Result,
7    context::Cx,
8    view::{StaticClass, class, *},
9};
10
11use super::{
12    Schema,
13    condition::{Condition, Watched},
14    fields::Field,
15    tree::{IntoSchema, Node, Retype, Source, render_nodes},
16};
17
18/// Renders a `Section`'s titled panel: a title row, ruled off from the body under it.
19const PANEL: StaticClass = class!(
20    "flex flex-col rounded-xl border border-border bg-card text-card-foreground shadow-xs",
21    "[&>[data-section-title]:not(:last-child)]:border-b [&>[data-section-title]]:border-border",
22);
23
24/// A titled block holding a form's fields ([`schema`](Self::schema)) or a detail page's columns
25/// ([`columns`](Self::columns), which `detail` declares).
26#[derive(Debug)]
27pub struct Section<C = Schema> {
28    title: crate::Text,
29    pub(crate) children: C,
30    extra_class: Option<String>,
31    pub(crate) condition: Option<Condition>,
32}
33
34impl Section<()> {
35    pub fn new(title: impl Into<crate::Text>) -> Self {
36        Self {
37            title: title.into(),
38            children: (),
39            extra_class: None,
40            condition: None,
41        }
42    }
43
44    /// Holds a form's fields and blocks.
45    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Section<Schema<F>> {
46        self.holding(children.into_schema())
47    }
48}
49
50impl<C> Section<C> {
51    /// Adds an additive `class` hook on the panel container.
52    pub fn class(mut self, class: impl Into<String>) -> Self {
53        self.extra_class = Some(class.into());
54        self
55    }
56
57    pub(crate) fn holding<D>(self, children: D) -> Section<D> {
58        self.map(|_| children)
59    }
60
61    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Section<D> {
62        Section {
63            title: self.title,
64            children: f(self.children),
65            extra_class: self.extra_class,
66            condition: self.condition,
67        }
68    }
69
70    /// The titled panel around `body`, or the title alone when the section holds nothing.
71    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: Option<BoxView<'a>>) -> BoxView<'a> {
72        section_chrome(cx, &self.title, self.extra_class.as_deref(), body)
73    }
74}
75
76// The chromes are free functions, not methods of the generic blocks, so tablo-core compiles each
77// view once rather than once for every detail model an app declares.
78
79fn section_chrome<'a>(
80    cx: &'a Cx,
81    title: &crate::Text,
82    extra: Option<&str>,
83    body: Option<BoxView<'a>>,
84) -> BoxView<'a> {
85    let title = title.clone();
86    let extra = extra.map(str::to_string);
87    match body {
88        Some(body) => view! {
89            cx =>
90            <div class=(class!(PANEL, extra.clone()))>
91                card_header(
92                    attrs: attributes! { class="py-4" data-section-title="" },
93                    card_title((title))
94                )
95                card_content(
96                    attrs: attributes! { class="flex flex-col gap-6 py-6" },
97                    (body)
98                )
99            </div>
100        }
101        .boxed(),
102        None => view! {
103            cx =>
104            <div class=(class!(PANEL, extra.clone()))>
105                card_header(
106                    attrs: attributes! { class="py-4" data-section-title="" },
107                    card_title((title))
108                )
109            </div>
110        }
111        .boxed(),
112    }
113}
114
115impl Section<Schema> {
116    pub(crate) async fn render<'a>(
117        &self,
118        cx: &'a Cx,
119        fields: &[Field],
120        source: &Source<'_>,
121    ) -> Result<BoxView<'a>> {
122        let body = if self.children.nodes.is_empty() {
123            None
124        } else {
125            Some(render_nodes(cx, &self.children.nodes, fields, source).await?)
126        };
127        Ok(self.chrome(cx, body))
128    }
129}
130
131/// An unlabelled block grouping a form's fields or a detail page's columns.
132#[derive(Debug)]
133pub struct Group<C = Schema> {
134    pub(crate) children: C,
135    pub(crate) condition: Option<Condition>,
136}
137
138impl Default for Group<()> {
139    fn default() -> Self {
140        Self {
141            children: (),
142            condition: None,
143        }
144    }
145}
146
147impl Group<()> {
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Holds a form's fields and blocks.
153    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Group<Schema<F>> {
154        self.holding(children.into_schema())
155    }
156}
157
158impl<C> Group<C> {
159    pub(crate) fn holding<D>(self, children: D) -> Group<D> {
160        self.map(|_| children)
161    }
162
163    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Group<D> {
164        Group {
165            children: f(self.children),
166            condition: self.condition,
167        }
168    }
169
170    /// The group around `body`.
171    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: BoxView<'a>) -> BoxView<'a> {
172        group_chrome(cx, body)
173    }
174}
175
176fn group_chrome<'a>(cx: &'a Cx, body: BoxView<'a>) -> BoxView<'a> {
177    view! { cx => ui_field_group((body)) }.boxed()
178}
179
180impl Group<Schema> {
181    pub(crate) async fn render<'a>(
182        &self,
183        cx: &'a Cx,
184        fields: &[Field],
185        source: &Source<'_>,
186    ) -> Result<BoxView<'a>> {
187        let body = render_nodes(cx, &self.children.nodes, fields, source).await?;
188        Ok(self.chrome(cx, body))
189    }
190}
191
192/// A block of `cols` columns, clamped to 1..12, laying out a form's fields or a detail page's
193/// columns.
194#[derive(Debug)]
195pub struct Grid<C = Schema> {
196    cols: u8,
197    pub(crate) children: C,
198    pub(crate) condition: Option<Condition>,
199}
200
201impl Grid<()> {
202    pub fn new(cols: u8) -> Self {
203        Self {
204            cols: cols.clamp(1, 12),
205            children: (),
206            condition: None,
207        }
208    }
209
210    /// Holds a form's fields and blocks.
211    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Grid<Schema<F>> {
212        self.holding(children.into_schema())
213    }
214}
215
216impl<C> Grid<C> {
217    pub(crate) fn holding<D>(self, children: D) -> Grid<D> {
218        self.map(|_| children)
219    }
220
221    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Grid<D> {
222        Grid {
223            cols: self.cols,
224            children: f(self.children),
225            condition: self.condition,
226        }
227    }
228
229    /// The grid around `body`.
230    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: BoxView<'a>) -> BoxView<'a> {
231        grid_chrome(cx, self.cols, body)
232    }
233}
234
235fn grid_chrome<'a>(cx: &'a Cx, cols: u8, body: BoxView<'a>) -> BoxView<'a> {
236    // Static literals for Tailwind scanner — `format!("grid grid-cols-{}")` would be
237    // purged because Tailwind only sees literal substrings.
238    let class: &'static str = match cols {
239        1 => "grid grid-cols-1 gap-4",
240        2 => "grid grid-cols-2 gap-4",
241        3 => "grid grid-cols-3 gap-4",
242        4 => "grid grid-cols-4 gap-4",
243        5 => "grid grid-cols-5 gap-4",
244        6 => "grid grid-cols-6 gap-4",
245        7 => "grid grid-cols-7 gap-4",
246        8 => "grid grid-cols-8 gap-4",
247        9 => "grid grid-cols-9 gap-4",
248        10 => "grid grid-cols-10 gap-4",
249        11 => "grid grid-cols-11 gap-4",
250        _ => "grid grid-cols-12 gap-4",
251    };
252    view! { cx => <div class=(class)>(body)</div> }.boxed()
253}
254
255impl Grid<Schema> {
256    pub(crate) async fn render<'a>(
257        &self,
258        cx: &'a Cx,
259        fields: &[Field],
260        source: &Source<'_>,
261    ) -> Result<BoxView<'a>> {
262        let body = render_nodes(cx, &self.children.nodes, fields, source).await?;
263        Ok(self.chrome(cx, body))
264    }
265}
266
267/// Generates `visible_when` for every layout block holding a form's fields.
268macro_rules! conditional_blocks {
269    ($($ty:ident),+ $(,)?) => {
270        $(
271            impl<F> $ty<Schema<F>> {
272                /// Shows the block and every field it holds only while `watched` posts one of
273                /// `values`, as [`TextField::visible_when`](crate::TextField::visible_when) shows a
274                /// field.
275                pub fn visible_when(
276                    mut self,
277                    watched: &impl Watched<F>,
278                    values: impl IntoIterator<Item = impl Into<String>>,
279                ) -> Self {
280                    self.condition = Some(Condition::new(watched, values));
281                    self
282                }
283            }
284        )+
285    };
286}
287
288conditional_blocks!(Section, Group, Grid, Tabs, Tab);
289
290/// Renders a `Tabs` block's list of triggers.
291const TAB_LIST: StaticClass = class!(
292    "inline-flex h-9 w-fit max-w-full items-center overflow-x-auto rounded-lg bg-muted p-[3px] \
293     text-muted-foreground"
294);
295/// Renders one trigger, which the selected tab raises.
296const TAB_TRIGGER: StaticClass = class!(
297    "inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border \
298     border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground \
299     transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] \
300     focus-visible:ring-ring/50 aria-selected:bg-background aria-selected:shadow-sm \
301     dark:text-muted-foreground dark:aria-selected:border-input dark:aria-selected:bg-input/30 \
302     dark:aria-selected:text-foreground data-tab-error:text-destructive"
303);
304
305/// A block showing one [`Tab`] at a time, each holding a form's fields
306/// ([`Tab::schema`]) or a detail page's columns ([`Tab::columns`]).
307///
308/// ```rust
309/// # #[derive(Debug, toasty::Model, tablo_core::ModelColumns)]
310/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String, bio: String }
311/// use tablo_core::{Field, Schema, Tab, Tabs};
312///
313/// let user = User::fields();
314/// Schema::new(Tabs::new([
315///     Tab::new("Account").schema((Field::text(user.name()), Field::text(user.email()))),
316///     Tab::new("About").schema(Field::text(user.bio()).multiline(4)),
317/// ]));
318/// ```
319///
320/// Every tab's fields render and post: an inactive tab's are only hidden, so a submission carries
321/// them as if no tab held them, and the browser's own checks still refuse what they refuse. The
322/// page opens on the first tab holding a field error, else on the first tab; the browser opens
323/// the tab of a control it refuses on submit. A tab whose every field or column the request may
324/// not see renders no trigger, and a block with no such tab renders nothing.
325#[derive(Debug)]
326pub struct Tabs<C = Schema> {
327    /// The block's tabs, each one node.
328    pub(crate) children: C,
329    pub(crate) condition: Option<Condition>,
330}
331
332impl<C: HoldsTabs> Tabs<C> {
333    /// Holds `tabs`, in the order their triggers show.
334    pub fn new(tabs: impl IntoIterator<Item = Tab<C>>) -> Self {
335        let mut children = C::default();
336        for tab in tabs {
337            children.push_tab(tab);
338        }
339        Self {
340            children,
341            condition: None,
342        }
343    }
344}
345
346impl<C> Tabs<C> {
347    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Tabs<D> {
348        Tabs {
349            children: f(self.children),
350            condition: self.condition,
351        }
352    }
353}
354
355/// One tab of a [`Tabs`] block: a titled trigger and the fields or columns it shows.
356#[derive(Debug)]
357pub struct Tab<C = Schema> {
358    label: crate::Text,
359    pub(crate) children: C,
360    pub(crate) condition: Option<Condition>,
361}
362
363impl Tab<()> {
364    pub fn new(label: impl Into<crate::Text>) -> Self {
365        Self {
366            label: label.into(),
367            children: (),
368            condition: None,
369        }
370    }
371
372    /// Holds a form's fields and blocks.
373    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Tab<Schema<F>> {
374        self.holding(children.into_schema())
375    }
376}
377
378impl<C> Tab<C> {
379    pub(crate) fn holding<D>(self, children: D) -> Tab<D> {
380        self.map(|_| children)
381    }
382
383    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Tab<D> {
384        Tab {
385            label: self.label,
386            children: f(self.children),
387            condition: self.condition,
388        }
389    }
390
391    /// The trigger's text.
392    pub(crate) fn label(&self) -> &crate::Text {
393        &self.label
394    }
395}
396
397/// What a [`Tabs`] block holds its tabs in: a form's [`Schema`] or a detail page's
398/// [`Detail`](crate::Detail).
399#[doc(hidden)]
400pub trait HoldsTabs: Default + Sized {
401    fn push_tab(&mut self, tab: Tab<Self>);
402}
403
404impl<F> HoldsTabs for Schema<F> {
405    fn push_tab(&mut self, tab: Tab<Self>) {
406        let mut fields = Vec::new();
407        let tab = tab.map(|mut children| {
408            fields = std::mem::take(&mut children.fields);
409            children.retype()
410        });
411        self.append(Schema::from_parts(vec![Node::Tab(Box::new(tab))], fields));
412    }
413}
414
415/// The DOM id prefix of the tab whose first field or column renders under `id`, inside `depth`
416/// tabs: an outer tab and the first tab of a block it holds start at the same field.
417pub(crate) fn tab_id(id: &str, depth: usize) -> String {
418    format!("{id}-{depth}")
419}
420
421/// One tab of a [`Tabs`] block, rendered: its trigger and its panel apart, so a condition on the
422/// tab guards each.
423pub(crate) struct TabView<'a> {
424    pub(crate) trigger: BoxView<'a>,
425    pub(crate) panel: BoxView<'a>,
426}
427
428/// Renders the tab `id` of a block whose active tab is `active`: a trigger reading `label`, and a
429/// panel holding `body`, hidden unless `active`. `errored` marks the trigger of a tab holding a
430/// field error.
431pub(crate) fn tab_view<'a>(
432    cx: &'a Cx,
433    id: &str,
434    label: &crate::Text,
435    active: bool,
436    errored: bool,
437    body: BoxView<'a>,
438) -> TabView<'a> {
439    let tab = format!("{id}-tab");
440    let panel = format!("{id}-tabpanel");
441    let (labelled_by, panel_id) = (tab.clone(), panel.clone());
442    let label = label.localize(cx);
443    let selected = if active { "true" } else { "false" };
444    let tabindex = if active { "0" } else { "-1" };
445    let trigger = view! {
446        cx =>
447        <button
448            type="button"
449            role="tab"
450            id=(tab)
451            aria-controls=(panel)
452            aria-selected=(selected)
453            tabindex=(tabindex)
454            data-tab-error=(errored)
455            class=(TAB_TRIGGER)
456        >
457            (label)
458        </button>
459    }
460    .boxed();
461    let panel = view! {
462        cx =>
463        <div
464            role="tabpanel"
465            id=(panel_id)
466            aria-labelledby=(labelled_by)
467            tabindex="0"
468            hidden=(!active)
469            data-tab-panel=""
470            class="flex flex-col gap-6 outline-none"
471        >
472            (body)
473        </div>
474    }
475    .boxed();
476    TabView { trigger, panel }
477}
478
479/// The block around its rendered `tabs`: the triggers in a list over the panels.
480pub(crate) fn tabs_chrome<'a>(cx: &'a Cx, tabs: Vec<TabView<'a>>) -> BoxView<'a> {
481    let (triggers, panels): (Vec<_>, Vec<_>) =
482        tabs.into_iter().map(|tab| (tab.trigger, tab.panel)).unzip();
483    view! {
484        cx =>
485        <div data-tabs="" class="flex flex-col gap-4">
486            <div role="tablist" class=(TAB_LIST)>
487                for trigger in triggers {
488                    (trigger)
489                }
490            </div>
491            for panel in panels {
492                (panel)
493            }
494        </div>
495    }
496    .boxed()
497}
498
499impl Tabs<Schema> {
500    pub(crate) async fn render<'a>(
501        &self,
502        cx: &'a Cx,
503        fields: &[Field],
504        source: &Source<'_>,
505    ) -> Result<BoxView<'a>> {
506        let tabs: Vec<(&Node, &Tab)> = self
507            .children
508            .nodes
509            .iter()
510            .filter_map(|node| match node {
511                Node::Tab(tab) if node.shows_any(fields, source) => Some((node, &**tab)),
512                _ => None,
513            })
514            .collect();
515        let errored: Vec<bool> = tabs
516            .iter()
517            .map(|(node, _)| node.has_error(fields, source))
518            .collect();
519        // The first tab holding an error, else the first the page shows.
520        let active = errored.iter().position(|e| *e).unwrap_or_else(|| {
521            tabs.iter()
522                .position(|(_, tab)| source.holds(tab.condition.as_ref()))
523                .unwrap_or(0)
524        });
525        let depth = source.tab_depth();
526        let mut views = Vec::with_capacity(tabs.len());
527        for (index, (node, tab)) in tabs.iter().enumerate() {
528            let id = node
529                .first_field()
530                .map(|field| tab_id(&source.id(fields[field].name()), depth))
531                .unwrap_or_default();
532            source.set_tab_depth(depth + 1);
533            let body = render_nodes(cx, &tab.children.nodes, fields, source).await;
534            source.set_tab_depth(depth);
535            let body = body?;
536            let view = tab_view(cx, &id, tab.label(), index == active, errored[index], body);
537            views.push(TabView {
538                trigger: source.guard(cx, tab.condition.as_ref(), view.trigger),
539                panel: source.guard(cx, tab.condition.as_ref(), view.panel),
540            });
541        }
542        Ok(tabs_chrome(cx, views))
543    }
544}