Skip to main content

tablo_core/schema/layouts/
section.rs

1//! The `Section` block: a titled panel, optionally described, iconed, collapsible or laid out
2//! aside its heading.
3
4use tablo_ui::{card_content, card_description, card_header, card_title};
5use topcoat::{
6    Result,
7    context::Cx,
8    icon::{IconData, icon},
9    view::{StaticClass, class, *},
10};
11
12use super::{
13    super::{
14        Schema,
15        condition::Condition,
16        fields::Field,
17        tree::{IntoSchema, Source, render_nodes},
18    },
19    Span, spanned,
20};
21
22/// Renders a `Section`'s titled panel: a title row, ruled off from the body under it.
23const PANEL: StaticClass = class!(
24    "flex flex-col rounded-xl border border-border bg-card text-card-foreground shadow-xs",
25    "[&>[data-section-title]:not(:last-child)]:border-b [&>[data-section-title]]:border-border",
26);
27
28/// Renders a collapsible section's title row: the whole row toggles the body, which an open
29/// section rules off under it.
30const SUMMARY: StaticClass = class!(
31    "flex cursor-pointer list-none items-center gap-2 px-6 py-4 outline-none",
32    "focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden",
33    "[[open]>&]:border-b [[open]>&]:border-border",
34);
35
36/// Renders a section with no panel of its own, a rule above it when another block precedes it.
37/// Alone in its form the title goes: the dialog around the form already names the record.
38const FLAT: StaticClass = class!(
39    "flex flex-col gap-4 [&:not(:first-of-type)]:border-t [&:not(:first-of-type)]:border-border",
40    "[&:not(:first-of-type)]:pt-6 [&:only-of-type>[data-section-title]]:hidden",
41);
42
43/// Whether a section folds its body away.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Collapse {
46    /// The body always shows.
47    Never,
48    /// The title toggles the body, which the page opens on.
49    Open,
50    /// The title toggles the body, which the page opens folded unless it holds a field error.
51    Closed,
52}
53
54/// What a section shows around its body: everything but its children.
55#[derive(Debug)]
56struct Look {
57    title: crate::Text,
58    description: Option<crate::Text>,
59    icon: Option<IconData>,
60    collapse: Collapse,
61    aside: bool,
62    extra_class: Option<String>,
63}
64
65/// A titled block holding a form's fields ([`schema`](Self::schema)) or a detail page's columns
66/// ([`columns`](Self::columns), which `detail` declares).
67///
68/// ```rust
69/// # #[derive(Debug, toasty::Model, tablo_core::ModelColumns)]
70/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String, bio: String }
71/// use tablo_core::{Field, Schema, Section};
72///
73/// let user = User::fields();
74/// Schema::new((
75///     Section::new("Account")
76///         .description("How the user signs in.")
77///         .aside()
78///         .schema((Field::text(user.name()), Field::text(user.email()))),
79///     Section::new("About")
80///         .collapsed()
81///         .schema(Field::text(user.bio()).multiline(4)),
82/// ));
83/// ```
84///
85/// A collapsible section renders a `<details>` element, so it opens and folds without a script.
86/// Its fields render and post while it is folded; a form section holding a field error renders
87/// open, and the browser opens a folded section holding a control it refuses on submit. An
88/// [`aside`](Self::aside) section does not collapse.
89#[derive(Debug)]
90pub struct Section<C = Schema> {
91    look: Look,
92    pub(crate) children: C,
93    pub(crate) condition: Option<Condition>,
94    pub(super) span: Option<Span>,
95}
96
97impl Section<()> {
98    pub fn new(title: impl Into<crate::Text>) -> Self {
99        Self {
100            look: Look {
101                title: title.into(),
102                description: None,
103                icon: None,
104                collapse: Collapse::Never,
105                aside: false,
106                extra_class: None,
107            },
108            children: (),
109            condition: None,
110            span: None,
111        }
112    }
113
114    /// Holds a form's fields and blocks.
115    pub fn schema<F>(self, children: impl IntoSchema<F>) -> Section<Schema<F>> {
116        self.holding(children.into_schema())
117    }
118}
119
120impl<C> Section<C> {
121    /// Adds an additive `class` hook on the panel container.
122    pub fn class(mut self, class: impl Into<String>) -> Self {
123        self.look.extra_class = Some(class.into());
124        self
125    }
126
127    /// Shows `description` under the title.
128    pub fn description(mut self, description: impl Into<crate::Text>) -> Self {
129        self.look.description = Some(description.into());
130        self
131    }
132
133    /// Shows `icon` before the title.
134    pub fn icon(mut self, data: IconData) -> Self {
135        self.look.icon = Some(data);
136        self
137    }
138
139    /// Lets the title fold the body away; the page opens on it shown.
140    pub fn collapsible(mut self) -> Self {
141        self.look.collapse = Collapse::Open;
142        self
143    }
144
145    /// Lets the title fold the body away, and opens the page on it folded.
146    pub fn collapsed(mut self) -> Self {
147        self.look.collapse = Collapse::Closed;
148        self
149    }
150
151    /// Lays the title and description out beside the body rather than over it, on a screen wide
152    /// enough for both.
153    pub fn aside(mut self) -> Self {
154        self.look.aside = true;
155        self
156    }
157
158    pub(crate) fn holding<D>(self, children: D) -> Section<D> {
159        self.map(|_| children)
160    }
161
162    pub(crate) fn map<D>(self, f: impl FnOnce(C) -> D) -> Section<D> {
163        Section {
164            look: self.look,
165            children: f(self.children),
166            condition: self.condition,
167            span: self.span,
168        }
169    }
170
171    /// The titled panel around `body`, or the title alone when the section holds nothing.
172    pub(crate) fn chrome<'a>(&self, cx: &'a Cx, body: Option<BoxView<'a>>) -> BoxView<'a> {
173        section_chrome(cx, &self.look, self.span, body, false)
174    }
175}
176
177// The chrome is a free function, not a method of the generic block, so tablo-core compiles its
178// view once rather than once for every detail model an app declares.
179
180/// The section `look` describes around `body`; `errored` opens a folded section holding a field
181/// error.
182fn section_chrome<'a>(
183    cx: &'a Cx,
184    look: &Look,
185    span: Option<Span>,
186    body: Option<BoxView<'a>>,
187    errored: bool,
188) -> BoxView<'a> {
189    spanned(cx, span, panel(cx, look, body, errored))
190}
191
192/// The section's panel, as `section_chrome` spans it.
193fn panel<'a>(cx: &'a Cx, look: &Look, body: Option<BoxView<'a>>, errored: bool) -> BoxView<'a> {
194    let extra = look.extra_class.clone();
195    let heading = heading(cx, look);
196    let description = look.description.clone();
197    match body {
198        Some(body) if look.aside => view! {
199            <div
200                class=(class!("grid gap-x-8 gap-y-4 md:grid-cols-3", extra.clone()))
201                data-section=""
202            >
203                <div class="flex flex-col gap-1.5" data-section-title="">
204                    (heading)
205                    if let Some(description) = description {
206                        card_description((description))
207                    }
208                </div>
209                <div class=(class!(PANEL, "md:col-span-2"))>
210                    card_content(
211                        attrs: attributes! { class="flex flex-col gap-6 py-6" },
212                        (body)
213                    )
214                </div>
215            </div>
216        }
217        .boxed(),
218        Some(body) if look.collapse != Collapse::Never => {
219            let open = look.collapse == Collapse::Open || errored;
220            view! {
221                <details
222                    class=(class!(PANEL, extra.clone()))
223                    data-section=""
224                    open=(open)
225                >
226                    <summary class=(SUMMARY) data-section-title="">
227                        <div class="flex min-w-0 flex-1 flex-col gap-1.5">
228                            (heading)
229                            if let Some(description) = description {
230                                card_description((description))
231                            }
232                        </div>
233                        icon(
234                            data: tablo_ui::icons::CHEVRON_DOWN,
235                            attrs: attributes! {
236                                class="shrink-0 text-muted-foreground transition-transform \
237                                       [[open]>summary>&]:rotate-180"
238                            }
239                        )
240                    </summary>
241                    card_content(
242                        attrs: attributes! { class="flex flex-col gap-6 py-6" },
243                        (body)
244                    )
245                </details>
246            }
247            .boxed()
248        }
249        body => view! {
250            <div class=(class!(PANEL, extra.clone())) data-section="">
251                card_header(
252                    attrs: attributes! { class="py-4" data-section-title="" },
253                    (heading)
254                    if let Some(description) = description {
255                        card_description((description))
256                    }
257                )
258                if let Some(body) = body {
259                    card_content(
260                        attrs: attributes! { class="flex flex-col gap-6 py-6" },
261                        (body)
262                    )
263                }
264            </div>
265        }
266        .boxed(),
267    }
268}
269
270/// The section `look` describes as a heading over `body`, for a form a dialog frames. Nothing folds
271/// here: a dialog's form is short, and a section always shows what it asks for.
272fn flat<'a>(cx: &'a Cx, look: &Look, span: Option<Span>, body: Option<BoxView<'a>>) -> BoxView<'a> {
273    let extra = look.extra_class.clone();
274    let heading = heading(cx, look);
275    let description = look.description.clone();
276    let view = view! {
277        <div class=(class!(FLAT, extra)) data-section="">
278            <div data-section-title="">(heading)</div>
279            if let Some(description) = description {
280                card_description((description))
281            }
282            if let Some(body) = body {
283                <div class="flex flex-col gap-6">(body)</div>
284            }
285        </div>
286    }
287    .boxed();
288    spanned(cx, span, view)
289}
290
291/// `body` under `title`, as a section declaring nothing else renders it: a related form's inside
292/// its owner's form.
293pub(crate) fn titled<'a>(cx: &'a Cx, title: crate::Text, body: BoxView<'a>) -> BoxView<'a> {
294    let look = Look {
295        title,
296        description: None,
297        icon: None,
298        collapse: Collapse::Never,
299        aside: false,
300        extra_class: None,
301    };
302    section_chrome(cx, &look, None, Some(body), false)
303}
304
305/// The section's title, after its icon.
306fn heading<'a>(cx: &'a Cx, look: &Look) -> BoxView<'a> {
307    let title = look.title.clone();
308    match look.icon.clone() {
309        Some(data) => view! {
310            <div class="flex items-center gap-2">
311                icon(
312                    data: data,
313                    attrs: attributes! { class="shrink-0 text-muted-foreground" }
314                )
315                card_title((title))
316            </div>
317        }
318        .boxed(),
319        None => view! { card_title((title)) }.boxed(),
320    }
321}
322
323impl Section<Schema> {
324    pub(crate) async fn render<'a>(
325        &self,
326        cx: &'a Cx,
327        fields: &[Field],
328        source: &Source<'_>,
329    ) -> Result<BoxView<'a>> {
330        if self.children.nodes.is_empty() {
331            return Ok(if source.is_flat() {
332                flat(cx, &self.look, self.span, None)
333            } else {
334                section_chrome(cx, &self.look, self.span, None, false)
335            });
336        }
337        let errored = self
338            .children
339            .nodes
340            .iter()
341            .any(|node| node.has_error(fields, source));
342        let body = render_nodes(cx, &self.children.nodes, fields, source).await?;
343        if source.is_flat() {
344            return Ok(flat(cx, &self.look, self.span, Some(body)));
345        }
346        Ok(section_chrome(
347            cx,
348            &self.look,
349            self.span,
350            Some(body),
351            errored,
352        ))
353    }
354}