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