1use 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
18const 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#[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 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 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 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
76fn 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#[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 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 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#[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 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 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 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
267macro_rules! conditional_blocks {
269 ($($ty:ident),+ $(,)?) => {
270 $(
271 impl<F> $ty<Schema<F>> {
272 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
290const 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);
295const 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#[derive(Debug)]
326pub struct Tabs<C = Schema> {
327 pub(crate) children: C,
329 pub(crate) condition: Option<Condition>,
330}
331
332impl<C: HoldsTabs> Tabs<C> {
333 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#[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 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 pub(crate) fn label(&self) -> &crate::Text {
393 &self.label
394 }
395}
396
397#[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
415pub(crate) fn tab_id(id: &str, depth: usize) -> String {
418 format!("{id}-{depth}")
419}
420
421pub(crate) struct TabView<'a> {
424 pub(crate) trigger: BoxView<'a>,
425 pub(crate) panel: BoxView<'a>,
426}
427
428pub(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
479pub(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 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}