use tablo_ui::{
card_content, card_header, card_title, field_error as ui_field_error,
field_group as ui_field_group,
};
use topcoat::{
Result,
context::Cx,
view::{StaticClass, class, *},
};
use super::{
Schema,
fields::Field,
tree::{IntoSchema, Mode, Source, render_nodes},
};
const PANEL: StaticClass = class!(
"flex flex-col gap-5 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-sm"
);
#[derive(Debug)]
pub struct Section {
title: String,
pub(crate) children: Schema,
extra_class: Option<String>,
}
impl Section {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
children: Schema::empty(),
extra_class: None,
}
}
pub fn schema(mut self, children: impl IntoSchema) -> Self {
self.children = children.into_schema();
self
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.extra_class = Some(class.into());
self
}
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
fields: &[Field],
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let title = self.title.clone();
let extra = self.extra_class.clone();
if !self.children.nodes.is_empty() {
let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
Ok(view! {
cx =>
<div class=(class!(PANEL, extra.clone()))>
card_header(card_title((title)))
card_content(
attrs: attributes! { class="flex flex-col gap-6" },
(child_view)
)
</div>
}
.boxed())
} else {
Ok(view! {
cx =>
<div class=(class!(PANEL, extra.clone()))>
card_header(card_title((title)))
</div>
}
.boxed())
}
}
}
#[derive(Debug, Default)]
pub struct Group {
pub(crate) children: Schema,
}
impl Group {
pub fn new() -> Self {
Self::default()
}
pub fn schema(mut self, children: impl IntoSchema) -> Self {
self.children = children.into_schema();
self
}
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
fields: &[Field],
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
Ok(view! { cx => ui_field_group((child_view)) }.boxed())
}
}
#[derive(Debug)]
pub struct Grid {
cols: u8,
pub(crate) children: Schema,
}
impl Grid {
pub fn new(cols: u8) -> Self {
Self {
cols: cols.clamp(1, 12),
children: Schema::empty(),
}
}
pub fn schema(mut self, children: impl IntoSchema) -> Self {
self.children = children.into_schema();
self
}
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
fields: &[Field],
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let class: &'static str = match self.cols {
1 => "grid grid-cols-1 gap-4",
2 => "grid grid-cols-2 gap-4",
3 => "grid grid-cols-3 gap-4",
4 => "grid grid-cols-4 gap-4",
5 => "grid grid-cols-5 gap-4",
6 => "grid grid-cols-6 gap-4",
7 => "grid grid-cols-7 gap-4",
8 => "grid grid-cols-8 gap-4",
9 => "grid grid-cols-9 gap-4",
10 => "grid grid-cols-10 gap-4",
11 => "grid grid-cols-11 gap-4",
_ => "grid grid-cols-12 gap-4",
};
let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
Ok(view! { cx => <div class=(class)>(child_view)</div> }.boxed())
}
}
#[derive(Debug)]
pub struct Repeater {
pub(crate) label: String,
pub(crate) children: Schema,
pub(crate) required: bool,
}
impl Repeater {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
children: Schema::empty(),
required: false,
}
}
pub fn schema(mut self, children: impl IntoSchema) -> Self {
self.children = children.into_schema();
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn label(&self) -> &str {
&self.label
}
pub(crate) async fn render<'a>(
&self,
cx: &'a Cx,
fields: &[Field],
source: &Source<'_>,
) -> Result<BoxView<'a>> {
let title = self.label.clone();
let title_id = repeater_title_id(&self.label);
let has_children = !self.children.nodes.is_empty();
if source.mode() == Mode::View {
let child_view = if has_children {
Some(render_nodes(cx, &self.children.nodes, fields, source).await?)
} else {
None
};
return Ok(view! {
cx =>
<div
class=(class!(PANEL, "ac-field"))
role="group"
aria-labelledby=(title_id.clone())
>
card_header(
card_title(
attrs: attributes! { id=(title_id.clone()) },
(title)
)
)
if let Some(child_view) = child_view {
card_content(
attrs: attributes! { class="flex flex-col gap-6" },
<div class="grid gap-4">(child_view)</div>
)
}
</div>
}
.boxed());
}
let required = self.required;
let own_error = source.errors_for(&self.label);
let has_error = own_error.is_some();
let error_text = own_error.unwrap_or_default().to_string();
let error_id = repeater_error_id(&self.label);
let container_class = if has_error {
"ac-field ac-field--error"
} else {
"ac-field"
};
let title_class = if has_error { "text-destructive" } else { "" };
if has_children {
let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
Ok(view! {
cx =>
<div
class=(class!(PANEL, container_class))
role="group"
aria-labelledby=(title_id.clone())
data-invalid=(has_error.then_some("true"))
aria-invalid=(if has_error { "true" } else { "false" })
aria-describedby=(has_error.then_some(error_id.clone()))
>
card_header(
card_title(
attrs: attributes! { id=(title_id.clone()) class=(title_class) },
(title)
if required {
<span class="text-destructive" aria-hidden="true">
"*"
</span>
}
)
)
card_content(
attrs: attributes! { class="flex flex-col gap-6" },
<div class="grid gap-4">(child_view)</div>
if has_error {
ui_field_error(
attrs: attributes! {
id=(error_id.clone())
class="ac-error"
aria-live="polite"
},
(error_text)
)
}
)
</div>
}
.boxed())
} else {
Ok(view! {
cx =>
<div
class=(class!(PANEL, container_class))
role="group"
aria-labelledby=(title_id.clone())
data-invalid=(has_error.then_some("true"))
aria-invalid=(if has_error { "true" } else { "false" })
aria-describedby=(has_error.then_some(error_id.clone()))
>
card_header(
card_title(
attrs: attributes! { id=(title_id.clone()) class=(title_class) },
(title)
if required {
<span class="text-destructive" aria-hidden="true">
"*"
</span>
}
)
)
if has_error {
card_content(
attrs: attributes! { class="flex flex-col gap-6" },
ui_field_error(
attrs: attributes! {
id=(error_id.clone())
class="ac-error"
aria-live="polite"
},
(error_text)
)
)
}
</div>
}
.boxed())
}
}
}
/// Returns the DOM id of a repeater's error node, slugging the label.
fn repeater_error_id(label: &str) -> String {
format!("{}-error", repeater_slug(label))
}
/// Returns the DOM id of a repeater's title node.
fn repeater_title_id(label: &str) -> String {
format!("{}-title", repeater_slug(label))
}
fn repeater_slug(label: &str) -> String {
let mut slug = String::with_capacity(label.len());
for character in label.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
} else if !slug.ends_with('-') {
slug.push('-');
}
}
slug.trim_matches('-').to_string()
}
#[cfg(test)]
mod tests;