Skip to main content

tablo_core/schema/
layouts.rs

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