1use tablo_ui::{
4 card_content, card_header, card_title, field_error as ui_field_error,
5 field_group as ui_field_group,
6};
7use topcoat::{
8 Result,
9 context::Cx,
10 view::{StaticClass, class, *},
11};
12
13use super::{
14 Schema,
15 fields::Field,
16 tree::{IntoSchema, Mode, Source, render_nodes},
17};
18
19const PANEL: StaticClass = class!(
21 "flex flex-col gap-5 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-sm"
22);
23
24#[derive(Debug)]
26pub struct Section {
27 title: String,
28 pub(crate) children: Schema,
29 extra_class: Option<String>,
30}
31
32impl Section {
33 pub fn new(title: impl Into<String>) -> Self {
34 Self {
35 title: title.into(),
36 children: Schema::empty(),
37 extra_class: None,
38 }
39 }
40
41 pub fn schema(mut self, children: impl IntoSchema) -> Self {
42 self.children = children.into_schema();
43 self
44 }
45
46 pub fn class(mut self, class: impl Into<String>) -> Self {
48 self.extra_class = Some(class.into());
49 self
50 }
51
52 pub(crate) async fn render<'a>(
53 &self,
54 cx: &'a Cx,
55 fields: &[Field],
56 source: &Source<'_>,
57 ) -> Result<BoxView<'a>> {
58 let title = self.title.clone();
59 let extra = self.extra_class.clone();
60 if !self.children.nodes.is_empty() {
61 let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
62 Ok(view! {
63 cx =>
64 <div class=(class!(PANEL, extra.clone()))>
65 card_header(card_title((title)))
66 card_content(
67 attrs: attributes! { class="flex flex-col gap-6" },
68 (child_view)
69 )
70 </div>
71 }
72 .boxed())
73 } else {
74 Ok(view! {
75 cx =>
76 <div class=(class!(PANEL, extra.clone()))>
77 card_header(card_title((title)))
78 </div>
79 }
80 .boxed())
81 }
82 }
83}
84
85#[derive(Debug, Default)]
87pub struct Group {
88 pub(crate) children: Schema,
89}
90
91impl Group {
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn schema(mut self, children: impl IntoSchema) -> Self {
97 self.children = children.into_schema();
98 self
99 }
100
101 pub(crate) async fn render<'a>(
102 &self,
103 cx: &'a Cx,
104 fields: &[Field],
105 source: &Source<'_>,
106 ) -> Result<BoxView<'a>> {
107 let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
108 Ok(view! { cx => ui_field_group((child_view)) }.boxed())
109 }
110}
111
112#[derive(Debug)]
114pub struct Grid {
115 cols: u8,
116 pub(crate) children: Schema,
117}
118
119impl Grid {
120 pub fn new(cols: u8) -> Self {
121 Self {
122 cols: cols.clamp(1, 12),
123 children: Schema::empty(),
124 }
125 }
126
127 pub fn schema(mut self, children: impl IntoSchema) -> Self {
128 self.children = children.into_schema();
129 self
130 }
131
132 pub(crate) async fn render<'a>(
133 &self,
134 cx: &'a Cx,
135 fields: &[Field],
136 source: &Source<'_>,
137 ) -> Result<BoxView<'a>> {
138 let class: &'static str = match self.cols {
141 1 => "grid grid-cols-1 gap-4",
142 2 => "grid grid-cols-2 gap-4",
143 3 => "grid grid-cols-3 gap-4",
144 4 => "grid grid-cols-4 gap-4",
145 5 => "grid grid-cols-5 gap-4",
146 6 => "grid grid-cols-6 gap-4",
147 7 => "grid grid-cols-7 gap-4",
148 8 => "grid grid-cols-8 gap-4",
149 9 => "grid grid-cols-9 gap-4",
150 10 => "grid grid-cols-10 gap-4",
151 11 => "grid grid-cols-11 gap-4",
152 _ => "grid grid-cols-12 gap-4",
153 };
154 let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
155 Ok(view! { cx => <div class=(class)>(child_view)</div> }.boxed())
156 }
157}
158
159#[derive(Debug)]
162pub struct Repeater {
163 pub(crate) label: String,
164 pub(crate) children: Schema,
165 pub(crate) required: bool,
166}
167
168impl Repeater {
169 pub fn new(label: impl Into<String>) -> Self {
170 Self {
171 label: label.into(),
172 children: Schema::empty(),
173 required: false,
174 }
175 }
176
177 pub fn schema(mut self, children: impl IntoSchema) -> Self {
178 self.children = children.into_schema();
179 self
180 }
181
182 pub fn required(mut self) -> Self {
183 self.required = true;
184 self
185 }
186
187 pub fn label(&self) -> &str {
188 &self.label
189 }
190
191 pub(crate) async fn render<'a>(
192 &self,
193 cx: &'a Cx,
194 fields: &[Field],
195 source: &Source<'_>,
196 ) -> Result<BoxView<'a>> {
197 let title = self.label.clone();
198 let title_id = repeater_title_id(&self.label);
199 let has_children = !self.children.nodes.is_empty();
200 if source.mode() == Mode::View {
201 let child_view = if has_children {
202 Some(render_nodes(cx, &self.children.nodes, fields, source).await?)
203 } else {
204 None
205 };
206 return Ok(view! {
207 cx =>
208 <div
209 class=(class!(PANEL, "ac-field"))
210 role="group"
211 aria-labelledby=(title_id.clone())
212 >
213 card_header(
214 card_title(
215 attrs: attributes! { id=(title_id.clone()) },
216 (title)
217 )
218 )
219 if let Some(child_view) = child_view {
220 card_content(
221 attrs: attributes! { class="flex flex-col gap-6" },
222 <div class="grid gap-4">(child_view)</div>
223 )
224 }
225 </div>
226 }
227 .boxed());
228 }
229 let required = self.required;
230 let own_error = source.errors_for(&self.label);
231 let has_error = own_error.is_some();
232 let error_text = own_error.unwrap_or_default().to_string();
233 let error_id = repeater_error_id(&self.label);
234 let container_class = if has_error {
235 "ac-field ac-field--error"
236 } else {
237 "ac-field"
238 };
239 let title_class = if has_error { "text-destructive" } else { "" };
240 if has_children {
241 let child_view = render_nodes(cx, &self.children.nodes, fields, source).await?;
242 Ok(view! {
243 cx =>
244 <div
245 class=(class!(PANEL, container_class))
246 role="group"
247 aria-labelledby=(title_id.clone())
248 data-invalid=(has_error.then_some("true"))
249 aria-invalid=(if has_error { "true" } else { "false" })
250 aria-describedby=(has_error.then_some(error_id.clone()))
251 >
252 card_header(
253 card_title(
254 attrs: attributes! { id=(title_id.clone()) class=(title_class) },
255 (title)
256 if required {
257 <span class="text-destructive" aria-hidden="true">
258 "*"
259 </span>
260 }
261 )
262 )
263 card_content(
264 attrs: attributes! { class="flex flex-col gap-6" },
265 <div class="grid gap-4">(child_view)</div>
266 if has_error {
267 ui_field_error(
268 attrs: attributes! {
269 id=(error_id.clone())
270 class="ac-error"
271 aria-live="polite"
272 },
273 (error_text)
274 )
275 }
276 )
277 </div>
278 }
279 .boxed())
280 } else {
281 Ok(view! {
282 cx =>
283 <div
284 class=(class!(PANEL, container_class))
285 role="group"
286 aria-labelledby=(title_id.clone())
287 data-invalid=(has_error.then_some("true"))
288 aria-invalid=(if has_error { "true" } else { "false" })
289 aria-describedby=(has_error.then_some(error_id.clone()))
290 >
291 card_header(
292 card_title(
293 attrs: attributes! { id=(title_id.clone()) class=(title_class) },
294 (title)
295 if required {
296 <span class="text-destructive" aria-hidden="true">
297 "*"
298 </span>
299 }
300 )
301 )
302 if has_error {
303 card_content(
304 attrs: attributes! { class="flex flex-col gap-6" },
305 ui_field_error(
306 attrs: attributes! {
307 id=(error_id.clone())
308 class="ac-error"
309 aria-live="polite"
310 },
311 (error_text)
312 )
313 )
314 }
315 </div>
316 }
317 .boxed())
318 }
319 }
320}
321
322fn repeater_error_id(label: &str) -> String {
324 format!("{}-error", repeater_slug(label))
325}
326
327fn repeater_title_id(label: &str) -> String {
329 format!("{}-title", repeater_slug(label))
330}
331
332fn repeater_slug(label: &str) -> String {
333 let mut slug = String::with_capacity(label.len());
334 for character in label.chars() {
335 if character.is_ascii_alphanumeric() {
336 slug.push(character.to_ascii_lowercase());
337 } else if !slug.ends_with('-') {
338 slug.push('-');
339 }
340 }
341 slug.trim_matches('-').to_string()
342}
343
344#[cfg(test)]
345mod tests;