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