1use std::collections::HashSet;
9
10use serde_json::Value;
11
12use crate::action::HttpMethod;
13use crate::component::{
14 ActionCardProps, ActionCardVariant, AlertProps, AlertVariant, AvatarProps, BadgeProps,
15 BadgeVariant, BreadcrumbProps, ButtonGroupProps, ButtonProps, ButtonType, ButtonVariant,
16 CalendarCellProps, CardProps, CheckboxProps, ChecklistProps, CollapsibleProps, Component,
17 ComponentNode, DataTableProps, DescriptionListProps, DropdownMenuAction, DropdownMenuProps,
18 EmptyStateProps, FormMaxWidth, FormProps, FormSectionLayout, FormSectionProps, GapSize,
19 GridProps, HeaderProps, IconPosition, ImageProps, InputProps, InputType, KanbanBoardProps,
20 ModalProps, NotificationDropdownProps, Orientation, PageHeaderProps, PaginationProps,
21 PluginProps, ProductTileProps, ProgressProps, SelectProps, SeparatorProps, SidebarProps, Size,
22 SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextElement, TextProps,
23 ToastProps, ToastVariant,
24};
25use crate::data::{resolve_path, resolve_path_string};
26use crate::plugin::{collect_plugin_assets, Asset};
27use crate::view::JsonUiView;
28
29pub fn render_to_html(view: &JsonUiView, data: &Value) -> String {
38 let mut html = String::from(
39 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
40 );
41 for node in &view.components {
42 html.push_str(&render_node(node, data));
43 }
44 html.push_str("</div>");
45 html
46}
47
48pub struct RenderResult {
53 pub html: String,
55 pub css_head: String,
57 pub scripts: String,
59}
60
61pub fn render_to_html_with_plugins(view: &JsonUiView, data: &Value) -> RenderResult {
67 let html = render_to_html(view, data);
68
69 let plugin_types = collect_plugin_types(view);
70 if plugin_types.is_empty() {
71 return RenderResult {
72 html,
73 css_head: String::new(),
74 scripts: String::new(),
75 };
76 }
77
78 let type_names: Vec<String> = plugin_types.into_iter().collect();
79 let assets = collect_plugin_assets(&type_names);
80
81 let css_head = render_css_tags(&assets.css);
82 let scripts = render_js_tags(&assets.js, &assets.init_scripts);
83
84 RenderResult {
85 html,
86 css_head,
87 scripts,
88 }
89}
90
91pub(crate) fn collect_plugin_types(view: &JsonUiView) -> HashSet<String> {
93 let mut types = HashSet::new();
94 for node in &view.components {
95 collect_plugin_types_node(node, &mut types);
96 }
97 types
98}
99
100fn collect_plugin_types_node(node: &ComponentNode, types: &mut HashSet<String>) {
102 match &node.component {
103 Component::Plugin(props) => {
104 types.insert(props.plugin_type.clone());
105 }
106 Component::Card(props) => {
107 for child in &props.children {
108 collect_plugin_types_node(child, types);
109 }
110 for child in &props.footer {
111 collect_plugin_types_node(child, types);
112 }
113 }
114 Component::Form(props) => {
115 for field in &props.fields {
116 collect_plugin_types_node(field, types);
117 }
118 }
119 Component::Modal(props) => {
120 for child in &props.children {
121 collect_plugin_types_node(child, types);
122 }
123 for child in &props.footer {
124 collect_plugin_types_node(child, types);
125 }
126 }
127 Component::Tabs(props) => {
128 for tab in &props.tabs {
129 for child in &tab.children {
130 collect_plugin_types_node(child, types);
131 }
132 }
133 }
134 Component::Grid(props) => {
135 for child in &props.children {
136 collect_plugin_types_node(child, types);
137 }
138 }
139 Component::Collapsible(props) => {
140 for child in &props.children {
141 collect_plugin_types_node(child, types);
142 }
143 }
144 Component::FormSection(props) => {
145 for child in &props.children {
146 collect_plugin_types_node(child, types);
147 }
148 }
149 Component::PageHeader(props) => {
150 for child in &props.actions {
151 collect_plugin_types_node(child, types);
152 }
153 }
154 Component::ButtonGroup(props) => {
155 for child in &props.buttons {
156 collect_plugin_types_node(child, types);
157 }
158 }
159 Component::Table(_)
161 | Component::Button(_)
162 | Component::Input(_)
163 | Component::Select(_)
164 | Component::Alert(_)
165 | Component::Badge(_)
166 | Component::Text(_)
167 | Component::Checkbox(_)
168 | Component::Switch(_)
169 | Component::Separator(_)
170 | Component::DescriptionList(_)
171 | Component::Breadcrumb(_)
172 | Component::Pagination(_)
173 | Component::Progress(_)
174 | Component::Avatar(_)
175 | Component::Skeleton(_)
176 | Component::StatCard(_)
177 | Component::Checklist(_)
178 | Component::Toast(_)
179 | Component::NotificationDropdown(_)
180 | Component::Sidebar(_)
181 | Component::Header(_)
182 | Component::EmptyState(_)
183 | Component::DropdownMenu(_)
184 | Component::CalendarCell(_)
185 | Component::ActionCard(_)
186 | Component::ProductTile(_)
187 | Component::DataTable(_)
188 | Component::Image(_) => {}
189 Component::KanbanBoard(props) => {
190 for col in &props.columns {
191 for child in &col.children {
192 collect_plugin_types_node(child, types);
193 }
194 }
195 }
196 }
197}
198
199fn render_css_tags(assets: &[Asset]) -> String {
201 let mut out = String::new();
202 for asset in assets {
203 out.push_str("<link rel=\"stylesheet\" href=\"");
204 out.push_str(&html_escape(&asset.url));
205 out.push('"');
206 if let Some(ref integrity) = asset.integrity {
207 out.push_str(" integrity=\"");
208 out.push_str(&html_escape(integrity));
209 out.push('"');
210 }
211 if let Some(ref co) = asset.crossorigin {
212 out.push_str(" crossorigin=\"");
213 out.push_str(&html_escape(co));
214 out.push('"');
215 }
216 out.push('>');
217 }
218 out
219}
220
221fn render_js_tags(assets: &[Asset], init_scripts: &[String]) -> String {
223 let mut out = String::new();
224 for asset in assets {
225 out.push_str("<script src=\"");
226 out.push_str(&html_escape(&asset.url));
227 out.push('"');
228 if let Some(ref integrity) = asset.integrity {
229 out.push_str(" integrity=\"");
230 out.push_str(&html_escape(integrity));
231 out.push('"');
232 }
233 if let Some(ref co) = asset.crossorigin {
234 out.push_str(" crossorigin=\"");
235 out.push_str(&html_escape(co));
236 out.push('"');
237 }
238 out.push_str("></script>");
239 }
240 if !init_scripts.is_empty() {
241 out.push_str("<script>");
242 for script in init_scripts {
243 out.push_str(script);
244 }
245 out.push_str("</script>");
246 }
247 out
248}
249
250fn render_node(node: &ComponentNode, data: &Value) -> String {
252 let component_html = render_component(&node.component, data);
253
254 if let Some(ref action) = node.action {
256 if action.method == HttpMethod::Get {
257 if let Some(ref url) = action.url {
258 let target_attr = match action.target.as_deref() {
259 Some(t) => {
260 format!(" target=\"{}\" rel=\"noopener noreferrer\"", html_escape(t))
261 }
262 None => String::new(),
263 };
264 let style_attr = if matches!(node.component, Component::Image(_)) {
269 " style=\"width:100%\""
270 } else {
271 ""
272 };
273 return format!(
274 "<a href=\"{}\" class=\"block\"{}{}>{}</a>",
275 html_escape(url),
276 style_attr,
277 target_attr,
278 component_html
279 );
280 }
281 }
282 }
283
284 component_html
285}
286
287fn render_component(component: &Component, data: &Value) -> String {
289 match component {
290 Component::Text(props) => render_text(props),
291 Component::Button(props) => render_button(props),
292 Component::Badge(props) => render_badge(props),
293 Component::Alert(props) => render_alert(props),
294 Component::Separator(props) => render_separator(props),
295 Component::Progress(props) => render_progress(props),
296 Component::Avatar(props) => render_avatar(props),
297 Component::Skeleton(props) => render_skeleton(props),
298 Component::Breadcrumb(props) => render_breadcrumb(props),
299 Component::Pagination(props) => render_pagination(props),
300 Component::DescriptionList(props) => render_description_list(props),
301
302 Component::Card(props) => render_card(props, data),
304 Component::Form(props) => render_form(props, data),
305 Component::Modal(props) => render_modal(props, data),
306 Component::Tabs(props) => render_tabs(props, data),
307 Component::Table(props) => render_table(props, data),
308
309 Component::Input(props) => render_input(props, data),
311 Component::Select(props) => render_select(props, data),
312 Component::Checkbox(props) => render_checkbox(props, data),
313 Component::Switch(props) => render_switch(props, data),
314
315 Component::Grid(props) => render_grid(props, data),
317 Component::Collapsible(props) => render_collapsible(props, data),
318 Component::FormSection(props) => render_form_section(props, data),
319
320 Component::EmptyState(props) => render_empty_state(props),
322 Component::DropdownMenu(props) => render_dropdown_menu(props),
323
324 Component::StatCard(props) => render_stat_card(props),
326 Component::Checklist(props) => render_checklist(props),
327 Component::Toast(props) => render_toast(props),
328 Component::NotificationDropdown(props) => render_notification_dropdown(props),
329 Component::Sidebar(props) => render_sidebar(props),
330 Component::Header(props) => render_header(props),
331
332 Component::PageHeader(props) => render_page_header(props, data),
334 Component::ButtonGroup(props) => render_button_group(props, data),
335
336 Component::CalendarCell(props) => render_calendar_cell(props),
338 Component::ActionCard(props) => render_action_card(props),
339 Component::ProductTile(props) => render_product_tile(props),
340 Component::DataTable(props) => render_data_table(props, data),
341
342 Component::KanbanBoard(props) => render_kanban_board(props, data),
344
345 Component::Image(props) => render_image(props),
347
348 Component::Plugin(props) => render_plugin(props, data),
350 }
351}
352
353fn render_calendar_cell(props: &CalendarCellProps) -> String {
356 let opacity = if props.is_current_month {
357 ""
358 } else {
359 " opacity-40"
360 };
361 let hover = if props.is_current_month {
362 " hover:bg-surface/60 transition-colors cursor-pointer"
363 } else {
364 " cursor-pointer"
365 };
366
367 let mut html = format!(
368 "<div class=\"flex flex-col min-h-[5rem] p-2 border border-border -mt-px -ml-px{opacity}{hover}\">",
369 );
370
371 if props.is_today {
373 html.push_str(&format!(
374 "<span class=\"w-7 h-7 flex items-center justify-center text-sm font-semibold bg-primary text-primary-foreground rounded-full\">{}</span>",
375 props.day
376 ));
377 } else {
378 html.push_str(&format!(
379 "<span class=\"text-sm text-text\">{}</span>",
380 props.day
381 ));
382 }
383
384 let total = if !props.dot_colors.is_empty() {
388 props.dot_colors.len() as u32
389 } else {
390 props.event_count
391 };
392 if total > 0 && total <= 3 {
393 html.push_str("<div class=\"flex gap-1 mt-auto pt-1\">");
394 if props.dot_colors.is_empty() {
395 for _ in 0..total {
396 html.push_str("<span class=\"w-1.5 h-1.5 rounded-full bg-primary\"></span>");
397 }
398 } else {
399 for color in props.dot_colors.iter().take(3) {
400 html.push_str(&format!(
401 "<span class=\"w-1.5 h-1.5 rounded-full {}\"></span>",
402 html_escape(color)
403 ));
404 }
405 }
406 html.push_str("</div>");
407 } else if total > 3 {
408 html.push_str(&format!(
409 "<span class=\"mt-auto pt-1 text-xs font-medium text-primary\">{total} prenot.</span>"
410 ));
411 }
412
413 html.push_str("</div>");
414 html
415}
416
417fn render_action_card(props: &ActionCardProps) -> String {
420 let border_class = match props.variant {
421 ActionCardVariant::Default => "border-l-primary",
422 ActionCardVariant::Setup => "border-l-warning",
423 ActionCardVariant::Danger => "border-l-destructive",
424 };
425
426 let (open_tag, close_tag) = if let Some(ref href) = props.href {
428 (
429 format!(
430 "<a href=\"{}\" aria-label=\"{}\" class=\"rounded-lg border-l-4 {} border border-border bg-card shadow-sm p-4 flex items-center gap-4 hover:bg-surface transition-colors duration-150 no-underline\">",
431 html_escape(href),
432 html_escape(&props.title),
433 border_class,
434 ),
435 "</a>".to_string(),
436 )
437 } else {
438 (
439 format!(
440 "<div class=\"rounded-lg border-l-4 {border_class} border border-border bg-card shadow-sm p-4 flex items-center gap-4 cursor-pointer hover:bg-surface transition-colors duration-150\">"
441 ),
442 "</div>".to_string(),
443 )
444 };
445
446 let mut html = open_tag;
447
448 if let Some(ref icon) = props.icon {
451 html.push_str(&format!(
452 "<div class=\"w-10 h-10 flex-shrink-0 rounded-md bg-surface flex items-center justify-center text-text-muted\">{icon}</div>",
453 ));
454 }
455
456 html.push_str(&format!(
458 "<div class=\"flex-1 min-w-0\"><p class=\"text-sm font-semibold text-text\">{}</p><p class=\"text-sm text-text-muted mt-0.5\">{}</p></div>",
459 html_escape(&props.title),
460 html_escape(&props.description)
461 ));
462
463 html.push_str("<span class=\"text-text-muted flex-shrink-0 text-lg\">›</span>");
465
466 html.push_str(&close_tag);
467 html
468}
469
470fn render_product_tile(props: &ProductTileProps) -> String {
473 let name = html_escape(&props.name);
474 let price = html_escape(&props.price);
475 let field = html_escape(&props.field);
476 let qty = props.default_quantity.unwrap_or(0);
477
478 format!(
479 "<div class=\"rounded-lg border border-border bg-card p-4 flex flex-col gap-3 touch-manipulation\">\
480 <div class=\"flex items-start justify-between gap-2\">\
481 <span class=\"text-sm font-semibold text-text\">{name}</span>\
482 <span class=\"text-sm font-semibold text-text-muted\">{price}</span>\
483 </div>\
484 <div class=\"flex items-center justify-between gap-2\">\
485 <button type=\"button\" data-qty-dec=\"{field}\" \
486 class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
487 aria-label=\"Diminuisci quantit\u{00E0} {name}\">\u{2212}</button>\
488 <span data-qty-display=\"{field}\" class=\"text-sm font-semibold text-text min-w-[2ch] text-center\">{qty}</span>\
489 <button type=\"button\" data-qty-inc=\"{field}\" \
490 class=\"min-h-[44px] min-w-[44px] flex items-center justify-center rounded-md border border-border bg-surface text-text text-lg font-semibold hover:bg-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
491 aria-label=\"Aumenta quantit\u{00E0} {name}\">+</button>\
492 </div>\
493 <input type=\"hidden\" name=\"{field}\" data-qty-input=\"{field}\" value=\"{qty}\">\
494 </div>"
495 )
496}
497
498fn render_kanban_board(props: &KanbanBoardProps, data: &Value) -> String {
501 if props.columns.is_empty() {
502 return String::new();
503 }
504
505 let default_id = props
506 .mobile_default_column
507 .as_deref()
508 .unwrap_or_else(|| &props.columns[0].id);
509
510 let mut html = String::new();
511
512 html.push_str("<div class=\"hidden md:block overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">");
514 html.push_str("<div class=\"flex gap-4\" style=\"min-width: min-content;\">");
515
516 for col in &props.columns {
517 html.push_str("<div class=\"min-w-[260px] flex-1 flex-shrink-0 rounded-lg border border-border bg-card/50 p-3\">");
518 html.push_str("<div class=\"flex items-center justify-between mb-3\">");
519 html.push_str(&format!(
520 "<h3 class=\"text-sm font-semibold text-text\">{}</h3>",
521 html_escape(&col.title),
522 ));
523 let badge_class = if col.count > 0 {
524 "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold bg-primary text-primary-foreground"
525 } else {
526 "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-text-muted bg-surface"
527 };
528 html.push_str(&format!(
529 "<span class=\"{}\">{}</span>",
530 badge_class, col.count,
531 ));
532 html.push_str("</div>");
533 html.push_str("<div class=\"space-y-2\">");
534 for child in &col.children {
535 html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
536 html.push_str(&render_node(child, data));
537 html.push_str("</div>");
538 }
539 html.push_str("</div>");
540 html.push_str("</div>");
541 }
542
543 html.push_str("</div>");
544 html.push_str("</div>");
545
546 html.push_str("<div class=\"block md:hidden\" data-tabs>");
548 html.push_str("<div class=\"flex border-b border-border mb-4\">");
549
550 for col in &props.columns {
551 let is_default = col.id == default_id;
552 let (border, text) = if is_default {
553 ("border-primary", "text-primary font-semibold")
554 } else {
555 ("border-transparent", "text-text-muted hover:text-text")
556 };
557 html.push_str(&format!(
558 "<button type=\"button\" data-tab=\"{}\" class=\"flex-1 px-3 py-2 text-sm border-b-2 {} {}\" aria-selected=\"{}\">{} <span class=\"ml-1 text-xs text-text-muted\">({})</span></button>",
559 html_escape(&col.id),
560 border,
561 text,
562 is_default,
563 html_escape(&col.title),
564 col.count,
565 ));
566 }
567
568 html.push_str("</div>");
569
570 for col in &props.columns {
571 let is_default = col.id == default_id;
572 let hidden = if is_default { "" } else { " hidden" };
573 html.push_str(&format!(
574 "<div data-tab-panel=\"{}\" class=\"space-y-3{hidden}\">",
575 html_escape(&col.id),
576 ));
577 for child in &col.children {
578 html.push_str("<div data-kanban-card class=\"cursor-pointer\">");
579 html.push_str(&render_node(child, data));
580 html.push_str("</div>");
581 }
582 html.push_str("</div>");
583 }
584
585 html.push_str("</div>");
586
587 html
588}
589
590fn render_dropdown_menu(props: &DropdownMenuProps) -> String {
593 let mut html = String::from("<div class=\"relative\">");
594
595 let trigger_icon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"5\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></svg>";
597 html.push_str(&format!(
598 "<button type=\"button\" data-dropdown-toggle=\"{}\" aria-label=\"{}\" \
599 class=\"inline-flex items-center justify-center rounded-md p-1.5 \
600 text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 \
601 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary \
602 focus-visible:ring-offset-2\">{}</button>",
603 html_escape(&props.menu_id),
604 html_escape(&props.trigger_label),
605 trigger_icon,
606 ));
607
608 html.push_str(&format!(
610 "<div data-dropdown=\"{}\" \
611 class=\"absolute right-0 z-50 mt-1 w-48 rounded-md border border-border bg-card shadow-md hidden\">",
612 html_escape(&props.menu_id),
613 ));
614
615 for item in &props.items {
616 let url = item.action.url.as_deref().unwrap_or("#");
617 let base_class = if item.destructive {
618 "block px-4 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors duration-150"
619 } else {
620 "block px-4 py-2 text-sm text-text hover:bg-surface transition-colors duration-150"
621 };
622
623 let confirm_attrs = if let Some(ref confirm) = item.action.confirm {
625 let mut attrs = format!(" data-confirm-title=\"{}\"", html_escape(&confirm.title));
626 if let Some(ref message) = confirm.message {
627 attrs.push_str(&format!(
628 " data-confirm-message=\"{}\"",
629 html_escape(message)
630 ));
631 }
632 attrs
633 } else {
634 String::new()
635 };
636
637 let onclick = if item.action.confirm.is_some() {
638 " onclick=\"return confirm(this.dataset.confirmMessage || this.dataset.confirmTitle)\""
639 } else {
640 ""
641 };
642
643 match item.action.method {
644 HttpMethod::Get => {
645 html.push_str(&format!(
646 "<a href=\"{}\" class=\"{}\"{}{}>{}</a>",
647 html_escape(url),
648 base_class,
649 confirm_attrs,
650 onclick,
651 html_escape(&item.label),
652 ));
653 }
654 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => {
655 let (form_method, needs_spoofing) = match item.action.method {
656 HttpMethod::Post => ("post", false),
657 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
658 _ => unreachable!(),
659 };
660 html.push_str(&format!(
661 "<form action=\"{}\" method=\"{}\">",
662 html_escape(url),
663 form_method,
664 ));
665 if needs_spoofing {
666 let method_value = match item.action.method {
667 HttpMethod::Put => "PUT",
668 HttpMethod::Patch => "PATCH",
669 HttpMethod::Delete => "DELETE",
670 _ => unreachable!(),
671 };
672 html.push_str(&format!(
673 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
674 ));
675 }
676 html.push_str(&format!(
677 "<button type=\"submit\" class=\"w-full text-left {}\"{}{}>{}</button>",
678 base_class,
679 confirm_attrs,
680 onclick,
681 html_escape(&item.label),
682 ));
683 html.push_str("</form>");
684 }
685 }
686 }
687
688 html.push_str("</div>"); html.push_str("</div>"); html
691}
692
693fn render_plugin(props: &PluginProps, data: &Value) -> String {
696 crate::plugin::with_plugin(&props.plugin_type, |plugin| {
697 plugin.render(&props.props, data)
698 })
699 .unwrap_or_else(|| {
700 format!(
701 "<div class=\"p-4 bg-destructive/10 text-destructive rounded-md\">Unknown plugin component: {}</div>",
702 html_escape(&props.plugin_type)
703 )
704 })
705}
706
707fn render_page_header(props: &PageHeaderProps, data: &Value) -> String {
710 let mut html =
711 String::from("<div class=\"flex flex-wrap items-center justify-between gap-3 pb-4\">");
712
713 html.push_str("<div class=\"flex items-center gap-2 min-w-0\">");
715
716 if !props.breadcrumb.is_empty() {
717 for item in &props.breadcrumb {
718 if let Some(ref url) = item.url {
719 html.push_str(&format!(
720 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">{}</a>",
721 html_escape(url),
722 html_escape(&item.label)
723 ));
724 } else {
725 html.push_str(&format!(
726 "<span class=\"text-sm text-text-muted whitespace-nowrap\">{}</span>",
727 html_escape(&item.label)
728 ));
729 }
730 html.push_str(
732 "<span aria-hidden=\"true\" class=\"text-text-muted flex-shrink-0\">\
733 <svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\
734 <path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>\
735 </svg></span>"
736 );
737 }
738 }
739
740 html.push_str(&format!(
741 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">{}</h2>",
742 html_escape(&props.title)
743 ));
744 html.push_str("</div>");
745
746 if !props.actions.is_empty() {
748 html.push_str("<div class=\"flex flex-wrap items-center gap-2\">");
749 for action in &props.actions {
750 html.push_str(&render_node(action, data));
751 }
752 html.push_str("</div>");
753 }
754
755 html.push_str("</div>");
756 html
757}
758
759fn render_button_group(props: &ButtonGroupProps, data: &Value) -> String {
760 let mut html = String::from("<div class=\"flex items-center gap-2 flex-wrap\">");
761 for button in &props.buttons {
762 html.push_str(&render_node(button, data));
763 }
764 html.push_str("</div>");
765 html
766}
767
768fn render_card(props: &CardProps, data: &Value) -> String {
771 let mut html = String::from(
772 "<div class=\"rounded-lg border border-border bg-card shadow-sm overflow-visible\"><div class=\"p-4\">",
773 );
774 html.push_str(&format!(
775 "<h3 class=\"text-base font-semibold leading-snug text-text\">{}</h3>",
776 html_escape(&props.title)
777 ));
778 if let Some(ref desc) = props.description {
779 html.push_str(&format!(
780 "<p class=\"mt-1 text-sm text-text-muted\">{}</p>",
781 html_escape(desc)
782 ));
783 }
784 if !props.children.is_empty() {
785 html.push_str(
786 "<div class=\"mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible\">",
787 );
788 for child in &props.children {
789 html.push_str(&render_node(child, data));
790 }
791 html.push_str("</div>");
792 }
793 html.push_str("</div>"); if !props.footer.is_empty() {
795 html.push_str("<div class=\"border-t border-border px-6 py-4 flex items-center justify-between gap-2\">");
796 for child in &props.footer {
797 html.push_str(&render_node(child, data));
798 }
799 html.push_str("</div>");
800 }
801 html.push_str("</div>"); match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
804 FormMaxWidth::Default => {}
805 FormMaxWidth::Narrow => {
806 html = format!("<div class=\"max-w-2xl mx-auto\">{html}</div>");
807 }
808 FormMaxWidth::Wide => {
809 html = format!("<div class=\"max-w-4xl mx-auto\">{html}</div>");
810 }
811 }
812
813 html
814}
815
816fn render_modal(props: &ModalProps, data: &Value) -> String {
817 let trigger = props.trigger_label.as_deref().unwrap_or("Open");
818 let mut html = String::new();
819 html.push_str(&format!(
821 "<button type=\"button\" class=\"inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium cursor-pointer\" data-modal-open=\"{}\">{}</button>",
822 html_escape(&props.id),
823 html_escape(trigger)
824 ));
825 html.push_str(&format!(
827 "<dialog id=\"{}\" aria-modal=\"true\" aria-labelledby=\"{}-title\" class=\"bg-card rounded-lg shadow-lg max-w-lg w-full mx-4 p-6 backdrop:bg-black/50\">",
828 html_escape(&props.id),
829 html_escape(&props.id)
830 ));
831 html.push_str("<div class=\"flex items-center justify-between mb-4\">");
833 html.push_str(&format!(
834 "<h3 id=\"{}-title\" class=\"text-lg font-semibold leading-snug text-text\">{}</h3>",
835 html_escape(&props.id),
836 html_escape(&props.title)
837 ));
838 html.push_str(
839 "<button type=\"button\" data-modal-close aria-label=\"Chiudi\" class=\"text-text-muted hover:text-text p-2 rounded transition-colors duration-150\">\u{00d7}</button>",
840 );
841 html.push_str("</div>");
842 if let Some(ref desc) = props.description {
843 html.push_str(&format!(
844 "<p class=\"text-sm text-text-muted mb-4\">{}</p>",
845 html_escape(desc)
846 ));
847 }
848 html.push_str(
849 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
850 );
851 for child in &props.children {
852 html.push_str(&render_node(child, data));
853 }
854 html.push_str("</div>");
855 if !props.footer.is_empty() {
856 html.push_str("<div class=\"mt-6 flex items-center justify-end gap-2\">");
857 for child in &props.footer {
858 html.push_str(&render_node(child, data));
859 }
860 html.push_str("</div>");
861 }
862 html.push_str("</dialog>");
863 html
864}
865
866fn render_tabs(props: &TabsProps, data: &Value) -> String {
867 if props.tabs.len() == 1 {
869 let tab = &props.tabs[0];
870 let mut html = String::from(
871 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
872 );
873 for child in &tab.children {
874 html.push_str(&render_node(child, data));
875 }
876 html.push_str("</div>");
877 return html;
878 }
879
880 let has_any_content = props.tabs.iter().any(|t| !t.children.is_empty());
884
885 let mut html = String::from("<div data-tabs>");
886 html.push_str("<div class=\"border-b border-border\">");
887 html.push_str("<nav class=\"flex -mb-px space-x-4\" role=\"tablist\">");
888
889 for tab in &props.tabs {
890 let is_active = tab.value == props.default_tab;
891 let border = if is_active {
892 "border-primary"
893 } else {
894 "border-transparent"
895 };
896 let text = if is_active {
897 "text-primary font-semibold"
898 } else {
899 "text-text-muted hover:text-text"
900 };
901
902 if has_any_content && (is_active || !tab.children.is_empty()) {
903 html.push_str(&format!(
905 "<button type=\"button\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" data-tab=\"{}\" \
906 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium cursor-pointer transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
907 aria-selected=\"{}\">{}</button>",
908 html_escape(&tab.value),
909 html_escape(&tab.value),
910 html_escape(&tab.value),
911 border,
912 text,
913 is_active,
914 html_escape(&tab.label),
915 ));
916 } else {
917 html.push_str(&format!(
919 "<a href=\"?tab={}\" role=\"tab\" id=\"tab-btn-{}\" aria-controls=\"tab-panel-{}\" \
920 class=\"border-b-2 {} {} px-3 py-2 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\" \
921 aria-selected=\"{}\">{}</a>",
922 html_escape(&tab.value),
923 html_escape(&tab.value),
924 html_escape(&tab.value),
925 border,
926 text,
927 is_active,
928 html_escape(&tab.label),
929 ));
930 }
931 }
932
933 html.push_str("</nav></div>");
934
935 for tab in &props.tabs {
937 if tab.children.is_empty() && tab.value != props.default_tab {
938 continue;
939 }
940 let hidden = if tab.value != props.default_tab {
941 " hidden"
942 } else {
943 ""
944 };
945 html.push_str(&format!(
946 "<div role=\"tabpanel\" id=\"tab-panel-{}\" aria-labelledby=\"tab-btn-{}\" data-tab-panel=\"{}\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto{}\">",
947 html_escape(&tab.value),
948 html_escape(&tab.value),
949 html_escape(&tab.value),
950 hidden,
951 ));
952 for child in &tab.children {
953 html.push_str(&render_node(child, data));
954 }
955 html.push_str("</div>");
956 }
957
958 html.push_str("</div>");
959 html
960}
961
962fn render_form(props: &FormProps, data: &Value) -> String {
963 let effective_method = props
965 .method
966 .as_ref()
967 .unwrap_or(&props.action.method)
968 .clone();
969
970 let (form_method, needs_spoofing) = match effective_method {
972 HttpMethod::Get => ("get", false),
973 HttpMethod::Post => ("post", false),
974 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
975 };
976
977 let action_url = props.action.url.as_deref().unwrap_or("#");
978 let mut html = match &props.guard {
979 Some(g) => format!(
980 "<form action=\"{}\" method=\"{}\" data-form-guard=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
981 html_escape(action_url),
982 form_method,
983 html_escape(g)
984 ),
985 None => format!(
986 "<form action=\"{}\" method=\"{}\" class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
987 html_escape(action_url),
988 form_method
989 ),
990 };
991
992 if needs_spoofing {
993 let method_value = match effective_method {
994 HttpMethod::Put => "PUT",
995 HttpMethod::Patch => "PATCH",
996 HttpMethod::Delete => "DELETE",
997 _ => unreachable!(),
998 };
999 html.push_str(&format!(
1000 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1001 ));
1002 }
1003
1004 for field in &props.fields {
1005 html.push_str(&render_node(field, data));
1006 }
1007 html.push_str("</form>");
1008
1009 let html = match props.max_width.as_ref().unwrap_or(&FormMaxWidth::Default) {
1011 FormMaxWidth::Default => html,
1012 FormMaxWidth::Narrow => format!("<div class=\"max-w-2xl mx-auto\">{html}</div>"),
1013 FormMaxWidth::Wide => format!("<div class=\"max-w-4xl mx-auto\">{html}</div>"),
1014 };
1015 html
1016}
1017
1018fn render_table(props: &TableProps, data: &Value) -> String {
1019 let mut html = String::from(
1020 "<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-border\">",
1021 );
1022
1023 html.push_str("<thead class=\"bg-surface\"><tr>");
1025 for col in &props.columns {
1026 html.push_str(&format!(
1027 "<th class=\"px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted\">{}</th>",
1028 html_escape(&col.label)
1029 ));
1030 }
1031 if props.row_actions.is_some() {
1032 html.push_str("<th class=\"px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-text-muted\">Azioni</th>");
1033 }
1034 html.push_str("</tr></thead>");
1035
1036 html.push_str("<tbody class=\"divide-y divide-border bg-background\">");
1038
1039 let rows = resolve_path(data, &props.data_path);
1040 let row_array = rows.and_then(|v| v.as_array());
1041
1042 if let Some(items) = row_array {
1043 if items.is_empty() {
1044 if let Some(ref msg) = props.empty_message {
1045 let col_count =
1046 props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1047 html.push_str(&format!(
1048 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1049 col_count,
1050 html_escape(msg)
1051 ));
1052 }
1053 } else {
1054 for row in items {
1055 html.push_str("<tr class=\"hover:bg-surface\">");
1056 for col in &props.columns {
1057 let cell_value = row.get(&col.key);
1058 let cell_text = match cell_value {
1059 Some(Value::String(s)) => s.clone(),
1060 Some(Value::Number(n)) => n.to_string(),
1061 Some(Value::Bool(b)) => b.to_string(),
1062 Some(Value::Null) | None => String::new(),
1063 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1064 serde_json::to_string(v).unwrap_or_default()
1065 }
1066 };
1067 html.push_str(&format!(
1068 "<td class=\"px-6 py-4 text-sm text-text whitespace-nowrap\">{}</td>",
1069 html_escape(&cell_text)
1070 ));
1071 }
1072 if let Some(ref actions) = props.row_actions {
1073 html.push_str("<td class=\"px-6 py-4 text-right text-sm space-x-2\">");
1074 for action in actions {
1075 let url = action.url.as_deref().unwrap_or("#");
1076 let label = action
1077 .handler
1078 .split('.')
1079 .next_back()
1080 .unwrap_or(&action.handler);
1081 html.push_str(&format!(
1082 "<a href=\"{}\" class=\"text-primary hover:text-primary/80\">{}</a>",
1083 html_escape(url),
1084 html_escape(label)
1085 ));
1086 }
1087 html.push_str("</td>");
1088 }
1089 html.push_str("</tr>");
1090 }
1091 }
1092 } else if let Some(ref msg) = props.empty_message {
1093 let col_count = props.columns.len() + if props.row_actions.is_some() { 1 } else { 0 };
1094 html.push_str(&format!(
1095 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1096 col_count,
1097 html_escape(msg)
1098 ));
1099 }
1100
1101 html.push_str("</tbody></table></div>");
1102 html
1103}
1104
1105fn render_data_table(props: &DataTableProps, data: &Value) -> String {
1106 let rows = resolve_path(data, &props.data_path);
1107 let row_array = rows.and_then(|v| v.as_array().cloned());
1108 let items = row_array.unwrap_or_default();
1109 let has_actions = props.row_actions.is_some();
1110 let col_count = props.columns.len() + if has_actions { 1 } else { 0 };
1111 let empty_msg = props
1112 .empty_message
1113 .as_deref()
1114 .unwrap_or("Nessun elemento trovato");
1115
1116 let mut html = String::new();
1117
1118 html.push_str(
1120 "<div class=\"hidden md:block rounded-lg border border-border overflow-visible\">",
1121 );
1122
1123 if items.is_empty() {
1124 html.push_str("<table class=\"w-full\"><tbody>");
1125 html.push_str(&format!(
1126 "<tr><td colspan=\"{}\" class=\"px-6 py-8 text-center text-sm text-text-muted\">{}</td></tr>",
1127 col_count,
1128 html_escape(empty_msg)
1129 ));
1130 html.push_str("</tbody></table>");
1131 } else {
1132 html.push_str("<table class=\"w-full\">");
1133
1134 html.push_str("<thead><tr class=\"bg-surface\">");
1136 for col in &props.columns {
1137 html.push_str(&format!(
1138 "<th class=\"px-6 py-4 text-left text-xs font-semibold uppercase tracking-wider text-text-muted\">{}</th>",
1139 html_escape(&col.label)
1140 ));
1141 }
1142 if has_actions {
1143 html.push_str(
1144 "<th class=\"px-6 py-4 text-right text-xs font-semibold uppercase tracking-wider text-text-muted\">Azioni</th>"
1145 );
1146 }
1147 html.push_str("</tr></thead>");
1148
1149 html.push_str("<tbody>");
1151 for (index, row) in items.iter().enumerate() {
1152 let row_key_value = if let Some(ref rk) = props.row_key {
1153 row.get(rk)
1154 .and_then(|v| match v {
1155 Value::String(s) => Some(s.clone()),
1156 Value::Number(n) => Some(n.to_string()),
1157 _ => None,
1158 })
1159 .unwrap_or_else(|| index.to_string())
1160 } else {
1161 index.to_string()
1162 };
1163 let href = props
1164 .row_href
1165 .as_ref()
1166 .map(|p| p.replace("{row_key}", &row_key_value));
1167 if let Some(ref url) = href {
1168 html.push_str(&format!(
1169 "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border cursor-pointer\" onclick=\"window.location='{}'\">",
1170 html_escape(url)
1171 ));
1172 } else {
1173 html.push_str(
1174 "<tr class=\"even:bg-surface hover:bg-surface/80 transition-colors duration-150 border-t border-border\">"
1175 );
1176 }
1177 for col in &props.columns {
1178 let cell_value = row.get(&col.key);
1179 let cell_text = match cell_value {
1180 Some(Value::String(s)) => s.clone(),
1181 Some(Value::Number(n)) => n.to_string(),
1182 Some(Value::Bool(b)) => b.to_string(),
1183 Some(Value::Null) | None => String::new(),
1184 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1185 serde_json::to_string(v).unwrap_or_default()
1186 }
1187 };
1188 html.push_str(&format!(
1189 "<td class=\"px-6 py-4 text-sm text-text\">{}</td>",
1190 html_escape(&cell_text)
1191 ));
1192 }
1193 if let Some(ref actions) = props.row_actions {
1194 let row_key_value = if let Some(ref rk) = props.row_key {
1195 row.get(rk)
1196 .and_then(|v| match v {
1197 Value::String(s) => Some(s.clone()),
1198 Value::Number(n) => Some(n.to_string()),
1199 _ => None,
1200 })
1201 .unwrap_or_else(|| index.to_string())
1202 } else {
1203 index.to_string()
1204 };
1205 let templated_items: Vec<DropdownMenuAction> = actions
1206 .iter()
1207 .map(|a| {
1208 let mut cloned = a.clone();
1209 let base_url = cloned
1211 .action
1212 .url
1213 .clone()
1214 .or_else(|| Some(cloned.action.handler.clone()));
1215 if let Some(url) = base_url {
1216 cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1217 }
1218 cloned
1219 })
1220 .collect();
1221 let dropdown_props = DropdownMenuProps {
1222 menu_id: format!("dt-{row_key_value}"),
1223 trigger_label: "\u{22EE}".to_string(),
1224 items: templated_items,
1225 trigger_variant: None,
1226 };
1227 html.push_str("<td class=\"px-6 py-4 text-right\">");
1228 html.push_str(&render_dropdown_menu(&dropdown_props));
1229 html.push_str("</td>");
1230 }
1231 html.push_str("</tr>");
1232 }
1233 html.push_str("</tbody></table>");
1234 }
1235 html.push_str("</div>");
1236
1237 html.push_str("<div class=\"block md:hidden space-y-3\">");
1239 if items.is_empty() {
1240 html.push_str(&format!(
1241 "<div class=\"text-center text-sm text-text-muted py-8\">{}</div>",
1242 html_escape(empty_msg)
1243 ));
1244 } else {
1245 for (index, row) in items.iter().enumerate() {
1246 let row_key_value = if let Some(ref rk) = props.row_key {
1247 row.get(rk)
1248 .and_then(|v| match v {
1249 Value::String(s) => Some(s.clone()),
1250 Value::Number(n) => Some(n.to_string()),
1251 _ => None,
1252 })
1253 .unwrap_or_else(|| index.to_string())
1254 } else {
1255 index.to_string()
1256 };
1257 let mobile_href = props
1258 .row_href
1259 .as_ref()
1260 .map(|p| p.replace("{row_key}", &row_key_value));
1261 let has_actions = props.row_actions.is_some();
1262 let use_outer_wrapper = mobile_href.is_some() && has_actions;
1266 if use_outer_wrapper {
1267 html.push_str(
1268 "<div class=\"rounded-lg border border-border bg-card overflow-hidden\">",
1269 );
1270 html.push_str(&format!(
1271 "<a href=\"{}\" class=\"block p-4 space-y-2 hover:bg-surface transition-colors\">",
1272 html_escape(mobile_href.as_ref().unwrap())
1273 ));
1274 } else if let Some(ref url) = mobile_href {
1275 html.push_str(&format!(
1276 "<a href=\"{}\" class=\"block rounded-lg border border-border bg-card p-4 space-y-2 hover:bg-surface transition-colors\">",
1277 html_escape(url)
1278 ));
1279 } else {
1280 html.push_str(
1281 "<div class=\"rounded-lg border border-border bg-card p-4 space-y-2\">",
1282 );
1283 }
1284 for col in &props.columns {
1285 let cell_value = row.get(&col.key);
1286 let cell_text = match cell_value {
1287 Some(Value::String(s)) => s.clone(),
1288 Some(Value::Number(n)) => n.to_string(),
1289 Some(Value::Bool(b)) => b.to_string(),
1290 Some(Value::Null) | None => String::new(),
1291 Some(v @ Value::Array(_)) | Some(v @ Value::Object(_)) => {
1292 serde_json::to_string(v).unwrap_or_default()
1293 }
1294 };
1295 html.push_str(&format!(
1296 "<div class=\"flex justify-between\"><span class=\"text-xs font-semibold text-text-muted uppercase\">{}</span><span class=\"text-sm text-text\">{}</span></div>",
1297 html_escape(&col.label),
1298 html_escape(&cell_text)
1299 ));
1300 }
1301 if use_outer_wrapper {
1302 html.push_str("</a>");
1303 }
1304 if let Some(ref actions) = props.row_actions {
1305 let row_key_value = if let Some(ref rk) = props.row_key {
1306 row.get(rk)
1307 .and_then(|v| match v {
1308 Value::String(s) => Some(s.clone()),
1309 Value::Number(n) => Some(n.to_string()),
1310 _ => None,
1311 })
1312 .unwrap_or_else(|| index.to_string())
1313 } else {
1314 index.to_string()
1315 };
1316 let templated_items: Vec<DropdownMenuAction> = actions
1317 .iter()
1318 .map(|a| {
1319 let mut cloned = a.clone();
1320 let base_url = cloned
1321 .action
1322 .url
1323 .clone()
1324 .or_else(|| Some(cloned.action.handler.clone()));
1325 if let Some(url) = base_url {
1326 cloned.action.url = Some(url.replace("{row_key}", &row_key_value));
1327 }
1328 cloned
1329 })
1330 .collect();
1331 let dropdown_props = DropdownMenuProps {
1332 menu_id: format!("dt-m-{row_key_value}"),
1333 trigger_label: "\u{22EE}".to_string(),
1334 items: templated_items,
1335 trigger_variant: None,
1336 };
1337 html.push_str(
1338 "<div class=\"px-4 pb-3 pt-2 border-t border-border flex justify-end\">",
1339 );
1340 html.push_str(&render_dropdown_menu(&dropdown_props));
1341 html.push_str("</div>");
1342 }
1343 if use_outer_wrapper {
1344 html.push_str("</div>");
1345 } else if mobile_href.is_some() {
1346 html.push_str("</a>");
1347 } else {
1348 html.push_str("</div>");
1349 }
1350 }
1351 }
1352 html.push_str("</div>");
1353
1354 html
1355}
1356
1357fn render_input(props: &InputProps, data: &Value) -> String {
1360 let resolved_value = if let Some(ref dv) = props.default_value {
1362 Some(dv.clone())
1363 } else if let Some(ref dp) = props.data_path {
1364 resolve_path_string(data, dp)
1365 } else {
1366 None
1367 };
1368
1369 if matches!(props.input_type, InputType::Hidden) {
1371 let val = resolved_value.as_deref().unwrap_or("");
1372 return format!(
1373 "<input type=\"hidden\" id=\"{}\" name=\"{}\" value=\"{}\">",
1374 html_escape(&props.field),
1375 html_escape(&props.field),
1376 html_escape(val)
1377 );
1378 }
1379
1380 let has_error = props.error.is_some();
1381 let border_class = if has_error {
1382 "border-destructive"
1383 } else {
1384 "border-border"
1385 };
1386 let focus_ring_class = if has_error {
1387 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1388 } else {
1389 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1390 };
1391
1392 let mut html = String::from("<div class=\"space-y-1\">");
1393 html.push_str(&format!(
1394 "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1395 html_escape(&props.field),
1396 html_escape(&props.label)
1397 ));
1398
1399 match props.input_type {
1400 InputType::Hidden => unreachable!("handled by early return above"),
1401 InputType::Textarea => {
1402 let val = resolved_value.as_deref().unwrap_or("");
1403 html.push_str(&format!(
1404 "<textarea id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1405 html_escape(&props.field),
1406 html_escape(&props.field),
1407 border_class,
1408 focus_ring_class
1409 ));
1410 if let Some(ref placeholder) = props.placeholder {
1411 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1412 }
1413 if props.required == Some(true) {
1414 html.push_str(" required");
1415 }
1416 if props.disabled == Some(true) {
1417 html.push_str(" disabled");
1418 }
1419 if has_error {
1421 html.push_str(&format!(
1422 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1423 html_escape(&props.field)
1424 ));
1425 }
1426 html.push_str(&format!(">{}</textarea>", html_escape(val)));
1427 }
1428 _ => {
1429 let input_type = match props.input_type {
1430 InputType::Text => "text",
1431 InputType::Email => "email",
1432 InputType::Password => "password",
1433 InputType::Number => "number",
1434 InputType::Date => "date",
1435 InputType::Time => "time",
1436 InputType::Url => "url",
1437 InputType::Tel => "tel",
1438 InputType::Search => "search",
1439 InputType::Textarea | InputType::Hidden => unreachable!(),
1440 };
1441 html.push_str(&format!(
1442 "<input type=\"{}\" id=\"{}\" name=\"{}\" class=\"block w-full rounded-md border {} px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1443 input_type,
1444 html_escape(&props.field),
1445 html_escape(&props.field),
1446 border_class,
1447 focus_ring_class
1448 ));
1449 if let Some(ref placeholder) = props.placeholder {
1450 html.push_str(&format!(" placeholder=\"{}\"", html_escape(placeholder)));
1451 }
1452 if let Some(ref val) = resolved_value {
1453 html.push_str(&format!(" value=\"{}\"", html_escape(val)));
1454 }
1455 if let Some(ref step) = props.step {
1456 html.push_str(&format!(" step=\"{}\"", html_escape(step)));
1457 }
1458 if let Some(ref list_id) = props.list {
1459 html.push_str(&format!(" list=\"{}\"", html_escape(list_id)));
1460 }
1461 if props.required == Some(true) {
1462 html.push_str(" required");
1463 }
1464 if props.disabled == Some(true) {
1465 html.push_str(" disabled");
1466 }
1467 if has_error {
1469 html.push_str(&format!(
1470 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1471 html_escape(&props.field)
1472 ));
1473 }
1474 html.push('>');
1475 if let Some(ref list_id) = props.list {
1476 if let Some(arr) = data.get(list_id).and_then(|v| v.as_array()) {
1477 html.push_str(&format!("<datalist id=\"{}\">", html_escape(list_id)));
1478 for opt in arr {
1479 if let Some(s) = opt.as_str() {
1480 html.push_str(&format!("<option value=\"{}\">", html_escape(s)));
1481 }
1482 }
1483 html.push_str("</datalist>");
1484 }
1485 }
1486 }
1487 }
1488
1489 if let Some(ref desc) = props.description {
1490 html.push_str(&format!(
1491 "<p class=\"text-sm text-text-muted\">{}</p>",
1492 html_escape(desc)
1493 ));
1494 }
1495
1496 if let Some(ref error) = props.error {
1497 html.push_str(&format!(
1499 "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1500 html_escape(&props.field),
1501 html_escape(error)
1502 ));
1503 }
1504 html.push_str("</div>");
1505 html
1506}
1507
1508fn render_select(props: &SelectProps, data: &Value) -> String {
1509 let selected_value = if let Some(ref dv) = props.default_value {
1511 Some(dv.clone())
1512 } else if let Some(ref dp) = props.data_path {
1513 resolve_path_string(data, dp)
1514 } else {
1515 None
1516 };
1517
1518 let has_error = props.error.is_some();
1519 let border_class = if has_error {
1520 "border-destructive"
1521 } else {
1522 "border-border"
1523 };
1524 let focus_ring_class = if has_error {
1525 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
1526 } else {
1527 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
1528 };
1529
1530 let mut html = String::from("<div class=\"space-y-1\">");
1531 html.push_str(&format!(
1532 "<label class=\"block text-sm font-medium text-text\" for=\"{}\">{}</label>",
1533 html_escape(&props.field),
1534 html_escape(&props.label)
1535 ));
1536
1537 html.push_str("<div class=\"relative\">");
1538 html.push_str(&format!(
1539 "<select id=\"{}\" name=\"{}\" class=\"block w-full appearance-none bg-background rounded-md border {} pr-10 px-3 py-2 text-base shadow-sm transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed {}\"",
1540 html_escape(&props.field),
1541 html_escape(&props.field),
1542 border_class,
1543 focus_ring_class
1544 ));
1545 if props.required == Some(true) {
1546 html.push_str(" required");
1547 }
1548 if props.disabled == Some(true) {
1549 html.push_str(" disabled");
1550 }
1551 if has_error {
1553 html.push_str(&format!(
1554 " aria-invalid=\"true\" aria-describedby=\"err-{}\"",
1555 html_escape(&props.field)
1556 ));
1557 }
1558 html.push('>');
1559
1560 if let Some(ref placeholder) = props.placeholder {
1561 html.push_str(&format!(
1562 "<option value=\"\">{}</option>",
1563 html_escape(placeholder)
1564 ));
1565 }
1566
1567 for opt in &props.options {
1568 let is_selected = selected_value.as_deref() == Some(&opt.value);
1569 let selected_attr = if is_selected { " selected" } else { "" };
1570 html.push_str(&format!(
1571 "<option value=\"{}\"{}>{}</option>",
1572 html_escape(&opt.value),
1573 selected_attr,
1574 html_escape(&opt.label)
1575 ));
1576 }
1577
1578 html.push_str("</select>");
1579 html.push_str(concat!(
1580 "<span class=\"pointer-events-none absolute inset-y-0 right-3 flex items-center\" aria-hidden=\"true\">",
1581 "<svg class=\"h-4 w-4 text-text-muted\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1582 "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
1583 "</svg></span>"
1584 ));
1585 html.push_str("</div>");
1586
1587 if let Some(ref desc) = props.description {
1588 html.push_str(&format!(
1589 "<p class=\"text-sm text-text-muted\">{}</p>",
1590 html_escape(desc)
1591 ));
1592 }
1593
1594 if let Some(ref error) = props.error {
1595 html.push_str(&format!(
1597 "<p id=\"err-{}\" class=\"text-sm text-destructive\">{}</p>",
1598 html_escape(&props.field),
1599 html_escape(error)
1600 ));
1601 }
1602 html.push_str("</div>");
1603 html
1604}
1605
1606fn render_checkbox(props: &CheckboxProps, data: &Value) -> String {
1607 let is_checked = if let Some(c) = props.checked {
1609 c
1610 } else if let Some(ref dp) = props.data_path {
1611 resolve_path(data, dp)
1612 .map(|v| match v {
1613 Value::Bool(b) => *b,
1614 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1615 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1616 Value::Null => false,
1617 _ => true,
1618 })
1619 .unwrap_or(false)
1620 } else {
1621 false
1622 };
1623
1624 let value_attr = props.value.as_deref().unwrap_or("1");
1625 let checkbox_id = match &props.value {
1627 Some(v) => format!("{}_{}", props.field, v),
1628 None => props.field.clone(),
1629 };
1630
1631 let mut html = String::from("<div class=\"space-y-1\">");
1632 html.push_str("<div class=\"flex items-center gap-2\">");
1633 html.push_str(&format!(
1634 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"{}\" class=\"h-4 w-4 rounded-sm border-border text-primary transition-colors duration-150 motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\"",
1635 html_escape(&checkbox_id),
1636 html_escape(&props.field),
1637 html_escape(value_attr)
1638 ));
1639 if is_checked {
1640 html.push_str(" checked");
1641 }
1642 if props.required == Some(true) {
1643 html.push_str(" required");
1644 }
1645 if props.disabled == Some(true) {
1646 html.push_str(" disabled");
1647 }
1648 html.push('>');
1649 html.push_str(&format!(
1650 "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1651 html_escape(&checkbox_id),
1652 html_escape(&props.label)
1653 ));
1654 html.push_str("</div>");
1655
1656 if let Some(ref desc) = props.description {
1657 html.push_str(&format!(
1658 "<p class=\"ml-6 text-sm text-text-muted\">{}</p>",
1659 html_escape(desc)
1660 ));
1661 }
1662
1663 if let Some(ref error) = props.error {
1664 html.push_str(&format!(
1665 "<p class=\"ml-6 text-sm text-destructive\">{}</p>",
1666 html_escape(error)
1667 ));
1668 }
1669 html.push_str("</div>");
1670 html
1671}
1672
1673fn render_switch(props: &SwitchProps, data: &Value) -> String {
1674 let is_checked = if let Some(c) = props.checked {
1676 c
1677 } else if let Some(ref dp) = props.data_path {
1678 resolve_path(data, dp)
1679 .map(|v| match v {
1680 Value::Bool(b) => *b,
1681 Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
1682 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
1683 Value::Null => false,
1684 _ => true,
1685 })
1686 .unwrap_or(false)
1687 } else {
1688 false
1689 };
1690
1691 let auto_submit = props.action.is_some();
1692 let onchange = if auto_submit {
1693 " onchange=\"this.closest('form').submit()\""
1694 } else {
1695 ""
1696 };
1697
1698 let mut html = String::new();
1699
1700 if let Some(ref action) = props.action {
1702 let action_url = action.url.as_deref().unwrap_or("#");
1703 let (form_method, needs_spoofing) = match action.method {
1704 HttpMethod::Get => ("get", false),
1705 HttpMethod::Post => ("post", false),
1706 HttpMethod::Put | HttpMethod::Patch | HttpMethod::Delete => ("post", true),
1707 };
1708 html.push_str(&format!(
1709 "<form action=\"{}\" method=\"{}\">",
1710 html_escape(action_url),
1711 form_method
1712 ));
1713 if needs_spoofing {
1714 let method_value = match action.method {
1715 HttpMethod::Put => "PUT",
1716 HttpMethod::Patch => "PATCH",
1717 HttpMethod::Delete => "DELETE",
1718 _ => unreachable!(),
1719 };
1720 html.push_str(&format!(
1721 "<input type=\"hidden\" name=\"_method\" value=\"{method_value}\">"
1722 ));
1723 }
1724 }
1725
1726 html.push_str("<div class=\"space-y-1\">");
1727 html.push_str("<div class=\"flex items-center justify-between\">");
1728
1729 html.push_str("<div>");
1731 html.push_str(&format!(
1732 "<label class=\"text-sm font-medium text-text\" for=\"{}\">{}</label>",
1733 html_escape(&props.field),
1734 html_escape(&props.label)
1735 ));
1736 if let Some(ref desc) = props.description {
1737 html.push_str(&format!(
1738 "<p class=\"text-sm text-text-muted\">{}</p>",
1739 html_escape(desc)
1740 ));
1741 }
1742 html.push_str("</div>");
1743
1744 html.push_str("<label class=\"relative inline-flex items-center cursor-pointer\">");
1746 let aria_checked = if is_checked { "true" } else { "false" };
1747 html.push_str(&format!(
1748 "<input type=\"checkbox\" id=\"{}\" name=\"{}\" value=\"1\" role=\"switch\" aria-checked=\"{}\" class=\"sr-only peer\"{}",
1749 html_escape(&props.field),
1750 html_escape(&props.field),
1751 aria_checked,
1752 onchange,
1753 ));
1754 if is_checked {
1755 html.push_str(" checked");
1756 }
1757 if props.required == Some(true) {
1758 html.push_str(" required");
1759 }
1760 if props.disabled == Some(true) {
1761 html.push_str(" disabled");
1762 }
1763 html.push('>');
1764 html.push_str("<div class=\"w-11 h-6 bg-border rounded-full peer peer-checked:bg-primary peer-focus:ring-2 peer-focus:ring-primary/30 after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-background after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-full\"></div>");
1765 html.push_str("</label>");
1766 html.push_str("</div>");
1767
1768 if let Some(ref error) = props.error {
1769 html.push_str(&format!(
1770 "<p class=\"text-sm text-destructive\">{}</p>",
1771 html_escape(error)
1772 ));
1773 }
1774 html.push_str("</div>");
1775
1776 if props.action.is_some() {
1777 html.push_str("</form>");
1778 }
1779
1780 html
1781}
1782
1783fn render_text(props: &TextProps) -> String {
1786 let content = html_escape(&props.content);
1787 match props.element {
1788 TextElement::P => format!("<p class=\"text-base leading-relaxed text-text\">{content}</p>"),
1789 TextElement::H1 => format!("<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">{content}</h1>"),
1790 TextElement::H2 => {
1791 format!("<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">{content}</h2>")
1792 }
1793 TextElement::H3 => {
1794 format!("<h3 class=\"text-xl font-semibold leading-snug text-text\">{content}</h3>")
1795 }
1796 TextElement::Span => format!("<span class=\"text-base text-text\">{content}</span>"),
1797 TextElement::Div => format!("<div class=\"text-base leading-relaxed text-text\">{content}</div>"),
1798 TextElement::Section => {
1799 format!("<section class=\"text-base leading-relaxed text-text\">{content}</section>")
1800 }
1801 }
1802}
1803
1804fn render_button(props: &ButtonProps) -> String {
1805 let base = "inline-flex items-center justify-center rounded-md font-medium transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
1806
1807 let variant_classes = match props.variant {
1808 ButtonVariant::Default => "bg-primary text-primary-foreground hover:bg-primary/90",
1809 ButtonVariant::Secondary => "bg-secondary text-secondary-foreground hover:bg-secondary/90",
1810 ButtonVariant::Destructive => {
1811 "bg-destructive text-primary-foreground hover:bg-destructive/90"
1812 }
1813 ButtonVariant::Outline => "border border-border bg-background text-text hover:bg-surface",
1814 ButtonVariant::Ghost => "text-text hover:bg-surface",
1815 ButtonVariant::Link => "text-primary underline hover:text-primary/80",
1816 };
1817
1818 let size_classes = match props.size {
1819 Size::Xs => "px-2 py-1 text-xs",
1820 Size::Sm => "px-3 py-1.5 text-sm",
1821 Size::Default => "px-4 py-2 text-sm",
1822 Size::Lg => "px-6 py-3 text-base",
1823 };
1824
1825 let disabled_classes = if props.disabled == Some(true) {
1826 " opacity-50 cursor-not-allowed"
1827 } else {
1828 ""
1829 };
1830
1831 let disabled_attr = if props.disabled == Some(true) {
1832 " disabled"
1833 } else {
1834 ""
1835 };
1836
1837 let label = html_escape(&props.label);
1838
1839 let content = if let Some(ref icon) = props.icon {
1841 let icon_span = format!(
1842 "<span class=\"icon\" data-icon=\"{}\">{}</span>",
1843 html_escape(icon),
1844 html_escape(icon)
1845 );
1846 let position = props.icon_position.as_ref().cloned().unwrap_or_default();
1847 match position {
1848 IconPosition::Left => format!("{icon_span} {label}"),
1849 IconPosition::Right => format!("{label} {icon_span}"),
1850 }
1851 } else {
1852 label
1853 };
1854
1855 let type_attr = match props.button_type.as_ref() {
1859 Some(ButtonType::Button) => " type=\"button\"",
1860 Some(ButtonType::Submit) => " type=\"submit\"",
1861 None => "",
1862 };
1863
1864 format!(
1865 "<button{type_attr} class=\"{base} {variant_classes} {size_classes}{disabled_classes}\"{disabled_attr}>{content}</button>"
1866 )
1867}
1868
1869fn render_badge(props: &BadgeProps) -> String {
1870 let base = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium";
1871 let variant_classes = match props.variant {
1872 BadgeVariant::Default => "bg-primary/10 text-primary",
1873 BadgeVariant::Secondary => "bg-secondary/10 text-secondary-foreground",
1874 BadgeVariant::Destructive => "bg-destructive/10 text-destructive",
1875 BadgeVariant::Outline => "border border-border text-text",
1876 };
1877 format!(
1878 "<span class=\"{} {}\">{}</span>",
1879 base,
1880 variant_classes,
1881 html_escape(&props.label)
1882 )
1883}
1884
1885const ICON_INFO: &str = concat!(
1888 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1889 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1890 "<path fill-rule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z\" clip-rule=\"evenodd\"/>",
1891 "</svg></span>"
1892);
1893
1894const ICON_SUCCESS: &str = concat!(
1895 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1896 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1897 "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z\" clip-rule=\"evenodd\"/>",
1898 "</svg></span>"
1899);
1900
1901const ICON_WARNING: &str = concat!(
1902 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1903 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1904 "<path fill-rule=\"evenodd\" d=\"M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z\" clip-rule=\"evenodd\"/>",
1905 "</svg></span>"
1906);
1907
1908const ICON_ERROR: &str = concat!(
1909 "<span aria-hidden=\"true\" class=\"shrink-0\">",
1910 "<svg class=\"h-5 w-5\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
1911 "<path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z\" clip-rule=\"evenodd\"/>",
1912 "</svg></span>"
1913);
1914
1915fn render_alert(props: &AlertProps) -> String {
1916 let variant_classes = match props.variant {
1917 AlertVariant::Info => "bg-primary/10 border-primary text-primary",
1918 AlertVariant::Success => "bg-success/10 border-success text-success",
1919 AlertVariant::Warning => "bg-warning/10 border-warning text-warning",
1920 AlertVariant::Error => "bg-destructive/10 border-destructive text-destructive",
1921 };
1922 let icon = match props.variant {
1923 AlertVariant::Info => ICON_INFO,
1924 AlertVariant::Success => ICON_SUCCESS,
1925 AlertVariant::Warning => ICON_WARNING,
1926 AlertVariant::Error => ICON_ERROR,
1927 };
1928 let mut html = format!(
1929 "<div role=\"alert\" class=\"rounded-md border p-4 flex items-start gap-3 {variant_classes}\">"
1930 );
1931 html.push_str(icon);
1932 html.push_str("<div>");
1933 if let Some(ref title) = props.title {
1934 html.push_str(&format!(
1935 "<h4 class=\"font-semibold mb-1\">{}</h4>",
1936 html_escape(title)
1937 ));
1938 }
1939 html.push_str(&format!("<p>{}</p>", html_escape(&props.message)));
1940 html.push_str("</div>");
1941 html.push_str("</div>");
1942 html
1943}
1944
1945fn render_separator(props: &SeparatorProps) -> String {
1946 let orientation = props.orientation.as_ref().cloned().unwrap_or_default();
1947 match orientation {
1948 Orientation::Horizontal => "<hr class=\"my-4 border-border\">".to_string(),
1949 Orientation::Vertical => "<div class=\"mx-4 h-full w-px bg-border\"></div>".to_string(),
1950 }
1951}
1952
1953fn render_progress(props: &ProgressProps) -> String {
1954 let max = props.max.unwrap_or(100) as f64;
1955 let pct = if max > 0.0 {
1956 ((props.value as f64 * 100.0 / max).round() as u8).min(100)
1957 } else {
1958 0
1959 };
1960
1961 let mut html = String::from("<div class=\"w-full\">");
1962 if let Some(ref label) = props.label {
1963 html.push_str(&format!(
1964 "<div class=\"mb-1 text-sm text-text-muted\">{}</div>",
1965 html_escape(label)
1966 ));
1967 }
1968 html.push_str(&format!(
1969 "<div class=\"w-full rounded-full bg-border h-2.5\"><div class=\"rounded-full bg-primary h-2.5\" style=\"width: {pct}%\"></div></div>"
1970 ));
1971 html.push_str("</div>");
1972 html
1973}
1974
1975fn render_avatar(props: &AvatarProps) -> String {
1976 let size = props.size.as_ref().cloned().unwrap_or_default();
1977 let size_classes = match size {
1978 Size::Xs => "h-6 w-6 text-xs",
1979 Size::Sm => "h-8 w-8 text-sm",
1980 Size::Default => "h-10 w-10 text-sm",
1981 Size::Lg => "h-12 w-12 text-base",
1982 };
1983
1984 if let Some(ref src) = props.src {
1985 format!(
1986 "<img src=\"{}\" alt=\"{}\" class=\"rounded-full object-cover {}\">",
1987 html_escape(src),
1988 html_escape(&props.alt),
1989 size_classes
1990 )
1991 } else {
1992 let fallback_text = props.fallback.as_deref().unwrap_or_else(|| {
1993 &props.alt
1995 });
1996 let initials: String = fallback_text.chars().take(2).collect();
1998 format!(
1999 "<span class=\"inline-flex items-center justify-center rounded-full bg-card text-text-muted {}\">{}</span>",
2000 size_classes,
2001 html_escape(&initials)
2002 )
2003 }
2004}
2005
2006fn render_image(props: &ImageProps) -> String {
2007 let container_style = match &props.aspect_ratio {
2008 Some(ratio) => format!(" style=\"aspect-ratio: {}\"", html_escape(ratio)),
2009 None => String::new(),
2010 };
2011
2012 let placeholder = match &props.placeholder_label {
2015 Some(label) => format!(
2016 "<div class=\"absolute inset-0 flex items-center justify-center \
2017 rounded-md bg-surface text-xs text-text-muted\">{}</div>",
2018 html_escape(label)
2019 ),
2020 None => String::from("<div class=\"absolute inset-0 rounded-md bg-surface\"></div>"),
2021 };
2022
2023 format!(
2024 "<div class=\"relative w-full\"{container_style}>\
2025 {placeholder}\
2026 <img src=\"{src}\" alt=\"{alt}\" \
2027 class=\"relative w-full h-full rounded-md object-cover object-top\" \
2028 loading=\"lazy\" onerror=\"this.style.display='none'\">\
2029 </div>",
2030 src = html_escape(&props.src),
2031 alt = html_escape(&props.alt),
2032 )
2033}
2034
2035const SHIMMER_CSS: &str = concat!(
2038 "<style>",
2039 "@keyframes ferro-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}",
2040 ".ferro-shimmer{",
2041 "background:linear-gradient(90deg,var(--color-card,#f1f5f9) 25%,var(--color-border,#e2e8f0) 50%,var(--color-card,#f1f5f9) 75%);",
2042 "background-size:200% 100%;",
2043 "animation:ferro-shimmer 1.5s ease-in-out infinite;",
2044 "}",
2045 "</style>"
2046);
2047
2048fn render_skeleton(props: &SkeletonProps) -> String {
2049 let width = props.width.as_deref().unwrap_or("100%");
2050 let height = props.height.as_deref().unwrap_or("1rem");
2051 let rounded = if props.rounded == Some(true) {
2052 "rounded-full"
2053 } else {
2054 "rounded-md"
2055 };
2056 format!("{SHIMMER_CSS}<div class=\"ferro-shimmer {rounded}\" style=\"width: {width}; height: {height}\"></div>")
2057}
2058
2059const BREADCRUMB_SEP: &str = concat!(
2062 "<span aria-hidden=\"true\" class=\"text-text-muted\">",
2063 "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2064 "<path fill-rule=\"evenodd\" d=\"M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z\" clip-rule=\"evenodd\"/>",
2065 "</svg></span>"
2066);
2067
2068fn render_breadcrumb(props: &BreadcrumbProps) -> String {
2069 let mut html =
2070 String::from("<nav class=\"flex items-center space-x-2 text-sm text-text-muted\">");
2071 let len = props.items.len();
2072 for (i, item) in props.items.iter().enumerate() {
2073 let is_last = i == len - 1;
2074 if is_last {
2075 html.push_str(&format!(
2076 "<span class=\"text-text font-medium\">{}</span>",
2077 html_escape(&item.label)
2078 ));
2079 } else if let Some(ref url) = item.url {
2080 html.push_str(&format!(
2081 "<a href=\"{}\" class=\"hover:text-text transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2082 html_escape(url),
2083 html_escape(&item.label)
2084 ));
2085 } else {
2086 html.push_str(&format!("<span>{}</span>", html_escape(&item.label)));
2087 }
2088 if !is_last {
2089 html.push_str(BREADCRUMB_SEP);
2090 }
2091 }
2092 html.push_str("</nav>");
2093 html
2094}
2095
2096fn render_pagination(props: &PaginationProps) -> String {
2097 if props.total == 0 || props.per_page == 0 {
2098 return String::new();
2099 }
2100
2101 let total_pages = props.total.div_ceil(props.per_page);
2102 if total_pages <= 1 {
2103 return String::new();
2104 }
2105
2106 let base_url = props.base_url.as_deref().unwrap_or("?");
2107 let current = props.current_page;
2108
2109 let mut html = String::from("<nav class=\"flex items-center space-x-1\">");
2110
2111 if current > 1 {
2113 html.push_str(&format!(
2114 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">«</a>",
2115 html_escape(base_url),
2116 current - 1
2117 ));
2118 }
2119
2120 let pages = compute_page_range(current, total_pages);
2122 let mut prev_page = 0u32;
2123 for page in pages {
2124 if prev_page > 0 && page > prev_page + 1 {
2125 html.push_str("<span class=\"px-2 text-text-muted\">…</span>");
2126 }
2127 if page == current {
2128 html.push_str(&format!(
2129 "<span class=\"px-3 py-1 rounded-md bg-primary text-primary-foreground\">{page}</span>"
2130 ));
2131 } else {
2132 html.push_str(&format!(
2133 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">{}</a>",
2134 html_escape(base_url),
2135 page,
2136 page
2137 ));
2138 }
2139 prev_page = page;
2140 }
2141
2142 if current < total_pages {
2144 html.push_str(&format!(
2145 "<a href=\"{}page={}\" class=\"px-3 py-1 rounded-md bg-background text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2\">»</a>",
2146 html_escape(base_url),
2147 current + 1
2148 ));
2149 }
2150
2151 html.push_str("</nav>");
2152 html
2153}
2154
2155fn compute_page_range(current: u32, total: u32) -> Vec<u32> {
2157 if total <= 7 {
2158 return (1..=total).collect();
2159 }
2160 let mut pages = Vec::new();
2161 pages.push(1);
2162 let start = current.saturating_sub(1).max(2);
2163 let end = (current + 1).min(total - 1);
2164 for p in start..=end {
2165 if !pages.contains(&p) {
2166 pages.push(p);
2167 }
2168 }
2169 if !pages.contains(&total) {
2170 pages.push(total);
2171 }
2172 pages.sort();
2173 pages.dedup();
2174 pages
2175}
2176
2177fn render_description_list(props: &DescriptionListProps) -> String {
2178 let columns = props.columns.unwrap_or(1);
2179 let mut html = format!("<dl class=\"grid grid-cols-{columns} gap-4\">");
2180 for item in &props.items {
2181 html.push_str(&format!(
2182 "<div><dt class=\"text-sm font-medium text-text-muted\">{}</dt><dd class=\"mt-1 text-sm text-text\">{}</dd></div>",
2183 html_escape(&item.label),
2184 html_escape(&item.value)
2185 ));
2186 }
2187 html.push_str("</dl>");
2188 html
2189}
2190
2191fn render_grid(props: &GridProps, data: &Value) -> String {
2194 let gap = match props.gap {
2195 GapSize::None => "gap-0",
2196 GapSize::Sm => "gap-2",
2197 GapSize::Md => "gap-4",
2198 GapSize::Lg => "gap-6",
2199 GapSize::Xl => "gap-8",
2200 };
2201
2202 if props.scrollable == Some(true) {
2203 let mut html = format!("<div class=\"overflow-x-auto\"><div class=\"grid grid-flow-col auto-cols-[minmax(280px,1fr)] {gap}\">");
2204 for child in &props.children {
2205 html.push_str(&render_node(child, data));
2206 }
2207 html.push_str("</div></div>");
2208 return html;
2209 }
2210
2211 let cols = props.columns.clamp(1, 12);
2212 let mut col_classes = format!("grid-cols-{cols}");
2213 if let Some(md) = props.md_columns {
2214 col_classes.push_str(&format!(" md:grid-cols-{}", md.clamp(1, 12)));
2215 }
2216 if let Some(lg) = props.lg_columns {
2217 col_classes.push_str(&format!(" lg:grid-cols-{}", lg.clamp(1, 12)));
2218 }
2219 let mut html = format!("<div class=\"grid w-full {col_classes} {gap}\">");
2220 for child in &props.children {
2221 html.push_str(&render_node(child, data));
2222 }
2223 html.push_str("</div>");
2224 html
2225}
2226
2227const CHEVRON_DOWN: &str = concat!(
2230 "<svg class=\"h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\">",
2231 "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" clip-rule=\"evenodd\"/>",
2232 "</svg>"
2233);
2234
2235fn render_collapsible(props: &CollapsibleProps, data: &Value) -> String {
2236 let mut html = String::from("<details class=\"group\"");
2237 if props.expanded {
2238 html.push_str(" open");
2239 }
2240 html.push('>');
2241 let aria_expanded = if props.expanded { "true" } else { "false" };
2242 html.push_str(&format!(
2243 "<summary class=\"flex items-center justify-between cursor-pointer px-4 py-3 text-sm font-medium text-text bg-surface rounded-lg hover:bg-card\" aria-expanded=\"{}\">{}<span class=\"text-text-muted group-open:rotate-180 transition-transform\">{CHEVRON_DOWN}</span></summary>",
2244 aria_expanded,
2245 html_escape(&props.title)
2246 ));
2247 html.push_str("<div class=\"px-4 py-3 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2248 for child in &props.children {
2249 html.push_str(&render_node(child, data));
2250 }
2251 html.push_str("</div></details>");
2252 html
2253}
2254
2255fn render_empty_state(props: &EmptyStateProps) -> String {
2256 let mut html = String::from(
2257 "<div class=\"flex flex-col items-center justify-center py-8 px-6 text-center\">",
2258 );
2259 html.push_str(&format!(
2260 "<p class=\"text-sm text-text-muted\">{}</p>",
2261 html_escape(&props.title)
2262 ));
2263 if let Some(ref desc) = props.description {
2264 html.push_str(&format!(
2265 "<p class=\"mt-1 text-sm text-text-muted\">{}</p>",
2266 html_escape(desc)
2267 ));
2268 }
2269 if let Some(ref action) = props.action {
2270 let label = props.action_label.as_deref().unwrap_or("Action");
2271 let url = action.url.as_deref().unwrap_or("#");
2272 html.push_str(&format!(
2273 "<a href=\"{}\" class=\"mt-4 inline-flex items-center justify-center rounded-md \
2274 border border-border bg-card text-text px-4 py-2 text-sm font-medium \
2275 hover:bg-surface transition-colors\">{}</a>",
2276 html_escape(url),
2277 html_escape(label)
2278 ));
2279 }
2280 html.push_str("</div>");
2281 html
2282}
2283
2284fn render_form_section(props: &FormSectionProps, data: &Value) -> String {
2285 let is_two_column = matches!(props.layout.as_ref(), Some(FormSectionLayout::TwoColumn));
2286
2287 if is_two_column {
2288 let mut html = String::from("<fieldset class=\"md:grid md:grid-cols-5 md:gap-8\">");
2290 html.push_str(&format!(
2291 "<div class=\"md:col-span-2\"><legend class=\"text-base font-semibold text-text\">{}</legend>",
2292 html_escape(&props.title)
2293 ));
2294 if let Some(ref desc) = props.description {
2295 html.push_str(&format!(
2296 "<p class=\"text-sm text-text-muted mt-1\">{}</p>",
2297 html_escape(desc)
2298 ));
2299 }
2300 html.push_str("</div>");
2301 html.push_str("<div class=\"md:col-span-3 space-y-4 mt-4 md:mt-0\">");
2302 for child in &props.children {
2303 html.push_str(&render_node(child, data));
2304 }
2305 html.push_str("</div></fieldset>");
2306 html
2307 } else {
2308 let mut html = String::from(
2310 "<fieldset class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">",
2311 );
2312 html.push_str(&format!(
2313 "<legend class=\"text-base font-semibold text-text\">{}</legend>",
2314 html_escape(&props.title)
2315 ));
2316 if let Some(ref desc) = props.description {
2317 html.push_str(&format!(
2318 "<p class=\"text-sm text-text-muted\">{}</p>",
2319 html_escape(desc)
2320 ));
2321 }
2322 html.push_str("<div class=\"space-y-4\">");
2323 for child in &props.children {
2324 html.push_str(&render_node(child, data));
2325 }
2326 html.push_str("</div></fieldset>");
2327 html
2328 }
2329}
2330
2331fn render_stat_card(props: &StatCardProps) -> String {
2334 let mut html =
2335 String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2336 if let Some(ref icon) = props.icon {
2337 html.push_str(&format!(
2338 "<span class=\"inline-block mb-2 w-6 h-6\">{icon}</span>"
2339 ));
2340 }
2342 html.push_str(&format!(
2343 "<p class=\"text-sm text-text-muted\">{}</p>",
2344 html_escape(&props.label)
2345 ));
2346 if let Some(ref sse) = props.sse_target {
2347 html.push_str(&format!(
2348 "<p class=\"text-2xl font-bold text-text\" data-sse-target=\"{}\" data-live-value>{}</p>",
2349 html_escape(sse),
2350 html_escape(&props.value)
2351 ));
2352 } else {
2353 html.push_str(&format!(
2354 "<p class=\"text-2xl font-bold text-text\">{}</p>",
2355 html_escape(&props.value)
2356 ));
2357 }
2358 if let Some(ref subtitle) = props.subtitle {
2359 html.push_str(&format!(
2360 "<p class=\"text-xs text-text-muted mt-1\">{}</p>",
2361 html_escape(subtitle)
2362 ));
2363 }
2364 html.push_str("</div>");
2365 html
2366}
2367
2368fn render_checklist(props: &ChecklistProps) -> String {
2369 let mut html =
2370 String::from("<div class=\"bg-card rounded-lg shadow-sm p-4 border border-border\">");
2371 html.push_str("<div class=\"flex items-center justify-between mb-3\">");
2372 html.push_str(&format!(
2373 "<h3 class=\"text-sm font-semibold leading-snug text-text\">{}</h3>",
2374 html_escape(&props.title)
2375 ));
2376 if props.dismissible {
2377 let dismiss_label = props.dismiss_label.as_deref().unwrap_or("Dismiss");
2378 html.push_str(&format!(
2379 "<button type=\"button\" class=\"text-xs font-medium text-text hover:text-primary\" data-dismissible>{}</button>",
2380 html_escape(dismiss_label)
2381 ));
2382 }
2383 html.push_str("</div>");
2384 if let Some(ref key) = props.data_key {
2385 html.push_str(&format!(
2386 "<div data-checklist-key=\"{}\">",
2387 html_escape(key)
2388 ));
2389 } else {
2390 html.push_str("<div>");
2391 }
2392 if props.dismissible {
2393 html.push_str("<ul data-dismissible class=\"space-y-2\">");
2394 } else {
2395 html.push_str("<ul class=\"space-y-2\">");
2396 }
2397 for item in &props.items {
2398 html.push_str("<li class=\"flex items-center gap-2\">");
2399 if item.checked {
2400 html.push_str("<input type=\"checkbox\" checked class=\"h-4 w-4 rounded-sm border-border text-primary\">");
2401 } else {
2402 html.push_str(
2403 "<input type=\"checkbox\" class=\"h-4 w-4 rounded-sm border-border text-primary\">",
2404 );
2405 }
2406 let label_class = if item.checked {
2407 "text-sm line-through text-text-muted"
2408 } else {
2409 "text-sm text-text"
2410 };
2411 if let Some(ref href) = item.href {
2412 html.push_str(&format!(
2413 "<a href=\"{}\" class=\"{}\">{}</a>",
2414 html_escape(href),
2415 label_class,
2416 html_escape(&item.label)
2417 ));
2418 } else {
2419 html.push_str(&format!(
2420 "<span class=\"{}\">{}</span>",
2421 label_class,
2422 html_escape(&item.label)
2423 ));
2424 }
2425 html.push_str("</li>");
2426 }
2427 html.push_str("</ul></div></div>");
2428 html
2429}
2430
2431fn render_toast(props: &ToastProps) -> String {
2432 let variant_classes = match props.variant {
2433 ToastVariant::Info => "bg-primary/10 border-primary text-primary",
2434 ToastVariant::Success => "bg-success/10 border-success text-success",
2435 ToastVariant::Warning => "bg-warning/10 border-warning text-warning",
2436 ToastVariant::Error => "bg-destructive/10 border-destructive text-destructive",
2437 };
2438 let variant_str = match props.variant {
2439 ToastVariant::Info => "info",
2440 ToastVariant::Success => "success",
2441 ToastVariant::Warning => "warning",
2442 ToastVariant::Error => "error",
2443 };
2444 let timeout = props.timeout.unwrap_or(5);
2445 let mut html = format!(
2446 "<div class=\"fixed top-4 right-4 z-50 rounded-md border p-4 shadow-lg {variant_classes}\" data-toast-variant=\"{variant_str}\" data-toast-timeout=\"{timeout}\"",
2447 );
2448 if props.dismissible {
2449 html.push_str(" data-toast-dismissible");
2450 }
2451 html.push('>');
2452 html.push_str("<div class=\"flex items-start gap-3\">");
2453 html.push_str(&format!(
2454 "<p class=\"text-sm\">{}</p>",
2455 html_escape(&props.message)
2456 ));
2457 if props.dismissible {
2458 html.push_str(
2459 "<button type=\"button\" class=\"ml-auto text-current opacity-70 hover:opacity-100\">×</button>",
2460 );
2461 }
2462 html.push_str("</div></div>");
2463 html
2464}
2465
2466const BELL_SVG: &str = concat!(
2469 "<svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">",
2470 "<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" ",
2471 "d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/>",
2472 "</svg>"
2473);
2474
2475fn render_notification_dropdown(props: &NotificationDropdownProps) -> String {
2476 let unread_count = props.notifications.iter().filter(|n| !n.read).count();
2477 let mut html = String::from("<div class=\"relative\" data-notification-dropdown>");
2478 html.push_str(&format!(
2480 "<button type=\"button\" class=\"relative p-2 text-text-muted hover:text-text\" data-notification-count=\"{unread_count}\">"
2481 ));
2482 html.push_str(BELL_SVG);
2483 if unread_count > 0 {
2484 html.push_str(&format!(
2485 "<span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{unread_count}</span>"
2486 ));
2487 }
2488 html.push_str("</button>");
2489 html.push_str(
2491 "<div class=\"hidden absolute right-0 mt-2 w-80 bg-card rounded-lg shadow-lg border border-border z-50\" data-notification-panel>",
2492 );
2493 if props.notifications.is_empty() {
2494 let empty = props.empty_text.as_deref().unwrap_or("No notifications");
2495 html.push_str(&format!(
2496 "<p class=\"p-4 text-sm text-text-muted\">{}</p>",
2497 html_escape(empty)
2498 ));
2499 } else {
2500 html.push_str("<ul class=\"divide-y divide-border\">");
2501 for item in &props.notifications {
2502 html.push_str("<li class=\"flex items-start gap-3 p-3\">");
2503 if let Some(ref icon) = item.icon {
2504 html.push_str(&format!(
2505 "<span class=\"text-lg shrink-0\">{}</span>",
2506 html_escape(icon)
2507 ));
2508 }
2509 html.push_str("<div class=\"flex-1 min-w-0\">");
2510 if let Some(ref url) = item.action_url {
2511 html.push_str(&format!(
2512 "<a href=\"{}\" class=\"text-sm text-text hover:underline\">{}</a>",
2513 html_escape(url),
2514 html_escape(&item.text)
2515 ));
2516 } else {
2517 html.push_str(&format!(
2518 "<p class=\"text-sm text-text\">{}</p>",
2519 html_escape(&item.text)
2520 ));
2521 }
2522 if let Some(ref ts) = item.timestamp {
2523 html.push_str(&format!(
2524 "<p class=\"text-xs text-text-muted mt-0.5\">{}</p>",
2525 html_escape(ts)
2526 ));
2527 }
2528 html.push_str("</div>");
2529 if !item.read {
2530 html.push_str(
2531 "<span class=\"h-2 w-2 mt-1 shrink-0 rounded-full bg-primary\"></span>",
2532 );
2533 }
2534 html.push_str("</li>");
2535 }
2536 html.push_str("</ul>");
2537 }
2538 html.push_str("</div></div>");
2539 html
2540}
2541
2542fn render_sidebar(props: &SidebarProps) -> String {
2543 let mut html =
2544 String::from("<aside class=\"flex flex-col h-full bg-background border-r border-border\">");
2545 if !props.fixed_top.is_empty() {
2547 html.push_str("<nav class=\"p-4 space-y-1\">");
2548 for item in &props.fixed_top {
2549 html.push_str(&render_sidebar_nav_item(item));
2550 }
2551 html.push_str("</nav>");
2552 }
2553 if !props.groups.is_empty() {
2555 html.push_str("<div class=\"flex-1 overflow-y-auto p-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">");
2556 for group in &props.groups {
2557 html.push_str("<div data-sidebar-group");
2558 if group.collapsed {
2559 html.push_str(" data-collapsed");
2560 }
2561 html.push('>');
2562 html.push_str(&format!(
2563 "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted uppercase tracking-wider\">{}</p>",
2564 html_escape(&group.label)
2565 ));
2566 html.push_str("<nav class=\"space-y-1\">");
2567 for item in &group.items {
2568 html.push_str(&render_sidebar_nav_item(item));
2569 }
2570 html.push_str("</nav></div>");
2571 }
2572 html.push_str("</div>");
2573 }
2574 if !props.fixed_bottom.is_empty() {
2576 html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
2577 for item in &props.fixed_bottom {
2578 html.push_str(&render_sidebar_nav_item(item));
2579 }
2580 html.push_str("</nav>");
2581 }
2582 html.push_str("</aside>");
2583 html
2584}
2585
2586fn render_sidebar_nav_item(item: &crate::component::SidebarNavItem) -> String {
2587 let classes = if item.active {
2588 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
2589 } else {
2590 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
2591 };
2592 let mut html = format!(
2593 "<a href=\"{}\" class=\"{}\">",
2594 html_escape(&item.href),
2595 classes
2596 );
2597 if let Some(ref icon) = item.icon {
2598 html.push_str(&format!(
2599 "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" ));
2601 }
2602 html.push_str(&format!("{}</a>", html_escape(&item.label)));
2603 html
2604}
2605
2606fn render_header(props: &HeaderProps) -> String {
2607 let mut html = String::from(
2608 "<header class=\"relative flex items-center justify-between px-6 py-4 bg-background border-b border-border\">",
2609 );
2610 html.push_str("<div></div>");
2612 html.push_str(&format!(
2615 "<span class=\"absolute left-1/2 -translate-x-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
2616 html_escape(&props.business_name)
2617 ));
2618 html.push_str("<div class=\"flex items-center gap-4\">");
2619 if let Some(count) = props.notification_count {
2621 if count > 0 {
2622 html.push_str(&format!(
2623 "<div class=\"relative\"><span class=\"text-text-muted\">{BELL_SVG}</span><span class=\"absolute top-0 right-0 inline-flex items-center justify-center h-4 w-4 text-xs font-bold text-primary-foreground bg-destructive rounded-full\" data-notification-count=\"{count}\">{count}</span></div>"
2624 ));
2625 } else {
2626 html.push_str(&format!(
2627 "<span class=\"text-text-muted\" data-notification-count=\"{count}\">{BELL_SVG}</span>"
2628 ));
2629 }
2630 }
2631 html.push_str("<div class=\"flex items-center gap-2\">");
2633 if let Some(ref avatar) = props.user_avatar {
2634 html.push_str(&format!(
2635 "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
2636 html_escape(avatar)
2637 ));
2638 } else if let Some(ref name) = props.user_name {
2639 let initials: String = name
2640 .split_whitespace()
2641 .filter_map(|w| w.chars().next())
2642 .take(2)
2643 .collect();
2644 html.push_str(&format!(
2645 "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full bg-card text-text-muted text-sm font-medium\">{}</span>",
2646 html_escape(&initials)
2647 ));
2648 html.push_str(&format!(
2649 "<span class=\"text-sm text-text\">{}</span>",
2650 html_escape(name)
2651 ));
2652 }
2653 if let Some(ref logout) = props.logout_url {
2654 html.push_str(&format!(
2655 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
2656 html_escape(logout)
2657 ));
2658 }
2659 html.push_str("</div></div></header>");
2660 html
2661}
2662
2663pub(crate) fn html_escape(s: &str) -> String {
2667 let mut escaped = String::with_capacity(s.len());
2668 for c in s.chars() {
2669 match c {
2670 '&' => escaped.push_str("&"),
2671 '<' => escaped.push_str("<"),
2672 '>' => escaped.push_str(">"),
2673 '"' => escaped.push_str("""),
2674 '\'' => escaped.push_str("'"),
2675 _ => escaped.push(c),
2676 }
2677 }
2678 escaped
2679}
2680
2681#[cfg(test)]
2682mod tests {
2683 use super::*;
2684 use crate::action::{Action, HttpMethod};
2685 use crate::component::*;
2686 use serde_json::json;
2687
2688 fn text_node(key: &str, content: &str, element: TextElement) -> ComponentNode {
2691 ComponentNode {
2692 key: key.to_string(),
2693 component: Component::Text(TextProps {
2694 content: content.to_string(),
2695 element,
2696 }),
2697 action: None,
2698 visibility: None,
2699 }
2700 }
2701
2702 fn button_node(key: &str, label: &str, variant: ButtonVariant, size: Size) -> ComponentNode {
2703 ComponentNode {
2704 key: key.to_string(),
2705 component: Component::Button(ButtonProps {
2706 label: label.to_string(),
2707 variant,
2708 size,
2709 disabled: None,
2710 icon: None,
2711 icon_position: None,
2712 button_type: None,
2713 }),
2714 action: None,
2715 visibility: None,
2716 }
2717 }
2718
2719 fn make_action(handler: &str, method: HttpMethod) -> Action {
2720 Action {
2721 handler: handler.to_string(),
2722 url: None,
2723 method,
2724 confirm: None,
2725 on_success: None,
2726 on_error: None,
2727 target: None,
2728 }
2729 }
2730
2731 fn make_action_with_url(handler: &str, method: HttpMethod, url: &str) -> Action {
2732 Action {
2733 handler: handler.to_string(),
2734 url: Some(url.to_string()),
2735 method,
2736 confirm: None,
2737 on_success: None,
2738 on_error: None,
2739 target: None,
2740 }
2741 }
2742
2743 #[test]
2746 fn render_empty_view_produces_wrapper_div() {
2747 let view = JsonUiView::new();
2748 let html = render_to_html(&view, &json!({}));
2749 assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>");
2750 }
2751
2752 #[test]
2753 fn render_view_with_component_wraps_in_div() {
2754 let view = JsonUiView::new().component(text_node("t", "Hello", TextElement::P));
2755 let html = render_to_html(&view, &json!({}));
2756 assert!(html.starts_with(
2757 "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\">"
2758 ));
2759 assert!(html.ends_with("</div>"));
2760 assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Hello</p>"));
2761 }
2762
2763 #[test]
2766 fn text_p_variant() {
2767 let view = JsonUiView::new().component(text_node("t", "Paragraph", TextElement::P));
2768 let html = render_to_html(&view, &json!({}));
2769 assert!(html.contains("<p class=\"text-base leading-relaxed text-text\">Paragraph</p>"));
2770 }
2771
2772 #[test]
2773 fn text_h1_variant() {
2774 let view = JsonUiView::new().component(text_node("t", "Title", TextElement::H1));
2775 let html = render_to_html(&view, &json!({}));
2776 assert!(html.contains(
2777 "<h1 class=\"text-3xl font-bold leading-tight tracking-tight text-text\">Title</h1>"
2778 ));
2779 }
2780
2781 #[test]
2782 fn text_h2_variant() {
2783 let view = JsonUiView::new().component(text_node("t", "Subtitle", TextElement::H2));
2784 let html = render_to_html(&view, &json!({}));
2785 assert!(html.contains(
2786 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text\">Subtitle</h2>"
2787 ));
2788 }
2789
2790 #[test]
2791 fn text_h3_variant() {
2792 let view = JsonUiView::new().component(text_node("t", "Section", TextElement::H3));
2793 let html = render_to_html(&view, &json!({}));
2794 assert!(html
2795 .contains("<h3 class=\"text-xl font-semibold leading-snug text-text\">Section</h3>"));
2796 }
2797
2798 #[test]
2799 fn text_span_variant() {
2800 let view = JsonUiView::new().component(text_node("t", "Inline", TextElement::Span));
2801 let html = render_to_html(&view, &json!({}));
2802 assert!(html.contains("<span class=\"text-base text-text\">Inline</span>"));
2803 }
2804
2805 #[test]
2808 fn button_default_variant() {
2809 let view = JsonUiView::new().component(button_node(
2810 "b",
2811 "Click",
2812 ButtonVariant::Default,
2813 Size::Default,
2814 ));
2815 let html = render_to_html(&view, &json!({}));
2816 assert!(html.contains("bg-primary text-primary-foreground hover:bg-primary/90"));
2817 assert!(html.contains(">Click</button>"));
2818 }
2819
2820 #[test]
2821 fn button_secondary_variant() {
2822 let view = JsonUiView::new().component(button_node(
2823 "b",
2824 "Click",
2825 ButtonVariant::Secondary,
2826 Size::Default,
2827 ));
2828 let html = render_to_html(&view, &json!({}));
2829 assert!(html.contains("bg-secondary text-secondary-foreground hover:bg-secondary/90"));
2830 }
2831
2832 #[test]
2833 fn button_destructive_variant() {
2834 let view = JsonUiView::new().component(button_node(
2835 "b",
2836 "Delete",
2837 ButtonVariant::Destructive,
2838 Size::Default,
2839 ));
2840 let html = render_to_html(&view, &json!({}));
2841 assert!(html.contains("bg-destructive text-primary-foreground hover:bg-destructive/90"));
2842 }
2843
2844 #[test]
2845 fn button_outline_variant() {
2846 let view = JsonUiView::new().component(button_node(
2847 "b",
2848 "Click",
2849 ButtonVariant::Outline,
2850 Size::Default,
2851 ));
2852 let html = render_to_html(&view, &json!({}));
2853 assert!(html.contains("border border-border bg-background text-text hover:bg-surface"));
2854 }
2855
2856 #[test]
2857 fn button_ghost_variant() {
2858 let view = JsonUiView::new().component(button_node(
2859 "b",
2860 "Click",
2861 ButtonVariant::Ghost,
2862 Size::Default,
2863 ));
2864 let html = render_to_html(&view, &json!({}));
2865 assert!(html.contains("text-text hover:bg-surface"));
2866 }
2867
2868 #[test]
2869 fn button_link_variant() {
2870 let view = JsonUiView::new().component(button_node(
2871 "b",
2872 "Click",
2873 ButtonVariant::Link,
2874 Size::Default,
2875 ));
2876 let html = render_to_html(&view, &json!({}));
2877 assert!(html.contains("text-primary underline hover:text-primary/80"));
2878 }
2879
2880 #[test]
2881 fn button_disabled_state() {
2882 let view = JsonUiView::new().component(ComponentNode {
2883 key: "b".to_string(),
2884 component: Component::Button(ButtonProps {
2885 label: "Disabled".to_string(),
2886 variant: ButtonVariant::Default,
2887 size: Size::Default,
2888 disabled: Some(true),
2889 icon: None,
2890 icon_position: None,
2891 button_type: None,
2892 }),
2893 action: None,
2894 visibility: None,
2895 });
2896 let html = render_to_html(&view, &json!({}));
2897 assert!(html.contains("opacity-50 cursor-not-allowed"));
2898 assert!(html.contains(" disabled"));
2899 }
2900
2901 #[test]
2902 fn button_with_icon_left() {
2903 let view = JsonUiView::new().component(ComponentNode {
2904 key: "b".to_string(),
2905 component: Component::Button(ButtonProps {
2906 label: "Save".to_string(),
2907 variant: ButtonVariant::Default,
2908 size: Size::Default,
2909 disabled: None,
2910 icon: Some("save".to_string()),
2911 icon_position: Some(IconPosition::Left),
2912 button_type: None,
2913 }),
2914 action: None,
2915 visibility: None,
2916 });
2917 let html = render_to_html(&view, &json!({}));
2918 assert!(html.contains("data-icon=\"save\""));
2919 let icon_pos = html.find("data-icon").unwrap();
2921 let label_pos = html.find("Save").unwrap();
2922 assert!(icon_pos < label_pos);
2923 }
2924
2925 #[test]
2926 fn button_with_icon_right() {
2927 let view = JsonUiView::new().component(ComponentNode {
2928 key: "b".to_string(),
2929 component: Component::Button(ButtonProps {
2930 label: "Next".to_string(),
2931 variant: ButtonVariant::Default,
2932 size: Size::Default,
2933 disabled: None,
2934 icon: Some("arrow-right".to_string()),
2935 icon_position: Some(IconPosition::Right),
2936 button_type: None,
2937 }),
2938 action: None,
2939 visibility: None,
2940 });
2941 let html = render_to_html(&view, &json!({}));
2942 assert!(html.contains("data-icon=\"arrow-right\""));
2943 let label_pos = html.find("Next").unwrap();
2945 let icon_pos = html.find("data-icon").unwrap();
2946 assert!(label_pos < icon_pos);
2947 }
2948
2949 #[test]
2952 fn button_size_xs() {
2953 let view =
2954 JsonUiView::new().component(button_node("b", "X", ButtonVariant::Default, Size::Xs));
2955 let html = render_to_html(&view, &json!({}));
2956 assert!(html.contains("px-2 py-1 text-xs"));
2957 }
2958
2959 #[test]
2960 fn button_size_sm() {
2961 let view =
2962 JsonUiView::new().component(button_node("b", "S", ButtonVariant::Default, Size::Sm));
2963 let html = render_to_html(&view, &json!({}));
2964 assert!(html.contains("px-3 py-1.5 text-sm"));
2965 }
2966
2967 #[test]
2968 fn button_size_default() {
2969 let view = JsonUiView::new().component(button_node(
2970 "b",
2971 "D",
2972 ButtonVariant::Default,
2973 Size::Default,
2974 ));
2975 let html = render_to_html(&view, &json!({}));
2976 assert!(html.contains("px-4 py-2 text-sm"));
2977 }
2978
2979 #[test]
2980 fn button_size_lg() {
2981 let view =
2982 JsonUiView::new().component(button_node("b", "L", ButtonVariant::Default, Size::Lg));
2983 let html = render_to_html(&view, &json!({}));
2984 assert!(html.contains("px-6 py-3 text-base"));
2985 }
2986
2987 #[test]
2990 fn badge_default_variant() {
2991 let view = JsonUiView::new().component(ComponentNode {
2992 key: "bg".to_string(),
2993 component: Component::Badge(BadgeProps {
2994 label: "New".to_string(),
2995 variant: BadgeVariant::Default,
2996 }),
2997 action: None,
2998 visibility: None,
2999 });
3000 let html = render_to_html(&view, &json!({}));
3001 assert!(html.contains("bg-primary/10 text-primary"));
3002 assert!(html.contains(">New</span>"));
3003 }
3004
3005 #[test]
3006 fn badge_secondary_variant() {
3007 let view = JsonUiView::new().component(ComponentNode {
3008 key: "bg".to_string(),
3009 component: Component::Badge(BadgeProps {
3010 label: "Draft".to_string(),
3011 variant: BadgeVariant::Secondary,
3012 }),
3013 action: None,
3014 visibility: None,
3015 });
3016 let html = render_to_html(&view, &json!({}));
3017 assert!(html.contains("bg-secondary/10 text-secondary-foreground"));
3018 }
3019
3020 #[test]
3021 fn badge_destructive_variant() {
3022 let view = JsonUiView::new().component(ComponentNode {
3023 key: "bg".to_string(),
3024 component: Component::Badge(BadgeProps {
3025 label: "Deleted".to_string(),
3026 variant: BadgeVariant::Destructive,
3027 }),
3028 action: None,
3029 visibility: None,
3030 });
3031 let html = render_to_html(&view, &json!({}));
3032 assert!(html.contains("bg-destructive/10 text-destructive"));
3033 }
3034
3035 #[test]
3036 fn badge_outline_variant() {
3037 let view = JsonUiView::new().component(ComponentNode {
3038 key: "bg".to_string(),
3039 component: Component::Badge(BadgeProps {
3040 label: "Info".to_string(),
3041 variant: BadgeVariant::Outline,
3042 }),
3043 action: None,
3044 visibility: None,
3045 });
3046 let html = render_to_html(&view, &json!({}));
3047 assert!(html.contains("border border-border text-text"));
3048 }
3049
3050 #[test]
3051 fn badge_has_base_classes() {
3052 let view = JsonUiView::new().component(ComponentNode {
3053 key: "bg".to_string(),
3054 component: Component::Badge(BadgeProps {
3055 label: "Test".to_string(),
3056 variant: BadgeVariant::Default,
3057 }),
3058 action: None,
3059 visibility: None,
3060 });
3061 let html = render_to_html(&view, &json!({}));
3062 assert!(html
3063 .contains("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"));
3064 }
3065
3066 #[test]
3069 fn alert_info_variant() {
3070 let view = JsonUiView::new().component(ComponentNode {
3071 key: "a".to_string(),
3072 component: Component::Alert(AlertProps {
3073 message: "Info message".to_string(),
3074 variant: AlertVariant::Info,
3075 title: None,
3076 }),
3077 action: None,
3078 visibility: None,
3079 });
3080 let html = render_to_html(&view, &json!({}));
3081 assert!(html.contains("bg-primary/10 border-primary text-primary"));
3082 assert!(html.contains("role=\"alert\""));
3083 assert!(html.contains("<p>Info message</p>"));
3084 }
3085
3086 #[test]
3087 fn alert_success_variant() {
3088 let view = JsonUiView::new().component(ComponentNode {
3089 key: "a".to_string(),
3090 component: Component::Alert(AlertProps {
3091 message: "Done".to_string(),
3092 variant: AlertVariant::Success,
3093 title: None,
3094 }),
3095 action: None,
3096 visibility: None,
3097 });
3098 let html = render_to_html(&view, &json!({}));
3099 assert!(html.contains("bg-success/10 border-success text-success"));
3100 }
3101
3102 #[test]
3103 fn alert_warning_variant() {
3104 let view = JsonUiView::new().component(ComponentNode {
3105 key: "a".to_string(),
3106 component: Component::Alert(AlertProps {
3107 message: "Careful".to_string(),
3108 variant: AlertVariant::Warning,
3109 title: None,
3110 }),
3111 action: None,
3112 visibility: None,
3113 });
3114 let html = render_to_html(&view, &json!({}));
3115 assert!(html.contains("bg-warning/10 border-warning text-warning"));
3116 }
3117
3118 #[test]
3119 fn alert_error_variant() {
3120 let view = JsonUiView::new().component(ComponentNode {
3121 key: "a".to_string(),
3122 component: Component::Alert(AlertProps {
3123 message: "Failed".to_string(),
3124 variant: AlertVariant::Error,
3125 title: None,
3126 }),
3127 action: None,
3128 visibility: None,
3129 });
3130 let html = render_to_html(&view, &json!({}));
3131 assert!(html.contains("bg-destructive/10 border-destructive text-destructive"));
3132 }
3133
3134 #[test]
3135 fn alert_with_title() {
3136 let view = JsonUiView::new().component(ComponentNode {
3137 key: "a".to_string(),
3138 component: Component::Alert(AlertProps {
3139 message: "Details here".to_string(),
3140 variant: AlertVariant::Warning,
3141 title: Some("Warning".to_string()),
3142 }),
3143 action: None,
3144 visibility: None,
3145 });
3146 let html = render_to_html(&view, &json!({}));
3147 assert!(html.contains("<h4 class=\"font-semibold mb-1\">Warning</h4>"));
3148 assert!(html.contains("<p>Details here</p>"));
3149 }
3150
3151 #[test]
3152 fn alert_without_title() {
3153 let view = JsonUiView::new().component(ComponentNode {
3154 key: "a".to_string(),
3155 component: Component::Alert(AlertProps {
3156 message: "No title".to_string(),
3157 variant: AlertVariant::Info,
3158 title: None,
3159 }),
3160 action: None,
3161 visibility: None,
3162 });
3163 let html = render_to_html(&view, &json!({}));
3164 assert!(!html.contains("<h4"));
3165 }
3166
3167 #[test]
3170 fn separator_horizontal() {
3171 let view = JsonUiView::new().component(ComponentNode {
3172 key: "s".to_string(),
3173 component: Component::Separator(SeparatorProps {
3174 orientation: Some(Orientation::Horizontal),
3175 }),
3176 action: None,
3177 visibility: None,
3178 });
3179 let html = render_to_html(&view, &json!({}));
3180 assert!(html.contains("<hr class=\"my-4 border-border\">"));
3181 }
3182
3183 #[test]
3184 fn separator_vertical() {
3185 let view = JsonUiView::new().component(ComponentNode {
3186 key: "s".to_string(),
3187 component: Component::Separator(SeparatorProps {
3188 orientation: Some(Orientation::Vertical),
3189 }),
3190 action: None,
3191 visibility: None,
3192 });
3193 let html = render_to_html(&view, &json!({}));
3194 assert!(html.contains("<div class=\"mx-4 h-full w-px bg-border\"></div>"));
3195 }
3196
3197 #[test]
3198 fn separator_default_is_horizontal() {
3199 let view = JsonUiView::new().component(ComponentNode {
3200 key: "s".to_string(),
3201 component: Component::Separator(SeparatorProps { orientation: None }),
3202 action: None,
3203 visibility: None,
3204 });
3205 let html = render_to_html(&view, &json!({}));
3206 assert!(html.contains("<hr"));
3207 }
3208
3209 #[test]
3212 fn progress_renders_bar() {
3213 let view = JsonUiView::new().component(ComponentNode {
3214 key: "p".to_string(),
3215 component: Component::Progress(ProgressProps {
3216 value: 50,
3217 max: None,
3218 label: None,
3219 }),
3220 action: None,
3221 visibility: None,
3222 });
3223 let html = render_to_html(&view, &json!({}));
3224 assert!(html.contains("style=\"width: 50%\""));
3225 assert!(html.contains("bg-primary h-2.5"));
3226 }
3227
3228 #[test]
3229 fn progress_with_label() {
3230 let view = JsonUiView::new().component(ComponentNode {
3231 key: "p".to_string(),
3232 component: Component::Progress(ProgressProps {
3233 value: 75,
3234 max: None,
3235 label: Some("Uploading...".to_string()),
3236 }),
3237 action: None,
3238 visibility: None,
3239 });
3240 let html = render_to_html(&view, &json!({}));
3241 assert!(html.contains("Uploading..."));
3242 assert!(html.contains("text-sm text-text-muted"));
3243 }
3244
3245 #[test]
3246 fn progress_with_custom_max() {
3247 let view = JsonUiView::new().component(ComponentNode {
3248 key: "p".to_string(),
3249 component: Component::Progress(ProgressProps {
3250 value: 25,
3251 max: Some(50),
3252 label: None,
3253 }),
3254 action: None,
3255 visibility: None,
3256 });
3257 let html = render_to_html(&view, &json!({}));
3258 assert!(html.contains("style=\"width: 50%\""));
3260 }
3261
3262 #[test]
3265 fn avatar_with_src() {
3266 let view = JsonUiView::new().component(ComponentNode {
3267 key: "av".to_string(),
3268 component: Component::Avatar(AvatarProps {
3269 src: Some("/img/user.jpg".to_string()),
3270 alt: "User".to_string(),
3271 fallback: None,
3272 size: None,
3273 }),
3274 action: None,
3275 visibility: None,
3276 });
3277 let html = render_to_html(&view, &json!({}));
3278 assert!(html.contains("<img"));
3279 assert!(html.contains("src=\"/img/user.jpg\""));
3280 assert!(html.contains("alt=\"User\""));
3281 assert!(html.contains("rounded-full object-cover"));
3282 }
3283
3284 #[test]
3285 fn avatar_without_src_uses_fallback() {
3286 let view = JsonUiView::new().component(ComponentNode {
3287 key: "av".to_string(),
3288 component: Component::Avatar(AvatarProps {
3289 src: None,
3290 alt: "John Doe".to_string(),
3291 fallback: Some("JD".to_string()),
3292 size: None,
3293 }),
3294 action: None,
3295 visibility: None,
3296 });
3297 let html = render_to_html(&view, &json!({}));
3298 assert!(!html.contains("<img"));
3299 assert!(html.contains("<span"));
3300 assert!(html.contains("bg-card text-text-muted"));
3301 assert!(html.contains(">JD</span>"));
3302 }
3303
3304 #[test]
3305 fn avatar_without_src_or_fallback_uses_alt_initials() {
3306 let view = JsonUiView::new().component(ComponentNode {
3307 key: "av".to_string(),
3308 component: Component::Avatar(AvatarProps {
3309 src: None,
3310 alt: "Alice".to_string(),
3311 fallback: None,
3312 size: Some(Size::Lg),
3313 }),
3314 action: None,
3315 visibility: None,
3316 });
3317 let html = render_to_html(&view, &json!({}));
3318 assert!(html.contains(">Al</span>"));
3319 assert!(html.contains("h-12 w-12 text-base"));
3320 }
3321
3322 #[test]
3325 fn image_with_aspect_ratio() {
3326 let view = JsonUiView::new().component(ComponentNode {
3327 key: "img".to_string(),
3328 component: Component::Image(ImageProps {
3329 src: "/img/page.png".to_string(),
3330 alt: "Page".to_string(),
3331 aspect_ratio: Some("16/9".to_string()),
3332 placeholder_label: None,
3333 }),
3334 action: None,
3335 visibility: None,
3336 });
3337 let html = render_to_html(&view, &json!({}));
3338 assert!(html.contains("<img"));
3339 assert!(html.contains("src=\"/img/page.png\""));
3340 assert!(html.contains("alt=\"Page\""));
3341 assert!(html.contains("w-full h-full rounded-md object-cover"));
3342 assert!(html.contains("style=\"aspect-ratio: 16/9\""));
3343 assert!(html.contains("loading=\"lazy\""));
3344 }
3345
3346 #[test]
3347 fn image_without_aspect_ratio_omits_style() {
3348 let view = JsonUiView::new().component(ComponentNode {
3349 key: "img".to_string(),
3350 component: Component::Image(ImageProps {
3351 src: "/img/page.png".to_string(),
3352 alt: "Page".to_string(),
3353 aspect_ratio: None,
3354 placeholder_label: None,
3355 }),
3356 action: None,
3357 visibility: None,
3358 });
3359 let html = render_to_html(&view, &json!({}));
3360 assert!(!html.contains("style="));
3361 assert!(html.contains("loading=\"lazy\""));
3362 }
3363
3364 #[test]
3365 fn image_xss_src_escaped() {
3366 let view = JsonUiView::new().component(ComponentNode {
3367 key: "img".to_string(),
3368 component: Component::Image(ImageProps {
3369 src: "x\" onerror=\"alert(1)".to_string(),
3370 alt: "Test".to_string(),
3371 aspect_ratio: None,
3372 placeholder_label: None,
3373 }),
3374 action: None,
3375 visibility: None,
3376 });
3377 let html = render_to_html(&view, &json!({}));
3378 assert!(html.contains("src=\"x" onerror="alert(1)\""));
3379 }
3380
3381 #[test]
3384 fn skeleton_default() {
3385 let view = JsonUiView::new().component(ComponentNode {
3386 key: "sk".to_string(),
3387 component: Component::Skeleton(SkeletonProps {
3388 width: None,
3389 height: None,
3390 rounded: None,
3391 }),
3392 action: None,
3393 visibility: None,
3394 });
3395 let html = render_to_html(&view, &json!({}));
3396 assert!(html.contains("ferro-shimmer"));
3397 assert!(html.contains("rounded-md"));
3398 assert!(html.contains("width: 100%"));
3399 assert!(html.contains("height: 1rem"));
3400 }
3401
3402 #[test]
3403 fn skeleton_custom_dimensions() {
3404 let view = JsonUiView::new().component(ComponentNode {
3405 key: "sk".to_string(),
3406 component: Component::Skeleton(SkeletonProps {
3407 width: Some("200px".to_string()),
3408 height: Some("40px".to_string()),
3409 rounded: Some(true),
3410 }),
3411 action: None,
3412 visibility: None,
3413 });
3414 let html = render_to_html(&view, &json!({}));
3415 assert!(html.contains("rounded-full"));
3416 assert!(html.contains("width: 200px"));
3417 assert!(html.contains("height: 40px"));
3418 }
3419
3420 #[test]
3423 fn breadcrumb_items_with_links() {
3424 let view = JsonUiView::new().component(ComponentNode {
3425 key: "bc".to_string(),
3426 component: Component::Breadcrumb(BreadcrumbProps {
3427 items: vec![
3428 BreadcrumbItem {
3429 label: "Home".to_string(),
3430 url: Some("/".to_string()),
3431 },
3432 BreadcrumbItem {
3433 label: "Users".to_string(),
3434 url: Some("/users".to_string()),
3435 },
3436 BreadcrumbItem {
3437 label: "Edit".to_string(),
3438 url: None,
3439 },
3440 ],
3441 }),
3442 action: None,
3443 visibility: None,
3444 });
3445 let html = render_to_html(&view, &json!({}));
3446 assert!(html.contains("<nav"));
3447 assert!(
3448 html.contains("<a href=\"/\""),
3449 "breadcrumb Home link should exist"
3450 );
3451 assert!(
3452 html.contains(">Home</a>"),
3453 "breadcrumb Home label should exist"
3454 );
3455 assert!(
3456 html.contains("<a href=\"/users\""),
3457 "breadcrumb Users link should exist"
3458 );
3459 assert!(
3460 html.contains(">Users</a>"),
3461 "breadcrumb Users label should exist"
3462 );
3463 assert!(html.contains("<span class=\"text-text font-medium\">Edit</span>"));
3465 assert!(html.contains("<svg"));
3467 }
3468
3469 #[test]
3470 fn breadcrumb_single_item() {
3471 let view = JsonUiView::new().component(ComponentNode {
3472 key: "bc".to_string(),
3473 component: Component::Breadcrumb(BreadcrumbProps {
3474 items: vec![BreadcrumbItem {
3475 label: "Home".to_string(),
3476 url: Some("/".to_string()),
3477 }],
3478 }),
3479 action: None,
3480 visibility: None,
3481 });
3482 let html = render_to_html(&view, &json!({}));
3483 assert!(html.contains("<span class=\"text-text font-medium\">Home</span>"));
3485 assert!(!html.contains("<span>/</span>"));
3487 }
3488
3489 #[test]
3492 fn pagination_renders_page_links() {
3493 let view = JsonUiView::new().component(ComponentNode {
3494 key: "pg".to_string(),
3495 component: Component::Pagination(PaginationProps {
3496 current_page: 2,
3497 per_page: 10,
3498 total: 50,
3499 base_url: None,
3500 }),
3501 action: None,
3502 visibility: None,
3503 });
3504 let html = render_to_html(&view, &json!({}));
3505 assert!(html.contains("<nav"));
3506 assert!(html.contains("bg-primary text-primary-foreground\">2</span>"));
3508 assert!(html.contains("?page=1"));
3510 assert!(html.contains("?page=3"));
3511 }
3512
3513 #[test]
3514 fn pagination_single_page_produces_no_output() {
3515 let view = JsonUiView::new().component(ComponentNode {
3516 key: "pg".to_string(),
3517 component: Component::Pagination(PaginationProps {
3518 current_page: 1,
3519 per_page: 10,
3520 total: 5,
3521 base_url: None,
3522 }),
3523 action: None,
3524 visibility: None,
3525 });
3526 let html = render_to_html(&view, &json!({}));
3527 assert!(!html.contains("<nav"));
3529 }
3530
3531 #[test]
3532 fn pagination_prev_and_next_buttons() {
3533 let view = JsonUiView::new().component(ComponentNode {
3534 key: "pg".to_string(),
3535 component: Component::Pagination(PaginationProps {
3536 current_page: 3,
3537 per_page: 10,
3538 total: 100,
3539 base_url: None,
3540 }),
3541 action: None,
3542 visibility: None,
3543 });
3544 let html = render_to_html(&view, &json!({}));
3545 assert!(html.contains("?page=2"));
3547 assert!(html.contains("?page=4"));
3549 }
3550
3551 #[test]
3552 fn pagination_no_prev_on_first_page() {
3553 let view = JsonUiView::new().component(ComponentNode {
3554 key: "pg".to_string(),
3555 component: Component::Pagination(PaginationProps {
3556 current_page: 1,
3557 per_page: 10,
3558 total: 30,
3559 base_url: None,
3560 }),
3561 action: None,
3562 visibility: None,
3563 });
3564 let html = render_to_html(&view, &json!({}));
3565 assert!(!html.contains("«"));
3567 assert!(html.contains("»"));
3569 }
3570
3571 #[test]
3572 fn pagination_custom_base_url() {
3573 let view = JsonUiView::new().component(ComponentNode {
3574 key: "pg".to_string(),
3575 component: Component::Pagination(PaginationProps {
3576 current_page: 1,
3577 per_page: 10,
3578 total: 30,
3579 base_url: Some("/users?sort=name&".to_string()),
3580 }),
3581 action: None,
3582 visibility: None,
3583 });
3584 let html = render_to_html(&view, &json!({}));
3585 assert!(html.contains("/users?sort=name&page=2"));
3586 }
3587
3588 #[test]
3591 fn description_list_renders_dl_dt_dd() {
3592 let view = JsonUiView::new().component(ComponentNode {
3593 key: "dl".to_string(),
3594 component: Component::DescriptionList(DescriptionListProps {
3595 items: vec![
3596 DescriptionItem {
3597 label: "Name".to_string(),
3598 value: "Alice".to_string(),
3599 format: None,
3600 },
3601 DescriptionItem {
3602 label: "Email".to_string(),
3603 value: "alice@example.com".to_string(),
3604 format: None,
3605 },
3606 ],
3607 columns: None,
3608 }),
3609 action: None,
3610 visibility: None,
3611 });
3612 let html = render_to_html(&view, &json!({}));
3613 assert!(html.contains("<dl"));
3614 assert!(html.contains("grid-cols-1"));
3615 assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Name</dt>"));
3616 assert!(html.contains("<dd class=\"mt-1 text-sm text-text\">Alice</dd>"));
3617 assert!(html.contains("<dt class=\"text-sm font-medium text-text-muted\">Email</dt>"));
3618 }
3619
3620 #[test]
3621 fn description_list_with_columns() {
3622 let view = JsonUiView::new().component(ComponentNode {
3623 key: "dl".to_string(),
3624 component: Component::DescriptionList(DescriptionListProps {
3625 items: vec![DescriptionItem {
3626 label: "Status".to_string(),
3627 value: "Active".to_string(),
3628 format: None,
3629 }],
3630 columns: Some(3),
3631 }),
3632 action: None,
3633 visibility: None,
3634 });
3635 let html = render_to_html(&view, &json!({}));
3636 assert!(html.contains("grid-cols-3"));
3637 }
3638
3639 #[test]
3642 fn xss_script_tags_escaped_in_text() {
3643 let view = JsonUiView::new().component(text_node(
3644 "t",
3645 "<script>alert('xss')</script>",
3646 TextElement::P,
3647 ));
3648 let html = render_to_html(&view, &json!({}));
3649 assert!(!html.contains("<script>"));
3650 assert!(html.contains("<script>"));
3651 assert!(html.contains("'"));
3652 }
3653
3654 #[test]
3655 fn xss_quotes_escaped_in_attributes() {
3656 let view = JsonUiView::new().component(ComponentNode {
3657 key: "av".to_string(),
3658 component: Component::Avatar(AvatarProps {
3659 src: Some("x\" onload=\"alert(1)".to_string()),
3660 alt: "Test".to_string(),
3661 fallback: None,
3662 size: None,
3663 }),
3664 action: None,
3665 visibility: None,
3666 });
3667 let html = render_to_html(&view, &json!({}));
3668 assert!(html.contains("""));
3670 assert!(html.contains("src=\"x" onload="alert(1)\""));
3672 }
3673
3674 #[test]
3675 fn xss_in_button_label() {
3676 let view = JsonUiView::new().component(ComponentNode {
3677 key: "b".to_string(),
3678 component: Component::Button(ButtonProps {
3679 label: "<img src=x onerror=alert(1)>".to_string(),
3680 variant: ButtonVariant::Default,
3681 size: Size::Default,
3682 disabled: None,
3683 icon: None,
3684 icon_position: None,
3685 button_type: None,
3686 }),
3687 action: None,
3688 visibility: None,
3689 });
3690 let html = render_to_html(&view, &json!({}));
3691 assert!(!html.contains("<img"));
3692 assert!(html.contains("<img"));
3693 }
3694
3695 #[test]
3696 fn xss_ampersand_in_content() {
3697 let view = JsonUiView::new().component(text_node("t", "Tom & Jerry", TextElement::P));
3698 let html = render_to_html(&view, &json!({}));
3699 assert!(html.contains("Tom & Jerry"));
3700 }
3701
3702 #[test]
3703 fn html_escape_function_covers_all_chars() {
3704 let result = html_escape("&<>\"'normal");
3705 assert_eq!(result, "&<>"'normal");
3706 }
3707
3708 #[test]
3711 fn get_action_wraps_in_anchor() {
3712 let view = JsonUiView::new().component(ComponentNode {
3713 key: "b".to_string(),
3714 component: Component::Button(ButtonProps {
3715 label: "View".to_string(),
3716 variant: ButtonVariant::Default,
3717 size: Size::Default,
3718 disabled: None,
3719 icon: None,
3720 icon_position: None,
3721 button_type: None,
3722 }),
3723 action: Some(make_action_with_url(
3724 "users.show",
3725 HttpMethod::Get,
3726 "/users/1",
3727 )),
3728 visibility: None,
3729 });
3730 let html = render_to_html(&view, &json!({}));
3731 assert!(html.contains("<a href=\"/users/1\" class=\"block\">"));
3732 assert!(html.contains("</a>"));
3733 assert!(html.contains("<button"));
3734 }
3735
3736 #[test]
3737 fn post_action_does_not_wrap_in_anchor() {
3738 let view = JsonUiView::new().component(ComponentNode {
3739 key: "b".to_string(),
3740 component: Component::Button(ButtonProps {
3741 label: "Submit".to_string(),
3742 variant: ButtonVariant::Default,
3743 size: Size::Default,
3744 disabled: None,
3745 icon: None,
3746 icon_position: None,
3747 button_type: None,
3748 }),
3749 action: Some(make_action_with_url(
3750 "users.store",
3751 HttpMethod::Post,
3752 "/users",
3753 )),
3754 visibility: None,
3755 });
3756 let html = render_to_html(&view, &json!({}));
3757 assert!(!html.contains("<a href="));
3758 assert!(html.contains("<button"));
3759 }
3760
3761 #[test]
3762 fn get_action_without_url_does_not_wrap() {
3763 let view = JsonUiView::new().component(ComponentNode {
3764 key: "b".to_string(),
3765 component: Component::Button(ButtonProps {
3766 label: "View".to_string(),
3767 variant: ButtonVariant::Default,
3768 size: Size::Default,
3769 disabled: None,
3770 icon: None,
3771 icon_position: None,
3772 button_type: None,
3773 }),
3774 action: Some(make_action("users.show", HttpMethod::Get)),
3775 visibility: None,
3776 });
3777 let html = render_to_html(&view, &json!({}));
3778 assert!(!html.contains("<a href="));
3779 }
3780
3781 #[test]
3782 fn delete_action_does_not_wrap_in_anchor() {
3783 let view = JsonUiView::new().component(ComponentNode {
3784 key: "b".to_string(),
3785 component: Component::Button(ButtonProps {
3786 label: "Delete".to_string(),
3787 variant: ButtonVariant::Destructive,
3788 size: Size::Default,
3789 disabled: None,
3790 icon: None,
3791 icon_position: None,
3792 button_type: None,
3793 }),
3794 action: Some(make_action_with_url(
3795 "users.destroy",
3796 HttpMethod::Delete,
3797 "/users/1",
3798 )),
3799 visibility: None,
3800 });
3801 let html = render_to_html(&view, &json!({}));
3802 assert!(!html.contains("<a href="));
3803 }
3804
3805 #[test]
3806 fn action_url_is_html_escaped() {
3807 let view = JsonUiView::new().component(ComponentNode {
3808 key: "b".to_string(),
3809 component: Component::Button(ButtonProps {
3810 label: "View".to_string(),
3811 variant: ButtonVariant::Default,
3812 size: Size::Default,
3813 disabled: None,
3814 icon: None,
3815 icon_position: None,
3816 button_type: None,
3817 }),
3818 action: Some(make_action_with_url(
3819 "users.show",
3820 HttpMethod::Get,
3821 "/users?id=1&name=test",
3822 )),
3823 visibility: None,
3824 });
3825 let html = render_to_html(&view, &json!({}));
3826 assert!(html.contains("href=\"/users?id=1&name=test\""));
3827 }
3828
3829 #[test]
3832 fn card_renders_title_and_description() {
3833 let view = JsonUiView::new().component(ComponentNode {
3834 key: "c".to_string(),
3835 component: Component::Card(CardProps {
3836 title: "My Card".to_string(),
3837 description: Some("A description".to_string()),
3838 children: vec![],
3839 footer: vec![],
3840 max_width: None,
3841 }),
3842 action: None,
3843 visibility: None,
3844 });
3845 let html = render_to_html(&view, &json!({}));
3846 assert!(html.contains("rounded-lg border border-border bg-card shadow-sm overflow-visible"));
3847 assert!(html
3848 .contains("<h3 class=\"text-base font-semibold leading-snug text-text\">My Card</h3>"));
3849 assert!(html.contains("<p class=\"mt-1 text-sm text-text-muted\">A description</p>"));
3850 }
3851
3852 #[test]
3853 fn card_renders_children_recursively() {
3854 let view = JsonUiView::new().component(ComponentNode {
3855 key: "c".to_string(),
3856 component: Component::Card(CardProps {
3857 title: "Card".to_string(),
3858 description: None,
3859 children: vec![text_node("t", "Child content", TextElement::P)],
3860 footer: vec![],
3861 max_width: None,
3862 }),
3863 action: None,
3864 visibility: None,
3865 });
3866 let html = render_to_html(&view, &json!({}));
3867 assert!(
3868 html.contains("mt-3 flex flex-wrap gap-3 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto overflow-visible")
3869 );
3870 assert!(html.contains("Child content"));
3871 }
3872
3873 #[test]
3874 fn card_renders_footer() {
3875 let view = JsonUiView::new().component(ComponentNode {
3876 key: "c".to_string(),
3877 component: Component::Card(CardProps {
3878 title: "Card".to_string(),
3879 description: None,
3880 children: vec![],
3881 max_width: None,
3882 footer: vec![button_node(
3883 "btn",
3884 "Save",
3885 ButtonVariant::Default,
3886 Size::Default,
3887 )],
3888 }),
3889 action: None,
3890 visibility: None,
3891 });
3892 let html = render_to_html(&view, &json!({}));
3893 assert!(html
3894 .contains("border-t border-border px-6 py-4 flex items-center justify-between gap-2"));
3895 assert!(html.contains(">Save</button>"));
3896 }
3897
3898 #[test]
3901 fn modal_renders_dialog_element() {
3902 let view = JsonUiView::new().component(ComponentNode {
3903 key: "m".to_string(),
3904 component: Component::Modal(ModalProps {
3905 id: "modal-confirm".to_string(),
3906 title: "Confirm".to_string(),
3907 description: Some("Are you sure?".to_string()),
3908 children: vec![text_node("t", "Body text", TextElement::P)],
3909 footer: vec![button_node(
3910 "ok",
3911 "OK",
3912 ButtonVariant::Default,
3913 Size::Default,
3914 )],
3915 trigger_label: Some("Open Modal".to_string()),
3916 }),
3917 action: None,
3918 visibility: None,
3919 });
3920 let html = render_to_html(&view, &json!({}));
3921 assert!(html.contains("<dialog"), "uses dialog element");
3922 assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
3923 assert!(
3924 html.contains("data-modal-open=\"modal-confirm\""),
3925 "trigger has data-modal-open"
3926 );
3927 assert!(html.contains("data-modal-close"), "has close button");
3928 assert!(html.contains("Confirm"), "shows title");
3929 assert!(html.contains("Are you sure?"), "shows description");
3930 assert!(html.contains("Body text"), "shows children");
3931 assert!(html.contains(">OK</button>"), "shows footer");
3932 assert!(!html.contains("<details"), "no details element");
3933 assert!(!html.contains("<summary"), "no summary element");
3934 }
3935
3936 #[test]
3937 fn modal_default_trigger_label() {
3938 let view = JsonUiView::new().component(ComponentNode {
3939 key: "m".to_string(),
3940 component: Component::Modal(ModalProps {
3941 id: "modal-dialog".to_string(),
3942 title: "Dialog".to_string(),
3943 description: None,
3944 children: vec![],
3945 footer: vec![],
3946 trigger_label: None,
3947 }),
3948 action: None,
3949 visibility: None,
3950 });
3951 let html = render_to_html(&view, &json!({}));
3952 assert!(html.contains("Open"), "default trigger label");
3953 assert!(html.contains("<dialog"), "uses dialog element");
3954 }
3955
3956 #[test]
3959 fn tabs_renders_only_default_tab_content() {
3960 let view = JsonUiView::new().component(ComponentNode {
3961 key: "tabs".to_string(),
3962 component: Component::Tabs(TabsProps {
3963 default_tab: "general".to_string(),
3964 tabs: vec![
3965 Tab {
3966 value: "general".to_string(),
3967 label: "General".to_string(),
3968 children: vec![text_node("t1", "General content", TextElement::P)],
3969 },
3970 Tab {
3971 value: "security".to_string(),
3972 label: "Security".to_string(),
3973 children: vec![text_node("t2", "Security content", TextElement::P)],
3974 },
3975 ],
3976 }),
3977 action: None,
3978 visibility: None,
3979 });
3980 let html = render_to_html(&view, &json!({}));
3981 assert!(html.contains("border-b-2 border-primary text-primary"));
3983 assert!(html.contains(">General</button>"));
3984 assert!(html.contains("border-transparent text-text-muted"));
3986 assert!(html.contains(">Security</button>"));
3987 assert!(html.contains("General content"));
3989 assert!(html.contains("Security content"));
3990 assert!(html.contains("data-tab-panel=\"general\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""));
3992 assert!(html.contains("data-tab-panel=\"security\" class=\"pt-4 flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto hidden\""));
3993 }
3994
3995 #[test]
3998 fn form_renders_action_url_and_method() {
3999 let view = JsonUiView::new().component(ComponentNode {
4000 key: "f".to_string(),
4001 component: Component::Form(FormProps {
4002 action: Action {
4003 handler: "users.store".to_string(),
4004 url: Some("/users".to_string()),
4005 method: HttpMethod::Post,
4006 confirm: None,
4007 on_success: None,
4008 on_error: None,
4009 target: None,
4010 },
4011 fields: vec![],
4012 method: None,
4013 guard: None,
4014 max_width: None,
4015 }),
4016 action: None,
4017 visibility: None,
4018 });
4019 let html = render_to_html(&view, &json!({}));
4020 assert!(html.contains("action=\"/users\""));
4021 assert!(html.contains("method=\"post\""));
4022 assert!(html.contains(
4023 "class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\""
4024 ));
4025 }
4026
4027 #[test]
4028 fn form_method_spoofing_for_delete() {
4029 let view = JsonUiView::new().component(ComponentNode {
4030 key: "f".to_string(),
4031 component: Component::Form(FormProps {
4032 action: Action {
4033 handler: "users.destroy".to_string(),
4034 url: Some("/users/1".to_string()),
4035 method: HttpMethod::Delete,
4036 confirm: None,
4037 on_success: None,
4038 on_error: None,
4039 target: None,
4040 },
4041 fields: vec![],
4042 method: None,
4043 guard: None,
4044 max_width: None,
4045 }),
4046 action: None,
4047 visibility: None,
4048 });
4049 let html = render_to_html(&view, &json!({}));
4050 assert!(html.contains("method=\"post\""));
4051 assert!(html.contains("<input type=\"hidden\" name=\"_method\" value=\"DELETE\">"));
4052 }
4053
4054 #[test]
4055 fn form_method_spoofing_for_put() {
4056 let view = JsonUiView::new().component(ComponentNode {
4057 key: "f".to_string(),
4058 component: Component::Form(FormProps {
4059 action: Action {
4060 handler: "users.update".to_string(),
4061 url: Some("/users/1".to_string()),
4062 method: HttpMethod::Put,
4063 confirm: None,
4064 on_success: None,
4065 on_error: None,
4066 target: None,
4067 },
4068 fields: vec![],
4069 method: Some(HttpMethod::Put),
4070 guard: None,
4071 max_width: None,
4072 }),
4073 action: None,
4074 visibility: None,
4075 });
4076 let html = render_to_html(&view, &json!({}));
4077 assert!(html.contains("method=\"post\""));
4078 assert!(html.contains("name=\"_method\" value=\"PUT\""));
4079 }
4080
4081 #[test]
4082 fn form_get_method_no_spoofing() {
4083 let view = JsonUiView::new().component(ComponentNode {
4084 key: "f".to_string(),
4085 component: Component::Form(FormProps {
4086 action: Action {
4087 handler: "users.index".to_string(),
4088 url: Some("/users".to_string()),
4089 method: HttpMethod::Get,
4090 confirm: None,
4091 on_success: None,
4092 on_error: None,
4093 target: None,
4094 },
4095 fields: vec![],
4096 method: None,
4097 guard: None,
4098 max_width: None,
4099 }),
4100 action: None,
4101 visibility: None,
4102 });
4103 let html = render_to_html(&view, &json!({}));
4104 assert!(html.contains("method=\"get\""));
4105 assert!(!html.contains("_method"));
4106 }
4107
4108 #[test]
4111 fn input_renders_label_and_field() {
4112 let view = JsonUiView::new().component(ComponentNode {
4113 key: "i".to_string(),
4114 component: Component::Input(InputProps {
4115 field: "email".to_string(),
4116 label: "Email".to_string(),
4117 input_type: InputType::Email,
4118 placeholder: Some("user@example.com".to_string()),
4119 required: Some(true),
4120 disabled: None,
4121 error: None,
4122 description: Some("Your work email".to_string()),
4123 default_value: None,
4124 data_path: None,
4125 step: None,
4126 list: None,
4127 }),
4128 action: None,
4129 visibility: None,
4130 });
4131 let html = render_to_html(&view, &json!({}));
4132 assert!(html.contains("for=\"email\""));
4133 assert!(html.contains(">Email</label>"));
4134 assert!(html.contains("Your work email"));
4135 assert!(html.contains("type=\"email\""));
4136 assert!(html.contains("id=\"email\""));
4137 assert!(html.contains("name=\"email\""));
4138 assert!(html.contains("placeholder=\"user@example.com\""));
4139 assert!(html.contains(" required"));
4140 assert!(html.contains("border-border"));
4141 }
4142
4143 #[test]
4144 fn input_renders_error_with_red_border() {
4145 let view = JsonUiView::new().component(ComponentNode {
4146 key: "i".to_string(),
4147 component: Component::Input(InputProps {
4148 field: "name".to_string(),
4149 label: "Name".to_string(),
4150 input_type: InputType::Text,
4151 placeholder: None,
4152 required: None,
4153 disabled: None,
4154 error: Some("Name is required".to_string()),
4155 description: None,
4156 default_value: None,
4157 data_path: None,
4158 step: None,
4159 list: None,
4160 }),
4161 action: None,
4162 visibility: None,
4163 });
4164 let html = render_to_html(&view, &json!({}));
4165 assert!(html.contains("border-destructive"));
4166 assert!(html.contains("text-destructive") && html.contains("Name is required"));
4167 assert!(
4168 html.contains("ring-destructive"),
4169 "error input should have destructive ring"
4170 );
4171 }
4172
4173 #[test]
4174 fn input_resolves_data_path_for_value() {
4175 let data = json!({"user": {"name": "Alice"}});
4176 let view = JsonUiView::new().component(ComponentNode {
4177 key: "i".to_string(),
4178 component: Component::Input(InputProps {
4179 field: "name".to_string(),
4180 label: "Name".to_string(),
4181 input_type: InputType::Text,
4182 placeholder: None,
4183 required: None,
4184 disabled: None,
4185 error: None,
4186 description: None,
4187 default_value: None,
4188 data_path: Some("/user/name".to_string()),
4189 step: None,
4190 list: None,
4191 }),
4192 action: None,
4193 visibility: None,
4194 });
4195 let html = render_to_html(&view, &data);
4196 assert!(html.contains("value=\"Alice\""));
4197 }
4198
4199 #[test]
4200 fn input_default_value_overrides_data_path() {
4201 let data = json!({"user": {"name": "Alice"}});
4202 let view = JsonUiView::new().component(ComponentNode {
4203 key: "i".to_string(),
4204 component: Component::Input(InputProps {
4205 field: "name".to_string(),
4206 label: "Name".to_string(),
4207 input_type: InputType::Text,
4208 placeholder: None,
4209 required: None,
4210 disabled: None,
4211 error: None,
4212 description: None,
4213 default_value: Some("Bob".to_string()),
4214 data_path: Some("/user/name".to_string()),
4215 step: None,
4216 list: None,
4217 }),
4218 action: None,
4219 visibility: None,
4220 });
4221 let html = render_to_html(&view, &data);
4222 assert!(html.contains("value=\"Bob\""));
4223 assert!(!html.contains("Alice"));
4224 }
4225
4226 #[test]
4227 fn input_textarea_renders_textarea_element() {
4228 let view = JsonUiView::new().component(ComponentNode {
4229 key: "i".to_string(),
4230 component: Component::Input(InputProps {
4231 field: "bio".to_string(),
4232 label: "Bio".to_string(),
4233 input_type: InputType::Textarea,
4234 placeholder: Some("Tell us about yourself".to_string()),
4235 required: None,
4236 disabled: None,
4237 error: None,
4238 description: None,
4239 default_value: Some("Hello world".to_string()),
4240 data_path: None,
4241 step: None,
4242 list: None,
4243 }),
4244 action: None,
4245 visibility: None,
4246 });
4247 let html = render_to_html(&view, &json!({}));
4248 assert!(html.contains("<textarea"));
4249 assert!(html.contains(">Hello world</textarea>"));
4250 assert!(html.contains("placeholder=\"Tell us about yourself\""));
4251 }
4252
4253 #[test]
4254 fn input_hidden_renders_hidden_field() {
4255 let view = JsonUiView::new().component(ComponentNode {
4256 key: "i".to_string(),
4257 component: Component::Input(InputProps {
4258 field: "token".to_string(),
4259 label: "Token".to_string(),
4260 input_type: InputType::Hidden,
4261 placeholder: None,
4262 required: None,
4263 disabled: None,
4264 error: None,
4265 description: None,
4266 default_value: Some("abc123".to_string()),
4267 data_path: None,
4268 step: None,
4269 list: None,
4270 }),
4271 action: None,
4272 visibility: None,
4273 });
4274 let html = render_to_html(&view, &json!({}));
4275 assert!(html.contains("type=\"hidden\""));
4276 assert!(html.contains("value=\"abc123\""));
4277 }
4278
4279 #[test]
4280 fn input_renders_datalist() {
4281 let props = InputProps {
4282 field: "category".to_string(),
4283 label: "Category".to_string(),
4284 input_type: InputType::Text,
4285 placeholder: None,
4286 required: None,
4287 disabled: None,
4288 error: None,
4289 description: None,
4290 default_value: None,
4291 data_path: None,
4292 step: None,
4293 list: Some("cat-suggestions".to_string()),
4294 };
4295 let data = serde_json::json!({
4296 "cat-suggestions": ["Pizza", "Pasta", "Bevande"]
4297 });
4298 let html = render_input(&props, &data);
4299 assert!(
4300 html.contains("list=\"cat-suggestions\""),
4301 "input should have list attribute"
4302 );
4303 assert!(
4304 html.contains("<datalist id=\"cat-suggestions\">"),
4305 "should render datalist element"
4306 );
4307 assert!(
4308 html.contains("<option value=\"Pizza\">"),
4309 "should render option for Pizza"
4310 );
4311 assert!(
4312 html.contains("<option value=\"Pasta\">"),
4313 "should render option for Pasta"
4314 );
4315 assert!(
4316 html.contains("<option value=\"Bevande\">"),
4317 "should render option for Bevande"
4318 );
4319 assert!(html.contains("</datalist>"), "should close datalist");
4320 }
4321
4322 #[test]
4323 fn input_no_datalist_without_data() {
4324 let props = InputProps {
4325 field: "category".to_string(),
4326 label: "Category".to_string(),
4327 input_type: InputType::Text,
4328 placeholder: None,
4329 required: None,
4330 disabled: None,
4331 error: None,
4332 description: None,
4333 default_value: None,
4334 data_path: None,
4335 step: None,
4336 list: Some("missing-key".to_string()),
4337 };
4338 let data = serde_json::json!({});
4339 let html = render_input(&props, &data);
4340 assert!(
4341 html.contains("list=\"missing-key\""),
4342 "input should still have list attribute"
4343 );
4344 assert!(
4345 !html.contains("<datalist"),
4346 "should NOT render datalist when data key missing"
4347 );
4348 }
4349
4350 #[test]
4353 fn select_renders_options_with_selected() {
4354 let view = JsonUiView::new().component(ComponentNode {
4355 key: "s".to_string(),
4356 component: Component::Select(SelectProps {
4357 field: "role".to_string(),
4358 label: "Role".to_string(),
4359 options: vec![
4360 SelectOption {
4361 value: "admin".to_string(),
4362 label: "Admin".to_string(),
4363 },
4364 SelectOption {
4365 value: "user".to_string(),
4366 label: "User".to_string(),
4367 },
4368 ],
4369 placeholder: Some("Select a role".to_string()),
4370 required: Some(true),
4371 disabled: None,
4372 error: None,
4373 description: None,
4374 default_value: Some("admin".to_string()),
4375 data_path: None,
4376 }),
4377 action: None,
4378 visibility: None,
4379 });
4380 let html = render_to_html(&view, &json!({}));
4381 assert!(html.contains("for=\"role\""));
4382 assert!(html.contains("id=\"role\""));
4383 assert!(html.contains("name=\"role\""));
4384 assert!(html.contains("<option value=\"\">Select a role</option>"));
4385 assert!(html.contains("<option value=\"admin\" selected>Admin</option>"));
4386 assert!(html.contains("<option value=\"user\">User</option>"));
4387 assert!(html.contains(" required"));
4388 }
4389
4390 #[test]
4391 fn select_resolves_data_path_for_selected() {
4392 let data = json!({"user": {"role": "user"}});
4393 let view = JsonUiView::new().component(ComponentNode {
4394 key: "s".to_string(),
4395 component: Component::Select(SelectProps {
4396 field: "role".to_string(),
4397 label: "Role".to_string(),
4398 options: vec![
4399 SelectOption {
4400 value: "admin".to_string(),
4401 label: "Admin".to_string(),
4402 },
4403 SelectOption {
4404 value: "user".to_string(),
4405 label: "User".to_string(),
4406 },
4407 ],
4408 placeholder: None,
4409 required: None,
4410 disabled: None,
4411 error: None,
4412 description: None,
4413 default_value: None,
4414 data_path: Some("/user/role".to_string()),
4415 }),
4416 action: None,
4417 visibility: None,
4418 });
4419 let html = render_to_html(&view, &data);
4420 assert!(html.contains("<option value=\"user\" selected>User</option>"));
4421 assert!(!html.contains("<option value=\"admin\" selected>"));
4422 }
4423
4424 #[test]
4425 fn select_renders_error() {
4426 let view = JsonUiView::new().component(ComponentNode {
4427 key: "s".to_string(),
4428 component: Component::Select(SelectProps {
4429 field: "role".to_string(),
4430 label: "Role".to_string(),
4431 options: vec![],
4432 placeholder: None,
4433 required: None,
4434 disabled: None,
4435 error: Some("Role is required".to_string()),
4436 description: None,
4437 default_value: None,
4438 data_path: None,
4439 }),
4440 action: None,
4441 visibility: None,
4442 });
4443 let html = render_to_html(&view, &json!({}));
4444 assert!(html.contains("border-destructive"));
4445 assert!(html.contains("Role is required"));
4446 assert!(
4447 html.contains("ring-destructive"),
4448 "error select should have destructive ring"
4449 );
4450 }
4451
4452 #[test]
4455 fn checkbox_renders_checked_state() {
4456 let view = JsonUiView::new().component(ComponentNode {
4457 key: "cb".to_string(),
4458 component: Component::Checkbox(CheckboxProps {
4459 field: "terms".to_string(),
4460 value: None,
4461 label: "Accept Terms".to_string(),
4462 description: Some("You must accept".to_string()),
4463 checked: Some(true),
4464 data_path: None,
4465 required: Some(true),
4466 disabled: None,
4467 error: None,
4468 }),
4469 action: None,
4470 visibility: None,
4471 });
4472 let html = render_to_html(&view, &json!({}));
4473 assert!(html.contains("type=\"checkbox\""));
4474 assert!(html.contains("id=\"terms\""));
4475 assert!(html.contains("name=\"terms\""));
4476 assert!(html.contains("value=\"1\""));
4477 assert!(html.contains(" checked"));
4478 assert!(html.contains(" required"));
4479 assert!(html.contains("for=\"terms\""));
4480 assert!(html.contains(">Accept Terms</label>"));
4481 assert!(html.contains("ml-6 text-sm text-text-muted"));
4482 assert!(html.contains("You must accept"));
4483 }
4484
4485 #[test]
4486 fn checkbox_resolves_data_path_for_checked() {
4487 let data = json!({"user": {"accepted": true}});
4488 let view = JsonUiView::new().component(ComponentNode {
4489 key: "cb".to_string(),
4490 component: Component::Checkbox(CheckboxProps {
4491 field: "accepted".to_string(),
4492 value: None,
4493 label: "Accepted".to_string(),
4494 description: None,
4495 checked: None,
4496 data_path: Some("/user/accepted".to_string()),
4497 required: None,
4498 disabled: None,
4499 error: None,
4500 }),
4501 action: None,
4502 visibility: None,
4503 });
4504 let html = render_to_html(&view, &data);
4505 assert!(html.contains(" checked"));
4506 }
4507
4508 #[test]
4509 fn checkbox_renders_error() {
4510 let view = JsonUiView::new().component(ComponentNode {
4511 key: "cb".to_string(),
4512 component: Component::Checkbox(CheckboxProps {
4513 field: "terms".to_string(),
4514 value: None,
4515 label: "Terms".to_string(),
4516 description: None,
4517 checked: None,
4518 data_path: None,
4519 required: None,
4520 disabled: None,
4521 error: Some("Must accept".to_string()),
4522 }),
4523 action: None,
4524 visibility: None,
4525 });
4526 let html = render_to_html(&view, &json!({}));
4527 assert!(html.contains("ml-6 text-sm text-destructive"));
4528 assert!(html.contains("Must accept"));
4529 }
4530
4531 #[test]
4534 fn switch_renders_toggle_structure() {
4535 let view = JsonUiView::new().component(ComponentNode {
4536 key: "sw".to_string(),
4537 component: Component::Switch(SwitchProps {
4538 field: "notifications".to_string(),
4539 label: "Notifications".to_string(),
4540 description: Some("Get email updates".to_string()),
4541 checked: Some(true),
4542 data_path: None,
4543 required: None,
4544 disabled: None,
4545 error: None,
4546 action: None,
4547 }),
4548 action: None,
4549 visibility: None,
4550 });
4551 let html = render_to_html(&view, &json!({}));
4552 assert!(html.contains("sr-only peer"));
4553 assert!(html.contains("id=\"notifications\""));
4554 assert!(html.contains("name=\"notifications\""));
4555 assert!(html.contains("value=\"1\""));
4556 assert!(html.contains(" checked"));
4557 assert!(html.contains("peer-checked:bg-primary"));
4558 assert!(html.contains("for=\"notifications\""));
4559 assert!(html.contains(">Notifications</label>"));
4560 assert!(html.contains("Get email updates"));
4561 }
4562
4563 #[test]
4564 fn switch_renders_error() {
4565 let view = JsonUiView::new().component(ComponentNode {
4566 key: "sw".to_string(),
4567 component: Component::Switch(SwitchProps {
4568 field: "agree".to_string(),
4569 label: "Agree".to_string(),
4570 description: None,
4571 checked: None,
4572 data_path: None,
4573 required: None,
4574 disabled: None,
4575 error: Some("Required".to_string()),
4576 action: None,
4577 }),
4578 action: None,
4579 visibility: None,
4580 });
4581 let html = render_to_html(&view, &json!({}));
4582 assert!(html.contains("text-sm text-destructive"));
4583 assert!(html.contains("Required"));
4584 }
4585
4586 #[test]
4589 fn table_renders_headers_and_data_rows() {
4590 let data = json!({
4591 "users": [
4592 {"name": "Alice", "email": "alice@example.com"},
4593 {"name": "Bob", "email": "bob@example.com"}
4594 ]
4595 });
4596 let view = JsonUiView::new().component(ComponentNode {
4597 key: "t".to_string(),
4598 component: Component::Table(TableProps {
4599 columns: vec![
4600 Column {
4601 key: "name".to_string(),
4602 label: "Name".to_string(),
4603 format: None,
4604 },
4605 Column {
4606 key: "email".to_string(),
4607 label: "Email".to_string(),
4608 format: None,
4609 },
4610 ],
4611 data_path: "/users".to_string(),
4612 row_actions: None,
4613 empty_message: Some("No users".to_string()),
4614 sortable: None,
4615 sort_column: None,
4616 sort_direction: None,
4617 }),
4618 action: None,
4619 visibility: None,
4620 });
4621 let html = render_to_html(&view, &data);
4622 assert!(html.contains("tracking-wider text-text-muted\">Name</th>"));
4624 assert!(html.contains("tracking-wider text-text-muted\">Email</th>"));
4625 assert!(html.contains(">Alice</td>"));
4627 assert!(html.contains(">alice@example.com</td>"));
4628 assert!(html.contains(">Bob</td>"));
4629 assert!(html.contains(">bob@example.com</td>"));
4630 assert!(html.contains("overflow-x-auto"));
4632 }
4633
4634 #[test]
4635 fn table_renders_empty_message() {
4636 let data = json!({"users": []});
4637 let view = JsonUiView::new().component(ComponentNode {
4638 key: "t".to_string(),
4639 component: Component::Table(TableProps {
4640 columns: vec![Column {
4641 key: "name".to_string(),
4642 label: "Name".to_string(),
4643 format: None,
4644 }],
4645 data_path: "/users".to_string(),
4646 row_actions: None,
4647 empty_message: Some("No users found".to_string()),
4648 sortable: None,
4649 sort_column: None,
4650 sort_direction: None,
4651 }),
4652 action: None,
4653 visibility: None,
4654 });
4655 let html = render_to_html(&view, &data);
4656 assert!(html.contains("No users found"));
4657 assert!(html.contains("text-center text-sm text-text-muted"));
4658 }
4659
4660 #[test]
4661 fn table_renders_empty_message_when_path_missing() {
4662 let data = json!({});
4663 let view = JsonUiView::new().component(ComponentNode {
4664 key: "t".to_string(),
4665 component: Component::Table(TableProps {
4666 columns: vec![Column {
4667 key: "name".to_string(),
4668 label: "Name".to_string(),
4669 format: None,
4670 }],
4671 data_path: "/users".to_string(),
4672 row_actions: None,
4673 empty_message: Some("No data".to_string()),
4674 sortable: None,
4675 sort_column: None,
4676 sort_direction: None,
4677 }),
4678 action: None,
4679 visibility: None,
4680 });
4681 let html = render_to_html(&view, &data);
4682 assert!(html.contains("No data"));
4683 }
4684
4685 #[test]
4686 fn table_renders_row_actions() {
4687 let data = json!({"items": [{"name": "Item 1"}]});
4688 let view = JsonUiView::new().component(ComponentNode {
4689 key: "t".to_string(),
4690 component: Component::Table(TableProps {
4691 columns: vec![Column {
4692 key: "name".to_string(),
4693 label: "Name".to_string(),
4694 format: None,
4695 }],
4696 data_path: "/items".to_string(),
4697 row_actions: Some(vec![
4698 make_action_with_url("items.edit", HttpMethod::Get, "/items/1/edit"),
4699 make_action_with_url("items.destroy", HttpMethod::Delete, "/items/1"),
4700 ]),
4701 empty_message: None,
4702 sortable: None,
4703 sort_column: None,
4704 sort_direction: None,
4705 }),
4706 action: None,
4707 visibility: None,
4708 });
4709 let html = render_to_html(&view, &data);
4710 assert!(html.contains(">Azioni</th>"));
4712 assert!(html.contains("href=\"/items/1/edit\""));
4714 assert!(html.contains(">edit</a>"));
4715 assert!(html.contains("href=\"/items/1\""));
4716 assert!(html.contains(">destroy</a>"));
4717 }
4718
4719 #[test]
4720 fn table_handles_numeric_and_bool_cells() {
4721 let data = json!({"rows": [{"count": 42, "active": true}]});
4722 let view = JsonUiView::new().component(ComponentNode {
4723 key: "t".to_string(),
4724 component: Component::Table(TableProps {
4725 columns: vec![
4726 Column {
4727 key: "count".to_string(),
4728 label: "Count".to_string(),
4729 format: None,
4730 },
4731 Column {
4732 key: "active".to_string(),
4733 label: "Active".to_string(),
4734 format: None,
4735 },
4736 ],
4737 data_path: "/rows".to_string(),
4738 row_actions: None,
4739 empty_message: None,
4740 sortable: None,
4741 sort_column: None,
4742 sort_direction: None,
4743 }),
4744 action: None,
4745 visibility: None,
4746 });
4747 let html = render_to_html(&view, &data);
4748 assert!(html.contains(">42</td>"));
4749 assert!(html.contains(">true</td>"));
4750 }
4751
4752 #[test]
4755 fn plugin_renders_error_div_when_not_registered() {
4756 let view = JsonUiView::new().component(ComponentNode {
4757 key: "map-1".to_string(),
4758 component: Component::Plugin(PluginProps {
4759 plugin_type: "UnknownPluginXyz".to_string(),
4760 props: json!({"lat": 0}),
4761 }),
4762 action: None,
4763 visibility: None,
4764 });
4765 let html = render_to_html(&view, &json!({}));
4766 assert!(html.contains("Unknown plugin component: UnknownPluginXyz"));
4767 assert!(html.contains("bg-destructive/10"));
4768 }
4769
4770 #[test]
4771 fn collect_plugin_types_finds_top_level_plugins() {
4772 let view = JsonUiView::new()
4773 .component(ComponentNode {
4774 key: "map".to_string(),
4775 component: Component::Plugin(PluginProps {
4776 plugin_type: "Map".to_string(),
4777 props: json!({}),
4778 }),
4779 action: None,
4780 visibility: None,
4781 })
4782 .component(ComponentNode {
4783 key: "text".to_string(),
4784 component: Component::Text(TextProps {
4785 content: "Hello".to_string(),
4786 element: TextElement::P,
4787 }),
4788 action: None,
4789 visibility: None,
4790 });
4791 let types = collect_plugin_types(&view);
4792 assert_eq!(types.len(), 1);
4793 assert!(types.contains("Map"));
4794 }
4795
4796 #[test]
4797 fn collect_plugin_types_finds_nested_in_card() {
4798 let view = JsonUiView::new().component(ComponentNode {
4799 key: "card".to_string(),
4800 component: Component::Card(CardProps {
4801 title: "Test".to_string(),
4802 description: None,
4803 children: vec![ComponentNode {
4804 key: "chart".to_string(),
4805 component: Component::Plugin(PluginProps {
4806 plugin_type: "Chart".to_string(),
4807 props: json!({}),
4808 }),
4809 action: None,
4810 visibility: None,
4811 }],
4812 footer: vec![],
4813 max_width: None,
4814 }),
4815 action: None,
4816 visibility: None,
4817 });
4818 let types = collect_plugin_types(&view);
4819 assert!(types.contains("Chart"));
4820 }
4821
4822 #[test]
4823 fn collect_plugin_types_deduplicates() {
4824 let view = JsonUiView::new()
4825 .component(ComponentNode {
4826 key: "map1".to_string(),
4827 component: Component::Plugin(PluginProps {
4828 plugin_type: "Map".to_string(),
4829 props: json!({}),
4830 }),
4831 action: None,
4832 visibility: None,
4833 })
4834 .component(ComponentNode {
4835 key: "map2".to_string(),
4836 component: Component::Plugin(PluginProps {
4837 plugin_type: "Map".to_string(),
4838 props: json!({"zoom": 5}),
4839 }),
4840 action: None,
4841 visibility: None,
4842 });
4843 let types = collect_plugin_types(&view);
4844 assert_eq!(types.len(), 1);
4845 }
4846
4847 #[test]
4848 fn collect_plugin_types_empty_for_builtin_only() {
4849 let view = JsonUiView::new().component(ComponentNode {
4850 key: "text".to_string(),
4851 component: Component::Text(TextProps {
4852 content: "Hello".to_string(),
4853 element: TextElement::P,
4854 }),
4855 action: None,
4856 visibility: None,
4857 });
4858 let types = collect_plugin_types(&view);
4859 assert!(types.is_empty());
4860 }
4861
4862 #[test]
4863 fn render_to_html_with_plugins_returns_empty_assets_for_builtin_only() {
4864 let view = JsonUiView::new().component(ComponentNode {
4865 key: "text".to_string(),
4866 component: Component::Text(TextProps {
4867 content: "Hello".to_string(),
4868 element: TextElement::P,
4869 }),
4870 action: None,
4871 visibility: None,
4872 });
4873 let result = render_to_html_with_plugins(&view, &json!({}));
4874 assert!(result.css_head.is_empty());
4875 assert!(result.scripts.is_empty());
4876 assert!(result.html.contains("Hello"));
4877 }
4878
4879 #[test]
4880 fn render_css_tags_generates_link_elements() {
4881 let assets = vec![Asset::new("https://cdn.example.com/style.css")
4882 .integrity("sha256-abc")
4883 .crossorigin("")];
4884 let tags = render_css_tags(&assets);
4885 assert!(tags.contains("rel=\"stylesheet\""));
4886 assert!(tags.contains("href=\"https://cdn.example.com/style.css\""));
4887 assert!(tags.contains("integrity=\"sha256-abc\""));
4888 assert!(tags.contains("crossorigin=\"\""));
4889 }
4890
4891 #[test]
4892 fn render_js_tags_generates_script_elements() {
4893 let assets = vec![Asset::new("https://cdn.example.com/lib.js")];
4894 let init = vec!["initLib();".to_string()];
4895 let tags = render_js_tags(&assets, &init);
4896 assert!(tags.contains("src=\"https://cdn.example.com/lib.js\""));
4897 assert!(tags.contains("<script>initLib();</script>"));
4898 }
4899
4900 #[test]
4903 fn stat_card_renders_label_and_value() {
4904 let view = JsonUiView::new().component(ComponentNode::stat_card(
4905 "rev",
4906 StatCardProps {
4907 label: "Revenue".to_string(),
4908 value: "$1,234".to_string(),
4909 icon: None,
4910 subtitle: None,
4911 sse_target: None,
4912 },
4913 ));
4914 let html = render_to_html(&view, &json!({}));
4915 assert!(html.contains("Revenue"));
4916 assert!(html.contains("$1,234"));
4917 assert!(html.contains("bg-card rounded-lg shadow-sm"));
4918 }
4919
4920 #[test]
4921 fn stat_card_renders_icon_and_subtitle() {
4922 let view = JsonUiView::new().component(ComponentNode::stat_card(
4923 "users",
4924 StatCardProps {
4925 label: "Users".to_string(),
4926 value: "42".to_string(),
4927 icon: Some("👤".to_string()),
4928 subtitle: Some("active today".to_string()),
4929 sse_target: None,
4930 },
4931 ));
4932 let html = render_to_html(&view, &json!({}));
4933 assert!(html.contains("👤"));
4934 assert!(html.contains("active today"));
4935 }
4936
4937 #[test]
4938 fn stat_card_renders_svg_icon_without_escaping() {
4939 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>"#;
4940 let view = JsonUiView::new().component(ComponentNode::stat_card(
4941 "svg-icon",
4942 StatCardProps {
4943 label: "Test".to_string(),
4944 value: "0".to_string(),
4945 icon: Some(svg.to_string()),
4946 subtitle: None,
4947 sse_target: None,
4948 },
4949 ));
4950 let html = render_to_html(&view, &json!({}));
4951 assert!(
4952 html.contains("<svg"),
4953 "SVG should render as markup, not escaped text"
4954 );
4955 assert!(!html.contains("<svg"), "SVG should NOT be HTML-escaped");
4956 }
4957
4958 #[test]
4959 fn stat_card_renders_sse_target_data_attributes() {
4960 let view = JsonUiView::new().component(ComponentNode::stat_card(
4961 "live",
4962 StatCardProps {
4963 label: "Live count".to_string(),
4964 value: "100".to_string(),
4965 icon: None,
4966 subtitle: None,
4967 sse_target: Some("visitor_count".to_string()),
4968 },
4969 ));
4970 let html = render_to_html(&view, &json!({}));
4971 assert!(html.contains("data-sse-target=\"visitor_count\""));
4972 assert!(html.contains("data-live-value"));
4973 }
4974
4975 #[test]
4976 fn stat_card_no_sse_target_omits_data_attributes() {
4977 let view = JsonUiView::new().component(ComponentNode::stat_card(
4978 "static",
4979 StatCardProps {
4980 label: "Label".to_string(),
4981 value: "99".to_string(),
4982 icon: None,
4983 subtitle: None,
4984 sse_target: None,
4985 },
4986 ));
4987 let html = render_to_html(&view, &json!({}));
4988 assert!(!html.contains("data-sse-target"));
4989 assert!(!html.contains("data-live-value"));
4990 }
4991
4992 #[test]
4995 fn checklist_renders_title_and_items() {
4996 let view = JsonUiView::new().component(ComponentNode::checklist(
4997 "tasks",
4998 ChecklistProps {
4999 title: "Setup Tasks".to_string(),
5000 items: vec![
5001 ChecklistItem {
5002 label: "Create account".to_string(),
5003 checked: true,
5004 href: None,
5005 },
5006 ChecklistItem {
5007 label: "Add team member".to_string(),
5008 checked: false,
5009 href: None,
5010 },
5011 ],
5012 dismissible: true,
5013 dismiss_label: None,
5014 data_key: None,
5015 },
5016 ));
5017 let html = render_to_html(&view, &json!({}));
5018 assert!(html.contains("Setup Tasks"));
5019 assert!(html.contains("Create account"));
5020 assert!(html.contains("Add team member"));
5021 }
5022
5023 #[test]
5024 fn checklist_checked_item_has_strikethrough() {
5025 let view = JsonUiView::new().component(ComponentNode::checklist(
5026 "tasks",
5027 ChecklistProps {
5028 title: "Tasks".to_string(),
5029 items: vec![ChecklistItem {
5030 label: "Done".to_string(),
5031 checked: true,
5032 href: None,
5033 }],
5034 dismissible: false,
5035 dismiss_label: None,
5036 data_key: None,
5037 },
5038 ));
5039 let html = render_to_html(&view, &json!({}));
5040 assert!(html.contains("line-through"));
5041 assert!(html.contains("checked"));
5042 }
5043
5044 #[test]
5045 fn checklist_dismissible_renders_dismiss_button() {
5046 let view = JsonUiView::new().component(ComponentNode::checklist(
5047 "tasks",
5048 ChecklistProps {
5049 title: "Tasks".to_string(),
5050 items: vec![],
5051 dismissible: true,
5052 dismiss_label: Some("Close".to_string()),
5053 data_key: None,
5054 },
5055 ));
5056 let html = render_to_html(&view, &json!({}));
5057 assert!(html.contains("Close"));
5058 assert!(html.contains("data-dismissible"));
5059 assert!(html.contains("font-medium"));
5060 assert!(html.contains("text-text"));
5061 assert!(html.contains("hover:text-primary"));
5062 }
5063
5064 #[test]
5065 fn checklist_data_key_added_to_container() {
5066 let view = JsonUiView::new().component(ComponentNode::checklist(
5067 "tasks",
5068 ChecklistProps {
5069 title: "Tasks".to_string(),
5070 items: vec![],
5071 dismissible: false,
5072 dismiss_label: None,
5073 data_key: Some("onboarding_checklist".to_string()),
5074 },
5075 ));
5076 let html = render_to_html(&view, &json!({}));
5077 assert!(html.contains("data-checklist-key=\"onboarding_checklist\""));
5078 }
5079
5080 #[test]
5081 fn checklist_item_with_href_renders_link() {
5082 let view = JsonUiView::new().component(ComponentNode::checklist(
5083 "tasks",
5084 ChecklistProps {
5085 title: "Tasks".to_string(),
5086 items: vec![ChecklistItem {
5087 label: "Visit docs".to_string(),
5088 checked: false,
5089 href: Some("/docs".to_string()),
5090 }],
5091 dismissible: false,
5092 dismiss_label: None,
5093 data_key: None,
5094 },
5095 ));
5096 let html = render_to_html(&view, &json!({}));
5097 assert!(html.contains("href=\"/docs\""));
5098 assert!(html.contains("Visit docs"));
5099 }
5100
5101 #[test]
5104 fn toast_renders_message_and_variant() {
5105 let view = JsonUiView::new().component(ComponentNode::toast(
5106 "t",
5107 ToastProps {
5108 message: "Saved successfully!".to_string(),
5109 variant: ToastVariant::Success,
5110 timeout: None,
5111 dismissible: true,
5112 },
5113 ));
5114 let html = render_to_html(&view, &json!({}));
5115 assert!(html.contains("Saved successfully!"));
5116 assert!(html.contains("data-toast-variant=\"success\""));
5117 }
5118
5119 #[test]
5120 fn toast_renders_timeout_attribute() {
5121 let view = JsonUiView::new().component(ComponentNode::toast(
5122 "t",
5123 ToastProps {
5124 message: "Warning!".to_string(),
5125 variant: ToastVariant::Warning,
5126 timeout: Some(10),
5127 dismissible: false,
5128 },
5129 ));
5130 let html = render_to_html(&view, &json!({}));
5131 assert!(html.contains("data-toast-timeout=\"10\""));
5132 assert!(!html.contains("data-toast-dismissible"));
5133 }
5134
5135 #[test]
5136 fn toast_default_timeout_is_five_seconds() {
5137 let view = JsonUiView::new().component(ComponentNode::toast(
5138 "t",
5139 ToastProps {
5140 message: "Hello".to_string(),
5141 variant: ToastVariant::Info,
5142 timeout: None,
5143 dismissible: false,
5144 },
5145 ));
5146 let html = render_to_html(&view, &json!({}));
5147 assert!(html.contains("data-toast-timeout=\"5\""));
5148 }
5149
5150 #[test]
5151 fn toast_dismissible_renders_dismiss_button() {
5152 let view = JsonUiView::new().component(ComponentNode::toast(
5153 "t",
5154 ToastProps {
5155 message: "Error occurred".to_string(),
5156 variant: ToastVariant::Error,
5157 timeout: None,
5158 dismissible: true,
5159 },
5160 ));
5161 let html = render_to_html(&view, &json!({}));
5162 assert!(html.contains("data-toast-dismissible"));
5163 assert!(html.contains("×"));
5164 }
5165
5166 #[test]
5167 fn toast_info_variant_uses_blue_classes() {
5168 let view = JsonUiView::new().component(ComponentNode::toast(
5169 "t",
5170 ToastProps {
5171 message: "Info".to_string(),
5172 variant: ToastVariant::Info,
5173 timeout: None,
5174 dismissible: false,
5175 },
5176 ));
5177 let html = render_to_html(&view, &json!({}));
5178 assert!(html.contains("bg-primary/10"));
5179 assert!(html.contains("data-toast-variant=\"info\""));
5180 }
5181
5182 #[test]
5183 fn toast_has_fixed_position_classes() {
5184 let view = JsonUiView::new().component(ComponentNode::toast(
5185 "t",
5186 ToastProps {
5187 message: "msg".to_string(),
5188 variant: ToastVariant::Info,
5189 timeout: None,
5190 dismissible: false,
5191 },
5192 ));
5193 let html = render_to_html(&view, &json!({}));
5194 assert!(html.contains("fixed top-4 right-4 z-50"));
5195 }
5196
5197 #[test]
5200 fn notification_dropdown_renders_bell_icon() {
5201 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5202 "notifs",
5203 NotificationDropdownProps {
5204 notifications: vec![],
5205 empty_text: None,
5206 },
5207 ));
5208 let html = render_to_html(&view, &json!({}));
5209 assert!(html.contains("data-notification-dropdown"));
5210 assert!(html.contains("data-notification-count=\"0\""));
5211 }
5212
5213 #[test]
5214 fn notification_dropdown_shows_unread_count_badge() {
5215 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5216 "notifs",
5217 NotificationDropdownProps {
5218 notifications: vec![
5219 NotificationItem {
5220 icon: None,
5221 text: "New message".to_string(),
5222 timestamp: None,
5223 read: false,
5224 action_url: None,
5225 },
5226 NotificationItem {
5227 icon: None,
5228 text: "Old message".to_string(),
5229 timestamp: None,
5230 read: true,
5231 action_url: None,
5232 },
5233 ],
5234 empty_text: None,
5235 },
5236 ));
5237 let html = render_to_html(&view, &json!({}));
5238 assert!(html.contains("data-notification-count=\"1\""));
5239 assert!(html.contains("New message"));
5240 assert!(html.contains("Old message"));
5241 }
5242
5243 #[test]
5244 fn notification_dropdown_shows_empty_text_when_no_notifications() {
5245 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5246 "notifs",
5247 NotificationDropdownProps {
5248 notifications: vec![],
5249 empty_text: Some("All caught up!".to_string()),
5250 },
5251 ));
5252 let html = render_to_html(&view, &json!({}));
5253 assert!(html.contains("All caught up!"));
5254 }
5255
5256 #[test]
5257 fn notification_dropdown_unread_indicator_for_unread_items() {
5258 let view = JsonUiView::new().component(ComponentNode::notification_dropdown(
5259 "notifs",
5260 NotificationDropdownProps {
5261 notifications: vec![NotificationItem {
5262 icon: None,
5263 text: "Unread".to_string(),
5264 timestamp: None,
5265 read: false,
5266 action_url: None,
5267 }],
5268 empty_text: None,
5269 },
5270 ));
5271 let html = render_to_html(&view, &json!({}));
5272 assert!(html.contains("bg-primary"));
5273 }
5274
5275 #[test]
5278 fn sidebar_renders_aside_element() {
5279 let view = JsonUiView::new().component(ComponentNode::sidebar(
5280 "nav",
5281 SidebarProps {
5282 fixed_top: vec![],
5283 groups: vec![],
5284 fixed_bottom: vec![],
5285 },
5286 ));
5287 let html = render_to_html(&view, &json!({}));
5288 assert!(html.contains("<aside"));
5289 assert!(html.contains("</aside>"));
5290 }
5291
5292 #[test]
5293 fn sidebar_renders_fixed_top_items() {
5294 let view = JsonUiView::new().component(ComponentNode::sidebar(
5295 "nav",
5296 SidebarProps {
5297 fixed_top: vec![SidebarNavItem {
5298 label: "Dashboard".to_string(),
5299 href: "/dashboard".to_string(),
5300 icon: None,
5301 active: true,
5302 }],
5303 groups: vec![],
5304 fixed_bottom: vec![],
5305 },
5306 ));
5307 let html = render_to_html(&view, &json!({}));
5308 assert!(html.contains("href=\"/dashboard\""));
5309 assert!(html.contains("Dashboard"));
5310 assert!(html.contains("bg-card text-primary"));
5311 }
5312
5313 #[test]
5314 fn sidebar_renders_groups_with_data_attribute() {
5315 let view = JsonUiView::new().component(ComponentNode::sidebar(
5316 "nav",
5317 SidebarProps {
5318 fixed_top: vec![],
5319 groups: vec![SidebarGroup {
5320 label: "Management".to_string(),
5321 collapsed: false,
5322 items: vec![SidebarNavItem {
5323 label: "Users".to_string(),
5324 href: "/users".to_string(),
5325 icon: None,
5326 active: false,
5327 }],
5328 }],
5329 fixed_bottom: vec![],
5330 },
5331 ));
5332 let html = render_to_html(&view, &json!({}));
5333 assert!(html.contains("data-sidebar-group"));
5334 assert!(html.contains("Management"));
5335 assert!(html.contains("Users"));
5336 assert!(!html.contains("data-collapsed"));
5337 }
5338
5339 #[test]
5340 fn sidebar_collapsed_group_has_data_collapsed() {
5341 let view = JsonUiView::new().component(ComponentNode::sidebar(
5342 "nav",
5343 SidebarProps {
5344 fixed_top: vec![],
5345 groups: vec![SidebarGroup {
5346 label: "Advanced".to_string(),
5347 collapsed: true,
5348 items: vec![],
5349 }],
5350 fixed_bottom: vec![],
5351 },
5352 ));
5353 let html = render_to_html(&view, &json!({}));
5354 assert!(html.contains("data-collapsed"));
5355 }
5356
5357 #[test]
5358 fn sidebar_inactive_item_uses_gray_classes() {
5359 let view = JsonUiView::new().component(ComponentNode::sidebar(
5360 "nav",
5361 SidebarProps {
5362 fixed_top: vec![SidebarNavItem {
5363 label: "Settings".to_string(),
5364 href: "/settings".to_string(),
5365 icon: None,
5366 active: false,
5367 }],
5368 groups: vec![],
5369 fixed_bottom: vec![],
5370 },
5371 ));
5372 let html = render_to_html(&view, &json!({}));
5373 assert!(html.contains("text-text-muted"));
5374 assert!(!html.contains("text-primary"));
5375 }
5376
5377 #[test]
5380 fn header_renders_business_name() {
5381 let view = JsonUiView::new().component(ComponentNode::header(
5382 "hdr",
5383 HeaderProps {
5384 business_name: "Acme Corp".to_string(),
5385 notification_count: None,
5386 user_name: None,
5387 user_avatar: None,
5388 logout_url: None,
5389 },
5390 ));
5391 let html = render_to_html(&view, &json!({}));
5392 assert!(html.contains("<header"));
5393 assert!(html.contains("Acme Corp"));
5394 }
5395
5396 #[test]
5397 fn header_renders_notification_count_badge() {
5398 let view = JsonUiView::new().component(ComponentNode::header(
5399 "hdr",
5400 HeaderProps {
5401 business_name: "Acme".to_string(),
5402 notification_count: Some(3),
5403 user_name: None,
5404 user_avatar: None,
5405 logout_url: None,
5406 },
5407 ));
5408 let html = render_to_html(&view, &json!({}));
5409 assert!(html.contains("data-notification-count=\"3\""));
5410 }
5411
5412 #[test]
5413 fn header_no_badge_when_count_is_zero() {
5414 let view = JsonUiView::new().component(ComponentNode::header(
5415 "hdr",
5416 HeaderProps {
5417 business_name: "Acme".to_string(),
5418 notification_count: Some(0),
5419 user_name: None,
5420 user_avatar: None,
5421 logout_url: None,
5422 },
5423 ));
5424 let html = render_to_html(&view, &json!({}));
5425 assert!(html.contains("data-notification-count=\"0\""));
5426 assert!(!html.contains("bg-destructive"));
5428 }
5429
5430 #[test]
5431 fn header_renders_user_name_initials() {
5432 let view = JsonUiView::new().component(ComponentNode::header(
5433 "hdr",
5434 HeaderProps {
5435 business_name: "Acme".to_string(),
5436 notification_count: None,
5437 user_name: Some("John Doe".to_string()),
5438 user_avatar: None,
5439 logout_url: None,
5440 },
5441 ));
5442 let html = render_to_html(&view, &json!({}));
5443 assert!(html.contains("JD"));
5444 assert!(html.contains("John Doe"));
5445 }
5446
5447 #[test]
5448 fn header_renders_avatar_image_when_provided() {
5449 let view = JsonUiView::new().component(ComponentNode::header(
5450 "hdr",
5451 HeaderProps {
5452 business_name: "Acme".to_string(),
5453 notification_count: None,
5454 user_name: None,
5455 user_avatar: Some("/avatar.jpg".to_string()),
5456 logout_url: None,
5457 },
5458 ));
5459 let html = render_to_html(&view, &json!({}));
5460 assert!(html.contains("src=\"/avatar.jpg\""));
5461 assert!(html.contains("rounded-full"));
5462 }
5463
5464 #[test]
5465 fn header_renders_logout_link() {
5466 let view = JsonUiView::new().component(ComponentNode::header(
5467 "hdr",
5468 HeaderProps {
5469 business_name: "Acme".to_string(),
5470 notification_count: None,
5471 user_name: None,
5472 user_avatar: None,
5473 logout_url: Some("/logout".to_string()),
5474 },
5475 ));
5476 let html = render_to_html(&view, &json!({}));
5477 assert!(html.contains("href=\"/logout\""));
5478 assert!(html.contains("Logout"));
5479 }
5480
5481 #[test]
5482 fn header_escapes_business_name_xss() {
5483 let view = JsonUiView::new().component(ComponentNode::header(
5484 "hdr",
5485 HeaderProps {
5486 business_name: "<script>alert(1)</script>".to_string(),
5487 notification_count: None,
5488 user_name: None,
5489 user_avatar: None,
5490 logout_url: None,
5491 },
5492 ));
5493 let html = render_to_html(&view, &json!({}));
5494 assert!(!html.contains("<script>"));
5495 assert!(html.contains("<script>"));
5496 }
5497
5498 #[test]
5501 fn test_render_deeply_nested_components() {
5502 let inner_card = ComponentNode::card(
5504 "inner-card",
5505 CardProps {
5506 title: "Inner Card".to_string(),
5507 description: None,
5508 children: vec![ComponentNode {
5509 key: "inner-text".to_string(),
5510 component: Component::Text(TextProps {
5511 content: "Deep content".to_string(),
5512 element: TextElement::P,
5513 }),
5514 action: None,
5515 visibility: None,
5516 }],
5517 footer: vec![],
5518 max_width: None,
5519 },
5520 );
5521 let outer_card = ComponentNode::card(
5522 "outer-card",
5523 CardProps {
5524 title: "Outer Card".to_string(),
5525 description: None,
5526 children: vec![inner_card],
5527 footer: vec![],
5528 max_width: None,
5529 },
5530 );
5531 let view = JsonUiView::new().component(outer_card);
5532 let html = render_to_html(&view, &json!({}));
5533
5534 assert!(
5535 html.contains("Outer Card"),
5536 "outer card title should be rendered"
5537 );
5538 assert!(
5539 html.contains("Inner Card"),
5540 "inner card title should be rendered"
5541 );
5542 assert!(
5543 html.contains("Deep content"),
5544 "nested text content should be rendered"
5545 );
5546 }
5547
5548 #[test]
5549 fn test_render_empty_view() {
5550 let view = JsonUiView::new();
5551 let html = render_to_html(&view, &json!({}));
5552 assert_eq!(html, "<div class=\"flex flex-wrap gap-4 [&>*]:w-full [&>button]:w-auto [&>a]:w-auto\"></div>", "empty view renders empty div");
5553 }
5554
5555 #[test]
5556 fn test_render_component_with_visibility_and_action() {
5557 use crate::action::{Action, HttpMethod};
5558 use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
5559
5560 let node = ComponentNode {
5562 key: "admin-link".to_string(),
5563 component: Component::Button(ButtonProps {
5564 label: "View Reports".to_string(),
5565 variant: ButtonVariant::Default,
5566 size: Size::Default,
5567 disabled: None,
5568 icon: None,
5569 icon_position: None,
5570 button_type: None,
5571 }),
5572 action: Some(Action {
5573 handler: "reports.index".to_string(),
5574 url: Some("/reports".to_string()),
5575 method: HttpMethod::Get,
5576 confirm: None,
5577 on_success: None,
5578 on_error: None,
5579 target: None,
5580 }),
5581 visibility: Some(Visibility::Condition(VisibilityCondition {
5582 path: "/auth/user/role".to_string(),
5583 operator: VisibilityOperator::Eq,
5584 value: Some(serde_json::Value::String("admin".to_string())),
5585 })),
5586 };
5587 let view = JsonUiView::new().component(node);
5588 let html = render_to_html(&view, &json!({}));
5589
5590 assert!(
5592 html.contains("View Reports"),
5593 "button label should be rendered"
5594 );
5595 assert!(
5596 html.contains("href=\"/reports\""),
5597 "GET action with URL should produce anchor href"
5598 );
5599 assert!(
5600 html.contains("<a "),
5601 "GET action should wrap component in anchor tag"
5602 );
5603 }
5604
5605 #[test]
5608 fn grid_renders_columns_and_gap() {
5609 let view = JsonUiView::new().component(ComponentNode::grid(
5610 "g",
5611 crate::component::GridProps {
5612 columns: 4,
5613 md_columns: None,
5614 lg_columns: None,
5615 gap: crate::component::GapSize::Lg,
5616 scrollable: None,
5617 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5618 },
5619 ));
5620 let html = render_to_html(&view, &json!({}));
5621 assert!(html.contains("grid w-full grid-cols-4 gap-6"));
5622 assert!(html.contains("Cell 1"));
5623 }
5624
5625 #[test]
5626 fn grid_clamps_columns() {
5627 let view = JsonUiView::new().component(ComponentNode::grid(
5628 "g",
5629 crate::component::GridProps {
5630 columns: 20,
5631 md_columns: None,
5632 lg_columns: None,
5633 gap: crate::component::GapSize::default(),
5634 scrollable: None,
5635 children: vec![],
5636 },
5637 ));
5638 let html = render_to_html(&view, &json!({}));
5639 assert!(html.contains("grid-cols-12"));
5640 }
5641
5642 #[test]
5643 fn grid_responsive_md_columns() {
5644 let view = JsonUiView::new().component(ComponentNode::grid(
5645 "g",
5646 crate::component::GridProps {
5647 columns: 1,
5648 md_columns: Some(3),
5649 lg_columns: None,
5650 gap: crate::component::GapSize::Md,
5651 scrollable: None,
5652 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5653 },
5654 ));
5655 let html = render_to_html(&view, &json!({}));
5656 assert!(html.contains("grid-cols-1 md:grid-cols-3"));
5657 }
5658
5659 #[test]
5660 fn grid_scrollable_renders_overflow() {
5661 let view = JsonUiView::new().component(ComponentNode::grid(
5662 "g",
5663 crate::component::GridProps {
5664 columns: 3,
5665 md_columns: None,
5666 lg_columns: None,
5667 gap: crate::component::GapSize::Md,
5668 scrollable: Some(true),
5669 children: vec![
5670 text_node("c1", "Col 1", TextElement::P),
5671 text_node("c2", "Col 2", TextElement::P),
5672 ],
5673 },
5674 ));
5675 let html = render_to_html(&view, &json!({}));
5676 assert!(
5677 html.contains("overflow-x-auto"),
5678 "should wrap with overflow-x-auto"
5679 );
5680 assert!(
5681 html.contains("grid-flow-col"),
5682 "should use grid-flow-col for scrollable"
5683 );
5684 assert!(
5685 html.contains("auto-cols-[minmax(280px,1fr)]"),
5686 "should use auto-cols"
5687 );
5688 assert!(html.contains("Col 1"));
5689 assert!(html.contains("Col 2"));
5690 }
5691
5692 #[test]
5693 fn grid_non_scrollable_unchanged() {
5694 let view = JsonUiView::new().component(ComponentNode::grid(
5695 "g",
5696 crate::component::GridProps {
5697 columns: 3,
5698 md_columns: None,
5699 lg_columns: None,
5700 gap: crate::component::GapSize::Md,
5701 scrollable: None,
5702 children: vec![text_node("c1", "Cell 1", TextElement::P)],
5703 },
5704 ));
5705 let html = render_to_html(&view, &json!({}));
5706 assert!(
5707 html.contains("grid w-full grid-cols-3 gap-4"),
5708 "non-scrollable should use grid-cols-N"
5709 );
5710 assert!(
5711 !html.contains("overflow-x-auto"),
5712 "non-scrollable should not have overflow-x-auto"
5713 );
5714 assert!(
5715 !html.contains("grid-flow-col"),
5716 "non-scrollable should not have grid-flow-col"
5717 );
5718 }
5719
5720 #[test]
5721 fn form_guard_renders_data_attribute() {
5722 let view = JsonUiView::new().component(ComponentNode {
5723 key: "f".to_string(),
5724 component: Component::Form(crate::component::FormProps {
5725 action: Action {
5726 handler: "orders.create".to_string(),
5727 url: Some("/orders".to_string()),
5728 method: HttpMethod::Post,
5729 confirm: None,
5730 on_success: None,
5731 on_error: None,
5732 target: None,
5733 },
5734 fields: vec![],
5735 method: None,
5736 guard: Some("number-gt-0".to_string()),
5737 max_width: None,
5738 }),
5739 action: None,
5740 visibility: None,
5741 });
5742 let html = render_to_html(&view, &json!({}));
5743 assert!(
5744 html.contains("data-form-guard=\"number-gt-0\""),
5745 "form with guard should render data-form-guard attribute"
5746 );
5747 }
5748
5749 #[test]
5750 fn form_without_guard_unchanged() {
5751 let view = JsonUiView::new().component(ComponentNode {
5752 key: "f".to_string(),
5753 component: Component::Form(crate::component::FormProps {
5754 action: Action {
5755 handler: "orders.create".to_string(),
5756 url: Some("/orders".to_string()),
5757 method: HttpMethod::Post,
5758 confirm: None,
5759 on_success: None,
5760 on_error: None,
5761 target: None,
5762 },
5763 fields: vec![],
5764 method: None,
5765 guard: None,
5766 max_width: None,
5767 }),
5768 action: None,
5769 visibility: None,
5770 });
5771 let html = render_to_html(&view, &json!({}));
5772 assert!(
5773 !html.contains("data-form-guard"),
5774 "form without guard should not render data-form-guard attribute"
5775 );
5776 }
5777
5778 #[test]
5781 fn collapsible_renders_details_summary() {
5782 let view = JsonUiView::new().component(ComponentNode::collapsible(
5783 "c",
5784 crate::component::CollapsibleProps {
5785 title: "More info".into(),
5786 expanded: false,
5787 children: vec![text_node("t", "Hidden text", TextElement::P)],
5788 },
5789 ));
5790 let html = render_to_html(&view, &json!({}));
5791 assert!(html.contains("<details"));
5792 assert!(!html.contains(" open"));
5793 assert!(html.contains("<summary"));
5794 assert!(html.contains("More info"));
5795 assert!(html.contains("Hidden text"));
5796 }
5797
5798 #[test]
5799 fn collapsible_expanded_has_open() {
5800 let view = JsonUiView::new().component(ComponentNode::collapsible(
5801 "c",
5802 crate::component::CollapsibleProps {
5803 title: "Open".into(),
5804 expanded: true,
5805 children: vec![],
5806 },
5807 ));
5808 let html = render_to_html(&view, &json!({}));
5809 assert!(html.contains("<details") && html.contains(" open"));
5810 }
5811
5812 #[test]
5815 fn empty_state_renders_title_and_description() {
5816 let view = JsonUiView::new().component(ComponentNode::empty_state(
5817 "es",
5818 crate::component::EmptyStateProps {
5819 title: "No orders".into(),
5820 description: Some("Create your first order".into()),
5821 action: None,
5822 action_label: None,
5823 },
5824 ));
5825 let html = render_to_html(&view, &json!({}));
5826 assert!(html.contains("No orders"));
5827 assert!(html.contains("Create your first order"));
5828 assert!(!html.contains("<a "));
5829 }
5830
5831 #[test]
5832 fn empty_state_renders_action_link() {
5833 let view = JsonUiView::new().component(ComponentNode::empty_state(
5834 "es",
5835 crate::component::EmptyStateProps {
5836 title: "Empty".into(),
5837 description: None,
5838 action: Some(Action {
5839 handler: "orders.new".into(),
5840 url: Some("/orders/new".into()),
5841 method: HttpMethod::Get,
5842 confirm: None,
5843 on_success: None,
5844 on_error: None,
5845 target: None,
5846 }),
5847 action_label: Some("New order".into()),
5848 },
5849 ));
5850 let html = render_to_html(&view, &json!({}));
5851 assert!(html.contains("href=\"/orders/new\""));
5852 assert!(html.contains("New order"));
5853 }
5854
5855 #[test]
5858 fn form_section_renders_fieldset() {
5859 let view = JsonUiView::new().component(ComponentNode::form_section(
5860 "fs",
5861 crate::component::FormSectionProps {
5862 title: "Contact".into(),
5863 description: Some("Enter details".into()),
5864 children: vec![text_node("n", "Name field", TextElement::P)],
5865 layout: None,
5866 },
5867 ));
5868 let html = render_to_html(&view, &json!({}));
5869 assert!(html.contains("<fieldset"));
5870 assert!(html.contains("<legend"));
5871 assert!(html.contains("Contact"));
5872 assert!(html.contains("Enter details"));
5873 assert!(html.contains("Name field"));
5874 }
5875
5876 #[test]
5879 fn switch_with_action_renders_form() {
5880 let view = JsonUiView::new().component(ComponentNode::switch(
5881 "sw",
5882 SwitchProps {
5883 field: "active".into(),
5884 label: "Active".into(),
5885 description: None,
5886 checked: Some(true),
5887 data_path: None,
5888 required: None,
5889 disabled: None,
5890 error: None,
5891 action: Some(Action {
5892 handler: "settings.toggle".into(),
5893 url: Some("/settings/toggle".into()),
5894 method: HttpMethod::Post,
5895 confirm: None,
5896 on_success: None,
5897 on_error: None,
5898 target: None,
5899 }),
5900 },
5901 ));
5902 let html = render_to_html(&view, &json!({}));
5903 assert!(html.contains("<form action=\"/settings/toggle\" method=\"post\">"));
5904 assert!(html.contains("onchange=\"this.closest('form').submit()\""));
5905 assert!(html.contains("</form>"));
5906 }
5907
5908 #[test]
5909 fn switch_without_action_no_form() {
5910 let view = JsonUiView::new().component(ComponentNode::switch(
5911 "sw",
5912 SwitchProps {
5913 field: "f".into(),
5914 label: "L".into(),
5915 description: None,
5916 checked: None,
5917 data_path: None,
5918 required: None,
5919 disabled: None,
5920 error: None,
5921 action: None,
5922 },
5923 ));
5924 let html = render_to_html(&view, &json!({}));
5925 assert!(!html.contains("<form"));
5926 assert!(!html.contains("onchange"));
5927 }
5928
5929 #[test]
5932 fn test_render_page_header_title_only() {
5933 let view = JsonUiView::new().component(ComponentNode {
5934 key: "ph".to_string(),
5935 component: Component::PageHeader(PageHeaderProps {
5936 title: "My Page".to_string(),
5937 breadcrumb: vec![],
5938 actions: vec![],
5939 }),
5940 action: None,
5941 visibility: None,
5942 });
5943 let html = render_to_html(&view, &json!({}));
5944 assert!(html.contains("pb-4"), "flex container with pb-4");
5945 assert!(html.contains(
5946 "<h2 class=\"text-2xl font-semibold leading-tight tracking-tight text-text truncate\">My Page</h2>"
5947 ));
5948 assert!(!html.contains("<nav"), "no breadcrumb nav when empty");
5949 assert!(!html.contains("flex-shrink-0"), "no actions div when empty");
5950 }
5951
5952 #[test]
5953 fn test_render_page_header_with_breadcrumb() {
5954 let view = JsonUiView::new().component(ComponentNode {
5955 key: "ph".to_string(),
5956 component: Component::PageHeader(PageHeaderProps {
5957 title: "Users".to_string(),
5958 breadcrumb: vec![
5959 BreadcrumbItem {
5960 label: "Home".to_string(),
5961 url: Some("/".to_string()),
5962 },
5963 BreadcrumbItem {
5964 label: "Users".to_string(),
5965 url: None,
5966 },
5967 ],
5968 actions: vec![],
5969 }),
5970 action: None,
5971 visibility: None,
5972 });
5973 let html = render_to_html(&view, &json!({}));
5974 assert!(html.contains("<a href=\"/\" class=\"text-sm text-text-muted hover:text-text whitespace-nowrap\">Home</a>"));
5975 assert!(
5976 html.contains("<span class=\"text-sm text-text-muted whitespace-nowrap\">Users</span>")
5977 );
5978 assert!(
5979 html.contains("<svg"),
5980 "SVG chevron separator between breadcrumb items"
5981 );
5982 }
5983
5984 #[test]
5985 fn test_render_page_header_with_actions() {
5986 let view = JsonUiView::new().component(ComponentNode {
5987 key: "ph".to_string(),
5988 component: Component::PageHeader(PageHeaderProps {
5989 title: "Dashboard".to_string(),
5990 breadcrumb: vec![],
5991 actions: vec![ComponentNode {
5992 key: "add-btn".to_string(),
5993 component: Component::Button(ButtonProps {
5994 label: "Add New".to_string(),
5995 variant: ButtonVariant::Default,
5996 size: Size::Default,
5997 disabled: None,
5998 icon: None,
5999 icon_position: None,
6000 button_type: None,
6001 }),
6002 action: None,
6003 visibility: None,
6004 }],
6005 }),
6006 action: None,
6007 visibility: None,
6008 });
6009 let html = render_to_html(&view, &json!({}));
6010 assert!(
6011 html.contains("flex flex-wrap items-center gap-2"),
6012 "actions wrapper with flex"
6013 );
6014 assert!(
6015 html.contains(">Add New</button>"),
6016 "action button rendered inside"
6017 );
6018 }
6019
6020 #[test]
6023 fn test_render_button_group() {
6024 let view = JsonUiView::new().component(ComponentNode {
6025 key: "bg".to_string(),
6026 component: Component::ButtonGroup(ButtonGroupProps {
6027 buttons: vec![
6028 ComponentNode {
6029 key: "save".to_string(),
6030 component: Component::Button(ButtonProps {
6031 label: "Save".to_string(),
6032 variant: ButtonVariant::Default,
6033 size: Size::Default,
6034 disabled: None,
6035 icon: None,
6036 icon_position: None,
6037 button_type: None,
6038 }),
6039 action: None,
6040 visibility: None,
6041 },
6042 ComponentNode {
6043 key: "cancel".to_string(),
6044 component: Component::Button(ButtonProps {
6045 label: "Cancel".to_string(),
6046 variant: ButtonVariant::Outline,
6047 size: Size::Default,
6048 disabled: None,
6049 icon: None,
6050 icon_position: None,
6051 button_type: None,
6052 }),
6053 action: None,
6054 visibility: None,
6055 },
6056 ],
6057 }),
6058 action: None,
6059 visibility: None,
6060 });
6061 let html = render_to_html(&view, &json!({}));
6062 assert!(
6063 html.contains("flex items-center gap-2 flex-wrap"),
6064 "horizontal flex container"
6065 );
6066 assert!(html.contains(">Save</button>"));
6067 assert!(html.contains(">Cancel</button>"));
6068 }
6069
6070 #[test]
6071 fn test_render_button_group_empty() {
6072 let view = JsonUiView::new().component(ComponentNode {
6073 key: "bg".to_string(),
6074 component: Component::ButtonGroup(ButtonGroupProps { buttons: vec![] }),
6075 action: None,
6076 visibility: None,
6077 });
6078 let html = render_to_html(&view, &json!({}));
6079 assert!(html.contains("<div class=\"flex items-center gap-2 flex-wrap\"></div>"));
6080 }
6081
6082 #[test]
6085 fn test_render_select_appearance_none() {
6086 let view = JsonUiView::new().component(ComponentNode {
6087 key: "sel".to_string(),
6088 component: Component::Select(SelectProps {
6089 field: "role".to_string(),
6090 label: "Role".to_string(),
6091 options: vec![SelectOption {
6092 value: "admin".to_string(),
6093 label: "Admin".to_string(),
6094 }],
6095 placeholder: None,
6096 required: None,
6097 disabled: None,
6098 error: None,
6099 description: None,
6100 default_value: None,
6101 data_path: None,
6102 }),
6103 action: None,
6104 visibility: None,
6105 });
6106 let html = render_to_html(&view, &json!({}));
6107 assert!(
6108 html.contains("appearance-none"),
6109 "select must have appearance-none class"
6110 );
6111 assert!(
6112 html.contains("bg-background"),
6113 "select must have bg-background class"
6114 );
6115 }
6116
6117 #[test]
6120 fn test_render_tabs_single_tab() {
6121 let view = JsonUiView::new().component(ComponentNode {
6122 key: "tabs".to_string(),
6123 component: Component::Tabs(TabsProps {
6124 default_tab: "only".to_string(),
6125 tabs: vec![Tab {
6126 value: "only".to_string(),
6127 label: "Only Tab".to_string(),
6128 children: vec![ComponentNode {
6129 key: "txt".to_string(),
6130 component: Component::Text(TextProps {
6131 content: "Content here".to_string(),
6132 element: TextElement::P,
6133 }),
6134 action: None,
6135 visibility: None,
6136 }],
6137 }],
6138 }),
6139 action: None,
6140 visibility: None,
6141 });
6142 let html = render_to_html(&view, &json!({}));
6143 assert!(
6144 !html.contains("data-tabs"),
6145 "no data-tabs wrapper for single tab"
6146 );
6147 assert!(
6148 !html.contains("role=\"tablist\""),
6149 "no tab nav for single tab"
6150 );
6151 assert!(html.contains("Content here"), "tab content still rendered");
6152 }
6153
6154 #[test]
6155 fn test_render_tabs_multi_still_works() {
6156 let view = JsonUiView::new().component(ComponentNode {
6157 key: "tabs".to_string(),
6158 component: Component::Tabs(TabsProps {
6159 default_tab: "tab1".to_string(),
6160 tabs: vec![
6161 Tab {
6162 value: "tab1".to_string(),
6163 label: "Tab One".to_string(),
6164 children: vec![ComponentNode {
6165 key: "t1".to_string(),
6166 component: Component::Text(TextProps {
6167 content: "Tab 1 content".to_string(),
6168 element: TextElement::P,
6169 }),
6170 action: None,
6171 visibility: None,
6172 }],
6173 },
6174 Tab {
6175 value: "tab2".to_string(),
6176 label: "Tab Two".to_string(),
6177 children: vec![ComponentNode {
6178 key: "t2".to_string(),
6179 component: Component::Text(TextProps {
6180 content: "Tab 2 content".to_string(),
6181 element: TextElement::P,
6182 }),
6183 action: None,
6184 visibility: None,
6185 }],
6186 },
6187 ],
6188 }),
6189 action: None,
6190 visibility: None,
6191 });
6192 let html = render_to_html(&view, &json!({}));
6193 assert!(
6194 html.contains("data-tabs"),
6195 "multi-tab still has data-tabs wrapper"
6196 );
6197 assert!(
6198 html.contains("role=\"tablist\""),
6199 "multi-tab still has nav with role=tablist"
6200 );
6201 assert!(html.contains("Tab One"), "tab label rendered");
6202 assert!(html.contains("Tab Two"), "tab label rendered");
6203 }
6204
6205 fn has_class(html: &str, class: &str) -> bool {
6211 html.contains(&format!("class=\"{class}\""))
6212 || html.contains(&format!("class=\"{class} "))
6213 || html.contains(&format!(" {class}\""))
6214 || html.contains(&format!(" {class} "))
6215 }
6216
6217 fn assert_element(html: &str, tag: &str, content: &str) {
6219 assert!(
6220 html.contains(&format!("<{tag} ")) || html.contains(&format!("<{tag}>")),
6221 "expected <{tag}> element in HTML"
6222 );
6223 assert!(
6224 html.contains(content),
6225 "expected content '{content}' in HTML"
6226 );
6227 }
6228
6229 mod structural_tests {
6239 use super::*;
6240 use serde_json::json;
6241
6242 #[test]
6245 fn h1_structural_element_and_semantic_class() {
6246 let view = JsonUiView::new().component(text_node("t", "Page Title", TextElement::H1));
6247 let html = render_to_html(&view, &json!({}));
6248 assert_element(&html, "h1", "Page Title");
6249 assert!(
6250 has_class(&html, "text-text"),
6251 "h1 should have text-text class"
6252 );
6253 }
6254
6255 #[test]
6258 fn h2_structural_element_and_semantic_class() {
6259 let view =
6260 JsonUiView::new().component(text_node("t", "Section Title", TextElement::H2));
6261 let html = render_to_html(&view, &json!({}));
6262 assert_element(&html, "h2", "Section Title");
6263 assert!(
6264 has_class(&html, "text-text"),
6265 "h2 should have text-text class"
6266 );
6267 }
6268
6269 #[test]
6272 fn h3_structural_element_and_semantic_class() {
6273 let view = JsonUiView::new().component(text_node("t", "Subsection", TextElement::H3));
6274 let html = render_to_html(&view, &json!({}));
6275 assert_element(&html, "h3", "Subsection");
6276 assert!(
6277 has_class(&html, "text-text"),
6278 "h3 should have text-text class"
6279 );
6280 }
6281
6282 #[test]
6285 fn p_structural_element_and_semantic_class() {
6286 let view = JsonUiView::new().component(text_node("t", "Body text", TextElement::P));
6287 let html = render_to_html(&view, &json!({}));
6288 assert_element(&html, "p", "Body text");
6289 assert!(
6290 has_class(&html, "text-text"),
6291 "p should have text-text class"
6292 );
6293 }
6294
6295 #[test]
6298 fn card_structural_title_and_description() {
6299 let view = JsonUiView::new().component(ComponentNode {
6300 key: "c".to_string(),
6301 component: Component::Card(CardProps {
6302 title: "Card Title".to_string(),
6303 description: Some("Card description".to_string()),
6304 children: vec![],
6305 footer: vec![],
6306 max_width: None,
6307 }),
6308 action: None,
6309 visibility: None,
6310 });
6311 let html = render_to_html(&view, &json!({}));
6312 assert!(html.contains("<div"), "card should render a div container");
6313 assert!(html.contains("Card Title"), "card title should be present");
6314 assert!(
6315 html.contains("Card description"),
6316 "card description should be present"
6317 );
6318 assert!(
6319 has_class(&html, "border-border"),
6320 "card container should have border-border"
6321 );
6322 }
6323
6324 #[test]
6327 fn alert_structural_container_and_message() {
6328 let view = JsonUiView::new().component(ComponentNode {
6329 key: "a".to_string(),
6330 component: Component::Alert(AlertProps {
6331 message: "Something went wrong".to_string(),
6332 variant: AlertVariant::Warning,
6333 title: None,
6334 }),
6335 action: None,
6336 visibility: None,
6337 });
6338 let html = render_to_html(&view, &json!({}));
6339 assert!(
6340 html.contains("role=\"alert\""),
6341 "alert should have role=alert"
6342 );
6343 assert!(
6344 html.contains("Something went wrong"),
6345 "alert message should be present"
6346 );
6347 assert!(
6348 has_class(&html, "text-warning"),
6349 "warning alert should have text-warning class"
6350 );
6351 }
6352
6353 #[test]
6356 fn input_structural_element_and_type() {
6357 let view = JsonUiView::new().component(ComponentNode {
6358 key: "i".to_string(),
6359 component: Component::Input(InputProps {
6360 field: "username".to_string(),
6361 label: "Username".to_string(),
6362 input_type: InputType::Text,
6363 placeholder: None,
6364 required: None,
6365 disabled: None,
6366 error: None,
6367 description: None,
6368 default_value: None,
6369 data_path: None,
6370 step: None,
6371 list: None,
6372 }),
6373 action: None,
6374 visibility: None,
6375 });
6376 let html = render_to_html(&view, &json!({}));
6377 assert!(
6378 html.contains("<input"),
6379 "input should render an <input element"
6380 );
6381 assert!(html.contains("type=\"text\""), "input type should be text");
6382 assert!(
6383 html.contains("name=\"username\""),
6384 "input name should match field"
6385 );
6386 }
6387
6388 #[test]
6391 fn select_structural_element_and_options() {
6392 let view = JsonUiView::new().component(ComponentNode {
6393 key: "s".to_string(),
6394 component: Component::Select(SelectProps {
6395 field: "status".to_string(),
6396 label: "Status".to_string(),
6397 options: vec![
6398 SelectOption {
6399 value: "active".to_string(),
6400 label: "Active".to_string(),
6401 },
6402 SelectOption {
6403 value: "inactive".to_string(),
6404 label: "Inactive".to_string(),
6405 },
6406 ],
6407 placeholder: None,
6408 required: None,
6409 disabled: None,
6410 error: None,
6411 description: None,
6412 default_value: None,
6413 data_path: None,
6414 }),
6415 action: None,
6416 visibility: None,
6417 });
6418 let html = render_to_html(&view, &json!({}));
6419 assert!(
6420 html.contains("<select"),
6421 "select should render a <select element"
6422 );
6423 assert!(
6424 html.contains("Active"),
6425 "select should render option labels"
6426 );
6427 assert!(
6428 html.contains("Inactive"),
6429 "select should render all options"
6430 );
6431 }
6432
6433 #[test]
6436 fn table_structural_headers_and_body() {
6437 let data = json!({
6438 "items": [{"name": "Widget", "price": "9.99"}]
6439 });
6440 let view = JsonUiView::new().component(ComponentNode {
6441 key: "t".to_string(),
6442 component: Component::Table(TableProps {
6443 columns: vec![
6444 Column {
6445 key: "name".to_string(),
6446 label: "Name".to_string(),
6447 format: None,
6448 },
6449 Column {
6450 key: "price".to_string(),
6451 label: "Price".to_string(),
6452 format: None,
6453 },
6454 ],
6455 data_path: "/items".to_string(),
6456 row_actions: None,
6457 empty_message: None,
6458 sortable: None,
6459 sort_column: None,
6460 sort_direction: None,
6461 }),
6462 action: None,
6463 visibility: None,
6464 });
6465 let html = render_to_html(&view, &data);
6466 assert!(
6467 html.contains("<table"),
6468 "table should render a <table element"
6469 );
6470 assert!(html.contains("<th"), "table should render header cells");
6471 assert!(
6472 html.contains("Name"),
6473 "table should render Name column header"
6474 );
6475 assert!(
6476 html.contains("Price"),
6477 "table should render Price column header"
6478 );
6479 assert!(html.contains("Widget"), "table should render row data");
6480 }
6481
6482 #[test]
6485 fn breadcrumb_structural_nav_and_links() {
6486 let view = JsonUiView::new().component(ComponentNode {
6487 key: "bc".to_string(),
6488 component: Component::Breadcrumb(BreadcrumbProps {
6489 items: vec![
6490 BreadcrumbItem {
6491 label: "Home".to_string(),
6492 url: Some("/".to_string()),
6493 },
6494 BreadcrumbItem {
6495 label: "Products".to_string(),
6496 url: Some("/products".to_string()),
6497 },
6498 BreadcrumbItem {
6499 label: "Widget".to_string(),
6500 url: None,
6501 },
6502 ],
6503 }),
6504 action: None,
6505 visibility: None,
6506 });
6507 let html = render_to_html(&view, &json!({}));
6508 assert!(
6509 html.contains("<nav"),
6510 "breadcrumb should render a <nav element"
6511 );
6512 assert!(
6513 html.contains("href=\"/\""),
6514 "breadcrumb should render Home link"
6515 );
6516 assert!(
6517 html.contains("href=\"/products\""),
6518 "breadcrumb should render Products link"
6519 );
6520 assert!(
6521 html.contains("Widget"),
6522 "breadcrumb should render last item text"
6523 );
6524 }
6525
6526 #[test]
6529 fn tabs_structural_buttons_and_content() {
6530 let view = JsonUiView::new().component(ComponentNode {
6531 key: "tabs".to_string(),
6532 component: Component::Tabs(TabsProps {
6533 default_tab: "overview".to_string(),
6534 tabs: vec![
6535 Tab {
6536 value: "overview".to_string(),
6537 label: "Overview".to_string(),
6538 children: vec![text_node("t1", "Overview content", TextElement::P)],
6539 },
6540 Tab {
6541 value: "details".to_string(),
6542 label: "Details".to_string(),
6543 children: vec![text_node("t2", "Details content", TextElement::P)],
6544 },
6545 ],
6546 }),
6547 action: None,
6548 visibility: None,
6549 });
6550 let html = render_to_html(&view, &json!({}));
6551 assert!(
6552 html.contains("<button"),
6553 "tabs should render button elements"
6554 );
6555 assert!(html.contains("Overview"), "tabs should render tab labels");
6556 assert!(
6557 html.contains("Details"),
6558 "tabs should render all tab labels"
6559 );
6560 assert!(
6561 html.contains("Overview content"),
6562 "tabs should render active tab content"
6563 );
6564 }
6565
6566 #[test]
6569 fn stat_card_structural_value_and_label() {
6570 let view = JsonUiView::new().component(ComponentNode::stat_card(
6571 "sales",
6572 StatCardProps {
6573 label: "Total Sales".to_string(),
6574 value: "1,024".to_string(),
6575 icon: None,
6576 subtitle: None,
6577 sse_target: None,
6578 },
6579 ));
6580 let html = render_to_html(&view, &json!({}));
6581 assert!(
6582 html.contains("Total Sales"),
6583 "stat card should render label"
6584 );
6585 assert!(html.contains("1,024"), "stat card should render value");
6586 assert!(
6587 has_class(&html, "rounded-lg"),
6588 "stat card should have rounded-lg class"
6589 );
6590 }
6591
6592 #[test]
6595 fn skeleton_structural_animate_class() {
6596 let view = JsonUiView::new().component(ComponentNode {
6597 key: "sk".to_string(),
6598 component: Component::Skeleton(SkeletonProps {
6599 width: None,
6600 height: None,
6601 rounded: None,
6602 }),
6603 action: None,
6604 visibility: None,
6605 });
6606 let html = render_to_html(&view, &json!({}));
6607 assert!(html.contains("<div"), "skeleton should render a div");
6608 assert!(
6609 html.contains("ferro-shimmer"),
6610 "skeleton should have ferro-shimmer class"
6611 );
6612 }
6613
6614 #[test]
6617 fn collapsible_structural_details_element() {
6618 let view = JsonUiView::new().component(ComponentNode::collapsible(
6619 "col",
6620 crate::component::CollapsibleProps {
6621 title: "Show more".into(),
6622 expanded: false,
6623 children: vec![text_node("t", "Collapsed content", TextElement::P)],
6624 },
6625 ));
6626 let html = render_to_html(&view, &json!({}));
6627 assert!(
6628 html.contains("<details"),
6629 "collapsible should render a <details element"
6630 );
6631 assert!(
6632 html.contains("Show more"),
6633 "collapsible should render the title"
6634 );
6635 assert!(
6636 html.contains("Collapsed content"),
6637 "collapsible should render children"
6638 );
6639 }
6640
6641 #[test]
6644 fn alert_svg_icon_per_variant() {
6645 use crate::component::AlertVariant;
6646 let variants = [
6647 AlertVariant::Info,
6648 AlertVariant::Success,
6649 AlertVariant::Warning,
6650 AlertVariant::Error,
6651 ];
6652 for variant in variants {
6653 let view = JsonUiView::new().component(ComponentNode {
6654 key: "a".to_string(),
6655 component: Component::Alert(AlertProps {
6656 variant,
6657 title: None,
6658 message: "Test message".to_string(),
6659 }),
6660 action: None,
6661 visibility: None,
6662 });
6663 let html = render_to_html(&view, &json!({}));
6664 assert!(html.contains("<svg"), "alert should contain SVG icon");
6665 assert!(
6666 html.contains("role=\"alert\""),
6667 "alert should preserve accessibility role"
6668 );
6669 assert!(
6670 has_class(&html, "flex"),
6671 "alert container should have flex class"
6672 );
6673 }
6674 }
6675
6676 #[test]
6679 fn skeleton_shimmer_class() {
6680 let view = JsonUiView::new().component(ComponentNode {
6681 key: "sk".to_string(),
6682 component: Component::Skeleton(SkeletonProps {
6683 width: None,
6684 height: None,
6685 rounded: None,
6686 }),
6687 action: None,
6688 visibility: None,
6689 });
6690 let html = render_to_html(&view, &json!({}));
6691 assert!(
6692 html.contains("ferro-shimmer"),
6693 "shimmer class should be present"
6694 );
6695 assert!(
6696 !html.contains("animate-pulse"),
6697 "old pulse class should be removed"
6698 );
6699 assert!(
6700 html.contains("@keyframes ferro-shimmer"),
6701 "CSS keyframe should be injected"
6702 );
6703 }
6704
6705 #[test]
6708 fn breadcrumb_svg_separator() {
6709 let view = JsonUiView::new().component(ComponentNode {
6710 key: "bc".to_string(),
6711 component: Component::Breadcrumb(BreadcrumbProps {
6712 items: vec![
6713 BreadcrumbItem {
6714 label: "Home".to_string(),
6715 url: Some("/".to_string()),
6716 },
6717 BreadcrumbItem {
6718 label: "Products".to_string(),
6719 url: Some("/products".to_string()),
6720 },
6721 BreadcrumbItem {
6722 label: "Detail".to_string(),
6723 url: None,
6724 },
6725 ],
6726 }),
6727 action: None,
6728 visibility: None,
6729 });
6730 let html = render_to_html(&view, &json!({}));
6731 assert!(html.contains("<svg"), "SVG separator should be present");
6732 assert!(
6733 !html.contains("<span>/</span>"),
6734 "old text separator should be removed"
6735 );
6736 assert!(
6737 html.contains("aria-hidden"),
6738 "separator should be decorative (aria-hidden)"
6739 );
6740 }
6741
6742 #[test]
6745 fn tab_active_font_semibold() {
6746 let view = JsonUiView::new().component(ComponentNode {
6747 key: "tabs".to_string(),
6748 component: Component::Tabs(TabsProps {
6749 default_tab: "tab1".to_string(),
6750 tabs: vec![
6751 Tab {
6752 value: "tab1".to_string(),
6753 label: "First Tab".to_string(),
6754 children: vec![text_node("t1", "Content one", TextElement::P)],
6755 },
6756 Tab {
6757 value: "tab2".to_string(),
6758 label: "Second Tab".to_string(),
6759 children: vec![text_node("t2", "Content two", TextElement::P)],
6760 },
6761 ],
6762 }),
6763 action: None,
6764 visibility: None,
6765 });
6766 let html = render_to_html(&view, &json!({}));
6767 assert!(
6768 has_class(&html, "font-semibold"),
6769 "active tab should have font-semibold class"
6770 );
6771 let count = html.matches("font-semibold").count();
6772 assert_eq!(count, 1, "only the active tab should have font-semibold");
6773 }
6774
6775 #[test]
6778 fn notification_bell_svg() {
6779 let view = JsonUiView::new().component(ComponentNode {
6780 key: "nd".to_string(),
6781 component: Component::NotificationDropdown(NotificationDropdownProps {
6782 notifications: vec![crate::component::NotificationItem {
6783 text: "New message".to_string(),
6784 read: false,
6785 icon: None,
6786 action_url: None,
6787 timestamp: None,
6788 }],
6789 empty_text: None,
6790 }),
6791 action: None,
6792 visibility: None,
6793 });
6794 let html = render_to_html(&view, &json!({}));
6795 assert!(html.contains("<svg"), "SVG bell should be present");
6796 assert!(
6797 !html.contains("🔔"),
6798 "bell emoji entity should be removed"
6799 );
6800 }
6801
6802 #[test]
6805 fn collapsible_svg_chevron() {
6806 let view = JsonUiView::new().component(ComponentNode::collapsible(
6807 "col",
6808 crate::component::CollapsibleProps {
6809 title: "Section".into(),
6810 expanded: false,
6811 children: vec![text_node("t", "Body text", TextElement::P)],
6812 },
6813 ));
6814 let html = render_to_html(&view, &json!({}));
6815 assert!(html.contains("<svg"), "SVG chevron should be present");
6816 assert!(
6817 !html.contains("▼"),
6818 "old down-arrow entity should be removed"
6819 );
6820 assert!(
6821 has_class(&html, "group-open:rotate-180"),
6822 "rotation class should be preserved"
6823 );
6824 assert!(
6825 has_class(&html, "transition-transform"),
6826 "transition class should be preserved"
6827 );
6828 }
6829
6830 #[test]
6833 fn select_renders_chevron_wrapper() {
6834 let view = JsonUiView::new().component(ComponentNode {
6835 key: "s".to_string(),
6836 component: Component::Select(SelectProps {
6837 field: "role".to_string(),
6838 label: "Role".to_string(),
6839 options: vec![],
6840 placeholder: None,
6841 required: None,
6842 disabled: None,
6843 error: None,
6844 description: None,
6845 default_value: None,
6846 data_path: None,
6847 }),
6848 action: None,
6849 visibility: None,
6850 });
6851 let html = render_to_html(&view, &json!({}));
6852 assert!(
6853 html.contains("<div class=\"relative\">"),
6854 "select should be wrapped in relative div"
6855 );
6856 assert!(
6857 html.contains("aria-hidden=\"true\""),
6858 "SVG span should have aria-hidden"
6859 );
6860 assert!(
6861 html.contains("<svg"),
6862 "inline SVG chevron should be present"
6863 );
6864 assert!(
6865 html.contains("pointer-events-none"),
6866 "SVG span should be non-interactive"
6867 );
6868 assert!(has_class(&html, "pr-10"), "select should have pr-10 class");
6869 }
6870
6871 #[test]
6872 fn input_renders_transition_classes() {
6873 let view = JsonUiView::new().component(ComponentNode {
6874 key: "i".to_string(),
6875 component: Component::Input(InputProps {
6876 field: "name".to_string(),
6877 label: "Name".to_string(),
6878 input_type: InputType::Text,
6879 placeholder: None,
6880 required: None,
6881 disabled: None,
6882 error: None,
6883 description: None,
6884 default_value: None,
6885 data_path: None,
6886 step: None,
6887 list: None,
6888 }),
6889 action: None,
6890 visibility: None,
6891 });
6892 let html = render_to_html(&view, &json!({}));
6893 assert!(
6894 has_class(&html, "transition-colors"),
6895 "input should have transition-colors"
6896 );
6897 assert!(
6898 has_class(&html, "duration-150"),
6899 "input should have duration-150"
6900 );
6901 assert!(
6902 html.contains("motion-reduce:transition-none"),
6903 "input should support reduced motion"
6904 );
6905 }
6906
6907 #[test]
6908 fn input_disabled_renders_disabled_classes() {
6909 let view = JsonUiView::new().component(ComponentNode {
6910 key: "i".to_string(),
6911 component: Component::Input(InputProps {
6912 field: "name".to_string(),
6913 label: "Name".to_string(),
6914 input_type: InputType::Text,
6915 placeholder: None,
6916 required: None,
6917 disabled: Some(true),
6918 error: None,
6919 description: None,
6920 default_value: None,
6921 data_path: None,
6922 step: None,
6923 list: None,
6924 }),
6925 action: None,
6926 visibility: None,
6927 });
6928 let html = render_to_html(&view, &json!({}));
6929 assert!(
6930 html.contains("disabled:opacity-50"),
6931 "input should have disabled:opacity-50"
6932 );
6933 assert!(
6934 html.contains("disabled:cursor-not-allowed"),
6935 "input should have disabled:cursor-not-allowed"
6936 );
6937 assert!(
6938 html.contains(" disabled"),
6939 "input should have disabled HTML attribute"
6940 );
6941 }
6942
6943 #[test]
6944 fn textarea_renders_error_focus_ring() {
6945 let view = JsonUiView::new().component(ComponentNode {
6946 key: "i".to_string(),
6947 component: Component::Input(InputProps {
6948 field: "bio".to_string(),
6949 label: "Bio".to_string(),
6950 input_type: InputType::Textarea,
6951 placeholder: None,
6952 required: None,
6953 disabled: None,
6954 error: Some("Required".to_string()),
6955 description: None,
6956 default_value: None,
6957 data_path: None,
6958 step: None,
6959 list: None,
6960 }),
6961 action: None,
6962 visibility: None,
6963 });
6964 let html = render_to_html(&view, &json!({}));
6965 assert!(
6966 html.contains("ring-destructive"),
6967 "textarea with error should have ring-destructive"
6968 );
6969 }
6970
6971 #[test]
6972 fn input_description_order() {
6973 let view = JsonUiView::new().component(ComponentNode {
6974 key: "i".to_string(),
6975 component: Component::Input(InputProps {
6976 field: "name".to_string(),
6977 label: "Name".to_string(),
6978 input_type: InputType::Text,
6979 placeholder: None,
6980 required: None,
6981 disabled: None,
6982 error: None,
6983 description: Some("Help text".to_string()),
6984 default_value: None,
6985 data_path: None,
6986 step: None,
6987 list: None,
6988 }),
6989 action: None,
6990 visibility: None,
6991 });
6992 let html = render_to_html(&view, &json!({}));
6993 let input_pos = html.find("<input").expect("input element should exist");
6994 let desc_pos = html.find("Help text").expect("description should exist");
6995 assert!(
6996 input_pos < desc_pos,
6997 "input should appear before description in DOM"
6998 );
6999 }
7000
7001 #[test]
7002 fn select_description_order() {
7003 let view = JsonUiView::new().component(ComponentNode {
7004 key: "s".to_string(),
7005 component: Component::Select(SelectProps {
7006 field: "role".to_string(),
7007 label: "Role".to_string(),
7008 options: vec![],
7009 placeholder: None,
7010 required: None,
7011 disabled: None,
7012 error: None,
7013 description: Some("Pick one".to_string()),
7014 default_value: None,
7015 data_path: None,
7016 }),
7017 action: None,
7018 visibility: None,
7019 });
7020 let html = render_to_html(&view, &json!({}));
7021 let select_close_pos = html.find("</select>").expect("select close should exist");
7022 let desc_pos = html.find("Pick one").expect("description should exist");
7023 assert!(
7024 select_close_pos < desc_pos,
7025 "select close should appear before description in DOM"
7026 );
7027 }
7028
7029 #[test]
7032 fn button_structural_element_and_text() {
7033 let view = JsonUiView::new().component(button_node(
7034 "btn",
7035 "Submit",
7036 ButtonVariant::Default,
7037 Size::Default,
7038 ));
7039 let html = render_to_html(&view, &json!({}));
7040 assert!(
7041 html.contains("<button"),
7042 "button should render a <button element"
7043 );
7044 assert!(html.contains("Submit"), "button should render label text");
7045 assert!(
7046 has_class(&html, "bg-primary"),
7047 "default button should have bg-primary class"
7048 );
7049 }
7050
7051 #[test]
7054 fn button_focus_ring() {
7055 let view = JsonUiView::new().component(button_node(
7056 "btn",
7057 "Click me",
7058 ButtonVariant::Default,
7059 Size::Default,
7060 ));
7061 let html = render_to_html(&view, &json!({}));
7062 assert!(
7063 has_class(&html, "focus-visible:ring-primary"),
7064 "button should have focus-visible:ring-primary class (INT-01)"
7065 );
7066 assert!(
7067 has_class(&html, "duration-150"),
7068 "button should have duration-150 class (INT-07)"
7069 );
7070 assert!(
7071 html.contains("motion-reduce:transition-none"),
7072 "button should have motion-reduce:transition-none (INT-07)"
7073 );
7074 }
7075
7076 #[test]
7079 fn tabs_focus_ring() {
7080 let view = JsonUiView::new().component(ComponentNode {
7081 key: "tabs".to_string(),
7082 component: Component::Tabs(TabsProps {
7083 default_tab: "tab1".to_string(),
7084 tabs: vec![
7085 Tab {
7086 value: "tab1".to_string(),
7087 label: "Tab One".to_string(),
7088 children: vec![text_node("t1", "Content one", TextElement::P)],
7089 },
7090 Tab {
7091 value: "tab2".to_string(),
7092 label: "Tab Two".to_string(),
7093 children: vec![text_node("t2", "Content two", TextElement::P)],
7094 },
7095 ],
7096 }),
7097 action: None,
7098 visibility: None,
7099 });
7100 let html = render_to_html(&view, &json!({}));
7101 assert!(
7102 has_class(&html, "focus-visible:ring-primary"),
7103 "tab button/link should have focus-visible:ring-primary class (INT-02)"
7104 );
7105 assert!(
7106 has_class(&html, "duration-150"),
7107 "tab button/link should have duration-150 class (INT-07)"
7108 );
7109 }
7110
7111 #[test]
7114 fn pagination_focus_ring() {
7115 let view = JsonUiView::new().component(ComponentNode {
7116 key: "pg".to_string(),
7117 component: Component::Pagination(PaginationProps {
7118 total: 30,
7119 per_page: 10,
7120 current_page: 2,
7121 base_url: Some("?".to_string()),
7122 }),
7123 action: None,
7124 visibility: None,
7125 });
7126 let html = render_to_html(&view, &json!({}));
7127 assert!(
7128 has_class(&html, "focus-visible:ring-primary"),
7129 "pagination <a> links should have focus-visible:ring-primary class (INT-03)"
7130 );
7131 assert!(
7132 has_class(&html, "duration-150"),
7133 "pagination <a> links should have duration-150 class (INT-07)"
7134 );
7135 }
7136
7137 #[test]
7140 fn breadcrumb_focus_ring() {
7141 let view = JsonUiView::new().component(ComponentNode {
7142 key: "bc".to_string(),
7143 component: Component::Breadcrumb(BreadcrumbProps {
7144 items: vec![
7145 BreadcrumbItem {
7146 label: "Home".to_string(),
7147 url: Some("/".to_string()),
7148 },
7149 BreadcrumbItem {
7150 label: "Current".to_string(),
7151 url: None,
7152 },
7153 ],
7154 }),
7155 action: None,
7156 visibility: None,
7157 });
7158 let html = render_to_html(&view, &json!({}));
7159 assert!(
7160 has_class(&html, "focus-visible:ring-primary"),
7161 "breadcrumb <a> link should have focus-visible:ring-primary class (INT-04)"
7162 );
7163 assert!(
7164 has_class(&html, "duration-150"),
7165 "breadcrumb <a> link should have duration-150 class (INT-07)"
7166 );
7167 }
7168
7169 #[test]
7172 fn sidebar_nav_focus_ring() {
7173 let view = JsonUiView::new().component(ComponentNode::sidebar(
7174 "nav",
7175 SidebarProps {
7176 fixed_top: vec![SidebarNavItem {
7177 label: "Dashboard".to_string(),
7178 href: "/dashboard".to_string(),
7179 icon: None,
7180 active: false,
7181 }],
7182 groups: vec![],
7183 fixed_bottom: vec![],
7184 },
7185 ));
7186 let html = render_to_html(&view, &json!({}));
7187 assert!(
7188 has_class(&html, "focus-visible:ring-primary"),
7189 "sidebar nav <a> item should have focus-visible:ring-primary class (INT-05)"
7190 );
7191 assert!(
7192 has_class(&html, "duration-150"),
7193 "sidebar nav <a> item should have duration-150 class (INT-07)"
7194 );
7195 }
7196
7197 #[test]
7200 fn table_row_hover() {
7201 let data = json!({"items": [{"name": "Alice"}]});
7202 let view = JsonUiView::new().component(ComponentNode {
7203 key: "t".to_string(),
7204 component: Component::Table(TableProps {
7205 columns: vec![Column {
7206 key: "name".to_string(),
7207 label: "Name".to_string(),
7208 format: None,
7209 }],
7210 data_path: "/items".to_string(),
7211 row_actions: None,
7212 empty_message: None,
7213 sortable: None,
7214 sort_column: None,
7215 sort_direction: None,
7216 }),
7217 action: None,
7218 visibility: None,
7219 });
7220 let html = render_to_html(&view, &data);
7221 assert!(
7222 html.contains("<tr class=\"hover:bg-surface\">"),
7223 "table body row should have hover:bg-surface class (INT-06)"
7224 );
7225 }
7226 }
7227
7228 #[test]
7231 fn test_render_dropdown_menu() {
7232 let props = DropdownMenuProps {
7233 menu_id: "actions-1".to_string(),
7234 trigger_label: "Azioni".to_string(),
7235 items: vec![
7236 DropdownMenuAction {
7237 label: "Modifica".to_string(),
7238 action: Action {
7239 handler: "items.edit".to_string(),
7240 url: Some("/items/1/edit".to_string()),
7241 method: HttpMethod::Get,
7242 confirm: None,
7243 on_success: None,
7244 on_error: None,
7245 target: None,
7246 },
7247 destructive: false,
7248 },
7249 DropdownMenuAction {
7250 label: "Elimina".to_string(),
7251 action: Action {
7252 handler: "items.destroy".to_string(),
7253 url: Some("/items/1".to_string()),
7254 method: HttpMethod::Delete,
7255 confirm: None,
7256 on_success: None,
7257 on_error: None,
7258 target: None,
7259 },
7260 destructive: true,
7261 },
7262 ],
7263 trigger_variant: None,
7264 };
7265
7266 let view = JsonUiView::new().component(ComponentNode::dropdown_menu("menu", props));
7267 let html = render_to_html(&view, &json!({}));
7268
7269 assert!(
7270 html.contains("data-dropdown-toggle=\"actions-1\""),
7271 "trigger has data-dropdown-toggle"
7272 );
7273 assert!(
7274 html.contains("data-dropdown=\"actions-1\""),
7275 "panel has data-dropdown"
7276 );
7277 assert!(html.contains("hidden"), "panel starts hidden");
7278 assert!(
7279 html.contains("text-destructive"),
7280 "destructive item has text-destructive class"
7281 );
7282 assert!(html.contains("type=\"button\""), "trigger is type=button");
7283 assert!(
7284 html.contains("aria-label=\"Azioni\""),
7285 "trigger has aria-label"
7286 );
7287 assert!(html.contains("Modifica"), "normal item label present");
7288 assert!(html.contains("Elimina"), "destructive item label present");
7289 assert!(
7291 html.contains("<a href=\"/items/1/edit\""),
7292 "GET action renders as link"
7293 );
7294 assert!(
7295 html.contains("<form action=\"/items/1\" method=\"post\">"),
7296 "DELETE action renders as form"
7297 );
7298 assert!(
7299 html.contains("name=\"_method\" value=\"DELETE\""),
7300 "DELETE method spoofing"
7301 );
7302 }
7303
7304 #[test]
7305 fn test_render_dropdown_menu_confirm() {
7306 use crate::action::{ConfirmDialog, DialogVariant};
7307
7308 let props = DropdownMenuProps {
7309 menu_id: "confirm-menu".to_string(),
7310 trigger_label: "Menu".to_string(),
7311 items: vec![DropdownMenuAction {
7312 label: "Elimina".to_string(),
7313 action: Action {
7314 handler: "items.destroy".to_string(),
7315 url: Some("/items/1".to_string()),
7316 method: HttpMethod::Delete,
7317 confirm: Some(ConfirmDialog {
7318 title: "Conferma eliminazione".to_string(),
7319 message: Some("Sei sicuro?".to_string()),
7320 variant: DialogVariant::Danger,
7321 }),
7322 on_success: None,
7323 on_error: None,
7324 target: None,
7325 },
7326 destructive: true,
7327 }],
7328 trigger_variant: None,
7329 };
7330
7331 let view = JsonUiView::new().component(ComponentNode::dropdown_menu("cm", props));
7332 let html = render_to_html(&view, &json!({}));
7333
7334 assert!(
7335 html.contains("data-confirm-title=\"Conferma eliminazione\""),
7336 "confirm title attribute"
7337 );
7338 assert!(
7339 html.contains("data-confirm-message=\"Sei sicuro?\""),
7340 "confirm message attribute"
7341 );
7342 assert!(html.contains("data-confirm"), "has data-confirm attribute");
7343 }
7344
7345 fn make_kanban_props() -> KanbanBoardProps {
7348 use crate::component::{CardProps, KanbanBoardProps, KanbanColumnProps};
7349
7350 KanbanBoardProps {
7351 columns: vec![
7352 KanbanColumnProps {
7353 id: "new".to_string(),
7354 title: "Nuovi".to_string(),
7355 count: 3,
7356 children: vec![ComponentNode::card(
7357 "card-1",
7358 CardProps {
7359 title: "Ordine #1".to_string(),
7360 description: None,
7361 children: vec![],
7362 footer: vec![],
7363 max_width: None,
7364 },
7365 )],
7366 },
7367 KanbanColumnProps {
7368 id: "progress".to_string(),
7369 title: "In corso".to_string(),
7370 count: 1,
7371 children: vec![ComponentNode::card(
7372 "card-2",
7373 CardProps {
7374 title: "Ordine #2".to_string(),
7375 description: None,
7376 children: vec![],
7377 footer: vec![],
7378 max_width: None,
7379 },
7380 )],
7381 },
7382 ],
7383 mobile_default_column: None,
7384 }
7385 }
7386
7387 #[test]
7388 fn test_render_kanban_board_desktop() {
7389 let props = make_kanban_props();
7390 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7391 let html = render_to_html(&view, &json!({}));
7392
7393 assert!(html.contains("hidden md:block"), "desktop wrapper present");
7394 assert!(html.contains("min-w-[260px]"), "column min width");
7395 assert!(html.contains("overflow-x-auto"), "scrollable container");
7396 assert!(html.contains("Nuovi"), "first column title");
7397 assert!(html.contains("In corso"), "second column title");
7398 assert!(
7399 html.contains("bg-primary text-primary-foreground"),
7400 "count badge styling"
7401 );
7402 assert!(html.contains(">3<"), "first column count");
7403 assert!(html.contains(">1<"), "second column count");
7404 }
7405
7406 #[test]
7407 fn test_render_kanban_board_mobile() {
7408 let props = make_kanban_props();
7409 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7410 let html = render_to_html(&view, &json!({}));
7411
7412 assert!(html.contains("block md:hidden"), "mobile wrapper present");
7413 assert!(html.contains("data-tabs"), "tab container attribute");
7414 assert!(html.contains("data-tab=\"new\""), "first tab button");
7415 assert!(html.contains("data-tab=\"progress\""), "second tab button");
7416 assert!(html.contains("data-tab-panel=\"new\""), "first tab panel");
7417 assert!(
7418 html.contains("data-tab-panel=\"progress\""),
7419 "second tab panel"
7420 );
7421 assert!(
7423 html.contains("aria-selected=\"true\""),
7424 "default tab selected"
7425 );
7426 assert!(
7427 html.contains("aria-selected=\"false\""),
7428 "non-default tab not selected"
7429 );
7430 }
7431
7432 #[test]
7433 fn test_render_kanban_board_custom_default_column() {
7434 let mut props = make_kanban_props();
7435 props.mobile_default_column = Some("progress".to_string());
7436 let view = JsonUiView::new().component(ComponentNode::kanban_board("kb", props));
7437 let html = render_to_html(&view, &json!({}));
7438
7439 assert!(
7442 !html.contains("data-tab-panel=\"progress\" class=\"space-y-3 hidden\""),
7443 "progress panel visible"
7444 );
7445 }
7446
7447 #[test]
7450 fn test_render_calendar_cell_today() {
7451 let props = CalendarCellProps {
7452 day: 15,
7453 is_today: true,
7454 is_current_month: true,
7455 event_count: 0,
7456 dot_colors: vec![],
7457 };
7458 let html = render_calendar_cell(&props);
7459 assert!(html.contains("bg-primary"), "today has bg-primary");
7460 assert!(
7461 html.contains("text-primary-foreground"),
7462 "today has foreground color"
7463 );
7464 assert!(html.contains("font-semibold"), "today is bold");
7465 assert!(html.contains("15"), "shows day number");
7466 }
7467
7468 #[test]
7469 fn test_render_calendar_cell_out_of_month() {
7470 let props = CalendarCellProps {
7471 day: 30,
7472 is_today: false,
7473 is_current_month: false,
7474 event_count: 0,
7475 dot_colors: vec![],
7476 };
7477 let html = render_calendar_cell(&props);
7478 assert!(html.contains("opacity-40"), "out-of-month has opacity");
7479 }
7480
7481 #[test]
7482 fn test_render_calendar_cell_events() {
7483 let props = CalendarCellProps {
7484 day: 5,
7485 is_today: false,
7486 is_current_month: true,
7487 event_count: 3,
7488 dot_colors: vec![],
7489 };
7490 let html = render_calendar_cell(&props);
7491 assert!(
7492 html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
7493 "shows event dots"
7494 );
7495 assert!(html.contains("flex gap-1"), "dots container present");
7496 }
7497
7498 #[test]
7499 fn test_render_calendar_cell_single_event_dot() {
7500 let props = CalendarCellProps {
7501 day: 5,
7502 is_today: false,
7503 is_current_month: true,
7504 event_count: 1,
7505 dot_colors: vec![],
7506 };
7507 let html = render_calendar_cell(&props);
7508 assert!(
7509 html.contains("w-1.5 h-1.5 rounded-full bg-primary"),
7510 "single event shows dot"
7511 );
7512 }
7513
7514 #[test]
7517 fn test_render_action_card_default() {
7518 let props = ActionCardProps {
7519 title: "Nuovo ordine".into(),
7520 description: "Crea un ordine".into(),
7521 icon: Some("📦".into()),
7522 variant: ActionCardVariant::Default,
7523 href: None,
7524 };
7525 let html = render_action_card(&props);
7526 assert!(
7527 html.contains("border-l-primary"),
7528 "default variant has primary border"
7529 );
7530 assert!(html.contains("Nuovo ordine"), "shows title");
7531 assert!(html.contains("Crea un ordine"), "shows description");
7532 assert!(html.contains("rsaquo"), "shows chevron");
7533 }
7534
7535 #[test]
7536 fn test_render_action_card_setup() {
7537 let props = ActionCardProps {
7538 title: "Configura".into(),
7539 description: "Completa la configurazione".into(),
7540 icon: None,
7541 variant: ActionCardVariant::Setup,
7542 href: None,
7543 };
7544 let html = render_action_card(&props);
7545 assert!(
7546 html.contains("border-l-warning"),
7547 "setup variant has warning border"
7548 );
7549 }
7550
7551 #[test]
7552 fn test_render_action_card_danger() {
7553 let props = ActionCardProps {
7554 title: "Elimina".into(),
7555 description: "Elimina questo elemento".into(),
7556 icon: None,
7557 variant: ActionCardVariant::Danger,
7558 href: None,
7559 };
7560 let html = render_action_card(&props);
7561 assert!(
7562 html.contains("border-l-destructive"),
7563 "danger variant has destructive border"
7564 );
7565 }
7566
7567 #[test]
7570 fn test_render_product_tile() {
7571 let props = ProductTileProps {
7572 product_id: "p1".into(),
7573 name: "Margherita".into(),
7574 price: "\u{20AC}8,50".into(),
7575 field: "qty_p1".into(),
7576 default_quantity: None,
7577 };
7578 let html = render_product_tile(&props);
7579 assert!(html.contains("Margherita"), "shows product name");
7580 assert!(html.contains("\u{20AC}8,50"), "shows price");
7581 assert!(
7582 html.contains("data-qty-inc=\"qty_p1\""),
7583 "inc button has data attr"
7584 );
7585 assert!(
7586 html.contains("data-qty-dec=\"qty_p1\""),
7587 "dec button has data attr"
7588 );
7589 assert!(
7590 html.contains("data-qty-display=\"qty_p1\""),
7591 "display span has data attr"
7592 );
7593 assert!(
7594 html.contains("data-qty-input=\"qty_p1\""),
7595 "hidden input has data attr"
7596 );
7597 assert!(html.contains("type=\"button\""), "buttons use type=button");
7598 assert!(html.contains("type=\"hidden\""), "hidden input present");
7599 assert!(html.contains("min-h-[44px]"), "44px touch target height");
7600 assert!(html.contains("min-w-[44px]"), "44px touch target width");
7601 assert!(
7602 html.contains("touch-manipulation"),
7603 "touch-manipulation on container"
7604 );
7605 assert!(html.contains("value=\"0\""), "default quantity is 0");
7606 }
7607
7608 #[test]
7609 fn test_render_product_tile_default_qty() {
7610 let props = ProductTileProps {
7611 product_id: "p2".into(),
7612 name: "Diavola".into(),
7613 price: "\u{20AC}10,00".into(),
7614 field: "qty_p2".into(),
7615 default_quantity: Some(2),
7616 };
7617 let html = render_product_tile(&props);
7618 assert!(html.contains("value=\"2\""), "default quantity is 2");
7619 assert!(html.contains(">2<"), "display shows 2");
7620 }
7621
7622 #[test]
7623 fn test_render_data_table_rows() {
7624 let props = DataTableProps {
7625 columns: vec![
7626 Column {
7627 key: "name".into(),
7628 label: "Nome".into(),
7629 format: None,
7630 },
7631 Column {
7632 key: "price".into(),
7633 label: "Prezzo".into(),
7634 format: None,
7635 },
7636 ],
7637 data_path: "items".into(),
7638 row_actions: None,
7639 empty_message: None,
7640 row_key: None,
7641 row_href: None,
7642 };
7643 let data = json!({
7644 "items": [
7645 {"name": "Margherita", "price": "8.50"},
7646 {"name": "Diavola", "price": "10.00"}
7647 ]
7648 });
7649 let html = render_data_table(&props, &data);
7650 assert!(html.contains("hidden md:block"), "desktop wrapper");
7651 assert!(html.contains("even:bg-surface"), "alternating rows");
7652 assert!(html.contains("block md:hidden"), "mobile wrapper");
7653 assert!(html.contains("uppercase"), "column header style");
7654 assert!(html.contains("Margherita"), "first row value");
7655 assert!(html.contains("Diavola"), "second row value");
7656 }
7657
7658 #[test]
7659 fn test_render_data_table_with_actions() {
7660 let props = DataTableProps {
7661 columns: vec![Column {
7662 key: "name".into(),
7663 label: "Nome".into(),
7664 format: None,
7665 }],
7666 data_path: "items".into(),
7667 row_actions: Some(vec![
7668 DropdownMenuAction {
7669 label: "Modifica".into(),
7670 action: Action {
7671 handler: "edit".into(),
7672 method: HttpMethod::Get,
7673 url: Some("/edit".into()),
7674 confirm: None,
7675 on_success: None,
7676 on_error: None,
7677 target: None,
7678 },
7679 destructive: false,
7680 },
7681 DropdownMenuAction {
7682 label: "Elimina".into(),
7683 action: Action {
7684 handler: "delete".into(),
7685 method: HttpMethod::Delete,
7686 url: Some("/delete".into()),
7687 confirm: None,
7688 on_success: None,
7689 on_error: None,
7690 target: None,
7691 },
7692 destructive: true,
7693 },
7694 ]),
7695 empty_message: None,
7696 row_key: Some("id".into()),
7697 row_href: None,
7698 };
7699 let data = json!({
7700 "items": [{"id": "p1", "name": "Margherita"}]
7701 });
7702 let html = render_data_table(&props, &data);
7703 assert!(
7704 html.contains("data-dropdown-toggle"),
7705 "DropdownMenu trigger present"
7706 );
7707 assert!(
7708 html.contains("text-destructive"),
7709 "destructive action in menu"
7710 );
7711 }
7712
7713 #[test]
7714 fn test_render_data_table_empty() {
7715 let props = DataTableProps {
7716 columns: vec![Column {
7717 key: "name".into(),
7718 label: "Nome".into(),
7719 format: None,
7720 }],
7721 data_path: "items".into(),
7722 row_actions: None,
7723 empty_message: None,
7724 row_key: None,
7725 row_href: None,
7726 };
7727 let data = json!({"items": []});
7728 let html = render_data_table(&props, &data);
7729 assert!(
7730 html.contains("Nessun elemento trovato"),
7731 "default empty message"
7732 );
7733 }
7734
7735 #[test]
7736 fn test_render_data_table_mobile_cards() {
7737 let props = DataTableProps {
7738 columns: vec![
7739 Column {
7740 key: "name".into(),
7741 label: "Nome".into(),
7742 format: None,
7743 },
7744 Column {
7745 key: "price".into(),
7746 label: "Prezzo".into(),
7747 format: None,
7748 },
7749 ],
7750 data_path: "items".into(),
7751 row_actions: None,
7752 empty_message: None,
7753 row_key: None,
7754 row_href: None,
7755 };
7756 let data = json!({
7757 "items": [
7758 {"name": "Margherita", "price": "8.50"},
7759 {"name": "Diavola", "price": "10.00"}
7760 ]
7761 });
7762 let html = render_data_table(&props, &data);
7763 assert!(html.contains("block md:hidden"), "mobile cards shown");
7764 assert!(
7765 html.contains("text-xs font-semibold text-text-muted uppercase"),
7766 "label styling"
7767 );
7768 }
7769
7770 #[test]
7773 fn test_render_modal_dialog() {
7774 let props = ModalProps {
7775 id: "modal-test".into(),
7776 title: "Test Title".into(),
7777 description: None,
7778 children: vec![],
7779 footer: vec![],
7780 trigger_label: Some("Open".into()),
7781 };
7782 let html = render_modal(&props, &serde_json::Value::Null);
7783 assert!(html.contains("<dialog"), "uses dialog element");
7784 assert!(html.contains("aria-modal=\"true\""), "has aria-modal");
7785 assert!(
7786 html.contains("aria-labelledby=\"modal-test-title\""),
7787 "has aria-labelledby"
7788 );
7789 assert!(
7790 html.contains("data-modal-open=\"modal-test\""),
7791 "trigger has data-modal-open"
7792 );
7793 assert!(html.contains("data-modal-close"), "has close button");
7794 assert!(
7795 html.contains("Chiudi"),
7796 "close button has Italian aria-label"
7797 );
7798 assert!(!html.contains("<details"), "no details element");
7799 assert!(!html.contains("<summary"), "no summary element");
7800 }
7801
7802 #[test]
7803 fn test_render_modal_with_description() {
7804 let props = ModalProps {
7805 id: "modal-desc".into(),
7806 title: "Title".into(),
7807 description: Some("A description".into()),
7808 children: vec![],
7809 footer: vec![],
7810 trigger_label: None,
7811 };
7812 let html = render_modal(&props, &serde_json::Value::Null);
7813 assert!(html.contains("A description"), "shows description");
7814 }
7815
7816 #[test]
7819 fn test_render_form_max_width_narrow() {
7820 let props = FormProps {
7821 action: Action::new("save"),
7822 fields: vec![],
7823 method: None,
7824 guard: None,
7825 max_width: Some(FormMaxWidth::Narrow),
7826 };
7827 let html = render_form(&props, &serde_json::Value::Null);
7828 assert!(html.contains("max-w-2xl"), "narrow form has max-w-2xl");
7829 assert!(html.contains("mx-auto"), "narrow form is centered");
7830 }
7831
7832 #[test]
7833 fn test_render_form_max_width_default() {
7834 let props = FormProps {
7835 action: Action::new("save"),
7836 fields: vec![],
7837 method: None,
7838 guard: None,
7839 max_width: None,
7840 };
7841 let html = render_form(&props, &serde_json::Value::Null);
7842 assert!(
7843 !html.contains("max-w-2xl"),
7844 "default form has no max-width wrapper"
7845 );
7846 }
7847
7848 #[test]
7849 fn test_render_form_section_two_column() {
7850 let props = FormSectionProps {
7851 title: "Section".into(),
7852 description: Some("Desc".into()),
7853 children: vec![],
7854 layout: Some(FormSectionLayout::TwoColumn),
7855 };
7856 let html = render_form_section(&props, &serde_json::Value::Null);
7857 assert!(html.contains("md:grid"), "two-column uses grid");
7858 assert!(
7859 html.contains("md:grid-cols-5"),
7860 "two-column uses 5-col grid"
7861 );
7862 assert!(html.contains("md:col-span-2"), "description takes 2 cols");
7863 assert!(html.contains("md:col-span-3"), "controls take 3 cols");
7864 }
7865
7866 #[test]
7869 fn test_render_input_with_error() {
7870 let props = InputProps {
7871 field: "email".into(),
7872 label: "Email".into(),
7873 input_type: InputType::Email,
7874 error: Some("Campo obbligatorio".into()),
7875 placeholder: None,
7876 default_value: None,
7877 data_path: None,
7878 required: None,
7879 disabled: None,
7880 step: None,
7881 description: None,
7882 list: None,
7883 };
7884 let html = render_input(&props, &serde_json::Value::Null);
7885 assert!(
7886 html.contains("aria-invalid=\"true\""),
7887 "input has aria-invalid"
7888 );
7889 assert!(
7890 html.contains("aria-describedby=\"err-email\""),
7891 "input has aria-describedby"
7892 );
7893 assert!(
7894 html.contains("id=\"err-email\""),
7895 "error paragraph has matching id"
7896 );
7897 assert!(
7898 html.contains("Campo obbligatorio"),
7899 "error message rendered"
7900 );
7901 }
7902
7903 #[test]
7904 fn test_render_input_hidden_no_label() {
7905 let props = InputProps {
7906 field: "csrf".into(),
7907 label: "".into(),
7908 input_type: InputType::Hidden,
7909 default_value: Some("token123".into()),
7910 error: None,
7911 placeholder: None,
7912 data_path: None,
7913 required: None,
7914 disabled: None,
7915 step: None,
7916 description: None,
7917 list: None,
7918 };
7919 let html = render_input(&props, &serde_json::Value::Null);
7920 assert!(!html.contains("<label"), "hidden input has no label");
7921 assert!(
7922 !html.contains("space-y-1"),
7923 "hidden input has no wrapper div"
7924 );
7925 assert!(html.contains("type=\"hidden\""), "hidden input present");
7926 }
7927
7928 #[test]
7931 fn test_render_switch_role_switch() {
7932 let props = SwitchProps {
7933 field: "active".into(),
7934 label: "Attivo".into(),
7935 description: None,
7936 checked: Some(true),
7937 data_path: None,
7938 required: None,
7939 disabled: None,
7940 error: None,
7941 action: None,
7942 };
7943 let html = render_switch(&props, &serde_json::Value::Null);
7944 assert!(html.contains("role=\"switch\""), "switch has role=switch");
7945 assert!(
7946 html.contains("aria-checked=\"true\""),
7947 "checked switch has aria-checked=true"
7948 );
7949 }
7950
7951 #[test]
7954 fn test_render_tabs_aria_attributes() {
7955 let props = TabsProps {
7956 default_tab: "general".into(),
7957 tabs: vec![
7958 Tab {
7959 value: "general".into(),
7960 label: "Generale".into(),
7961 children: vec![],
7962 },
7963 Tab {
7964 value: "advanced".into(),
7965 label: "Avanzate".into(),
7966 children: vec![],
7967 },
7968 ],
7969 };
7970 let html = render_tabs(&props, &serde_json::Value::Null);
7971 assert!(html.contains("id=\"tab-btn-general\""), "tab button has id");
7972 assert!(
7973 html.contains("aria-controls=\"tab-panel-general\""),
7974 "tab button has aria-controls"
7975 );
7976 assert!(
7977 html.contains("id=\"tab-panel-general\""),
7978 "tab panel has id"
7979 );
7980 assert!(
7981 html.contains("aria-labelledby=\"tab-btn-general\""),
7982 "tab panel has aria-labelledby"
7983 );
7984 }
7985
7986 #[test]
7989 fn test_render_collapsible_aria_expanded() {
7990 let props = CollapsibleProps {
7991 title: "Details".into(),
7992 expanded: false,
7993 children: vec![],
7994 };
7995 let html = render_collapsible(&props, &serde_json::Value::Null);
7996 assert!(
7997 html.contains("aria-expanded=\"false\""),
7998 "closed collapsible has aria-expanded=false"
7999 );
8000 }
8001
8002 #[test]
8005 fn test_render_action_card_with_href() {
8006 let props = ActionCardProps {
8007 title: "Nuovo ordine".into(),
8008 description: "Crea un ordine".into(),
8009 icon: None,
8010 variant: ActionCardVariant::Default,
8011 href: Some("/ordini/nuovo".into()),
8012 };
8013 let html = render_action_card(&props);
8014 assert!(
8015 html.contains("<a href=\"/ordini/nuovo\""),
8016 "card wraps in <a> with href"
8017 );
8018 assert!(
8019 html.contains("aria-label=\"Nuovo ordine\""),
8020 "card link has aria-label"
8021 );
8022 assert!(
8023 !html.contains("<div class=\"rounded"),
8024 "no div wrapper when href present"
8025 );
8026 }
8027
8028 #[test]
8029 fn test_render_action_card_without_href() {
8030 let props = ActionCardProps {
8031 title: "Test".into(),
8032 description: "Desc".into(),
8033 icon: None,
8034 variant: ActionCardVariant::Default,
8035 href: None,
8036 };
8037 let html = render_action_card(&props);
8038 assert!(
8039 html.contains("<div class=\"rounded"),
8040 "uses div when no href"
8041 );
8042 assert!(!html.contains("<a "), "no anchor when no href");
8043 }
8044
8045 #[test]
8046 fn test_render_button_type_button_default() {
8047 let props = ButtonProps {
8048 label: "Click".into(),
8049 variant: ButtonVariant::Default,
8050 size: Size::Default,
8051 disabled: None,
8052 icon: None,
8053 icon_position: None,
8054 button_type: None,
8055 };
8056 let html = render_button(&props);
8057 assert!(
8058 !html.contains("type=\""),
8059 "default omits type attribute so browser applies HTML default (submit in forms)"
8060 );
8061 }
8062
8063 #[test]
8064 fn test_render_button_type_button_explicit() {
8065 let props = ButtonProps {
8066 label: "Click".into(),
8067 variant: ButtonVariant::Default,
8068 size: Size::Default,
8069 disabled: None,
8070 icon: None,
8071 icon_position: None,
8072 button_type: Some(ButtonType::Button),
8073 };
8074 let html = render_button(&props);
8075 assert!(html.contains("type=\"button\""));
8076 }
8077
8078 #[test]
8079 fn test_render_button_type_submit() {
8080 let props = ButtonProps {
8081 label: "Salva".into(),
8082 variant: ButtonVariant::Default,
8083 size: Size::Default,
8084 disabled: None,
8085 icon: None,
8086 icon_position: None,
8087 button_type: Some(ButtonType::Submit),
8088 };
8089 let html = render_button(&props);
8090 assert!(
8091 html.contains("type=\"submit\""),
8092 "submit button type is submit"
8093 );
8094 }
8095
8096 #[test]
8097 fn data_table_row_actions_url_templating() {
8098 use crate::action::*;
8099 use crate::component::*;
8100 let props = DataTableProps {
8101 columns: vec![Column {
8102 key: "name".into(),
8103 label: "Name".into(),
8104 format: None,
8105 }],
8106 data_path: "items".into(),
8107 row_actions: Some(vec![DropdownMenuAction {
8108 label: "Delete".into(),
8109 action: {
8110 let mut a = Action::new("items.delete");
8111 a.url = Some("/items/{row_key}/delete".into());
8112 a.method = HttpMethod::Delete;
8113 a
8114 },
8115 destructive: true,
8116 }]),
8117 empty_message: None,
8118 row_key: Some("id".into()),
8119 row_href: None,
8120 };
8121 let data = serde_json::json!({ "items": [{"id": "42", "name": "Widget"}] });
8122 let html = render_data_table(&props, &data);
8123 assert!(
8124 html.contains("/items/42/delete"),
8125 "URL must have {{row_key}} replaced with actual row key value '42'"
8126 );
8127 assert!(
8128 !html.contains("{row_key}"),
8129 "No unreplaced {{row_key}} placeholders should remain"
8130 );
8131 }
8132}