1use 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#[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 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 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#[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 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 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 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 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
196macro_rules! conditional_blocks {
198 ($($ty:ident),+ $(,)?) => {
199 $(
200 impl<F> $ty<Schema<F>> {
201 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
219macro_rules! spanning_blocks {
221 ($($ty:ident),+ $(,)?) => {
222 $(
223 impl<C> $ty<C> {
224 pub fn span(mut self, columns: u8) -> Self {
228 self.span = Some(Span::columns(columns));
229 self
230 }
231
232 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
244const 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);
249const 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#[derive(Debug)]
280pub struct Tabs<C = Schema> {
281 pub(crate) children: C,
283 pub(crate) condition: Option<Condition>,
284 span: Option<Span>,
285}
286
287impl<C: HoldsTabs> Tabs<C> {
288 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 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#[derive(Debug)]
332pub struct Tab<C = Schema> {
333 face: TabFace,
334 pub(crate) children: C,
335 pub(crate) condition: Option<Condition>,
336}
337
338#[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 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 pub fn icon(mut self, data: IconData) -> Self {
382 self.face.icon = Some(data);
383 self
384 }
385
386 pub fn badge(mut self, text: impl Into<crate::Text>) -> Self {
388 self.face.badge = Some(text.into());
389 self
390 }
391
392 pub fn badge_color(mut self, color: Color) -> Self {
394 self.face.badge_color = Some(color);
395 self
396 }
397
398 pub(crate) fn face(&self) -> &TabFace {
400 &self.face
401 }
402}
403
404#[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
422pub(crate) fn tab_id(id: &str, depth: usize) -> String {
425 format!("{id}-{depth}")
426}
427
428pub(crate) struct TabView<'a> {
431 pub(crate) trigger: BoxView<'a>,
432 pub(crate) panel: BoxView<'a>,
433}
434
435pub(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
503fn 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 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}