tablo_core/schema/layouts/
section.rs1use 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
22const 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
28const 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
36const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Collapse {
46 Never,
48 Open,
50 Closed,
52}
53
54#[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#[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 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 pub fn class(mut self, class: impl Into<String>) -> Self {
123 self.look.extra_class = Some(class.into());
124 self
125 }
126
127 pub fn description(mut self, description: impl Into<crate::Text>) -> Self {
129 self.look.description = Some(description.into());
130 self
131 }
132
133 pub fn icon(mut self, data: IconData) -> Self {
135 self.look.icon = Some(data);
136 self
137 }
138
139 pub fn collapsible(mut self) -> Self {
141 self.look.collapse = Collapse::Open;
142 self
143 }
144
145 pub fn collapsed(mut self) -> Self {
147 self.look.collapse = Collapse::Closed;
148 self
149 }
150
151 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 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
177fn 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
192fn 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
270fn 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
291pub(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
305fn 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}