Skip to main content

ferrox_front_ui/
doc_components.rs

1//! # Documentation Components & Design System Primitives (`ferrox-front-ui::doc_components`)
2//!
3//! Provides enterprise-grade, reusable documentation UI components built in pure Rust WebAssembly:
4//! 1. **Admonition / Callout**: Glassmorphic alert callouts (`Note`, `Tip`, `Warning`, `Danger`, `Caution`, `Info`).
5//! 2. **CodeBlock**: Interactive code blocks with language badge, optional filename, and native Wasm clipboard copy.
6//! 3. **TabGroup & TabPanel**: Reactive multi-panel tab switchers (e.g., Cargo / npm / pip / composer code blocks).
7//! 4. **TableOfContents (TOC)**: Right-hand sidebar navigation with scrollspy tracking and smooth scrolling.
8//! 5. **DocSidebar & NavTreeView**: Hierarchical collapsible documentation sidebar with category folding and active link tracking.
9
10use std::rc::Rc;
11use wasm_bindgen::prelude::*;
12use wasm_bindgen::JsCast;
13use web_sys::{window, ScrollIntoViewOptions, ScrollBehavior};
14use ferrox_front_core::dom::*;
15use ferrox_front_core::reactivity::Signal;
16use crate::icons::Icon;
17
18// =============================================================================
19// 1. ADMONITION / CALLOUT
20// =============================================================================
21
22/// Varieties of callout / admonition boxes.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AdmonitionType {
25    Note,
26    Tip,
27    Warning,
28    Danger,
29    Caution,
30    Info,
31}
32
33impl AdmonitionType {
34    pub fn default_title(&self) -> &'static str {
35        match self {
36            Self::Note => "NOTE",
37            Self::Tip => "TIP",
38            Self::Warning => "WARNING",
39            Self::Danger => "DANGER",
40            Self::Caution => "CAUTION",
41            Self::Info => "INFO",
42        }
43    }
44
45    /// Alias for default_title
46    pub fn title(&self) -> &'static str {
47        self.default_title()
48    }
49
50    pub fn icon_name(&self) -> &'static str {
51        match self {
52            Self::Note => "info",
53            Self::Tip => "zap",
54            Self::Warning => "alert-triangle",
55            Self::Danger => "alert-circle",
56            Self::Caution => "shield",
57            Self::Info => "info",
58        }
59    }
60
61    /// Color tokens: (accent_border, background_tint, text_color, icon_color)
62    pub fn style_tokens(&self) -> (&'static str, &'static str, &'static str, &'static str) {
63        match self {
64            Self::Note => ("#6366f1", "rgba(99, 102, 241, 0.08)", "#c7d2fe", "#818cf8"),
65            Self::Tip => ("#10b981", "rgba(16, 185, 129, 0.08)", "#a7f3d0", "#34d399"),
66            Self::Warning => ("#f59e0b", "rgba(245, 158, 11, 0.08)", "#fde68a", "#fbbf24"),
67            Self::Danger => ("#ef4444", "rgba(239, 68, 68, 0.08)", "#fecaca", "#f87171"),
68            Self::Caution => ("#f97316", "rgba(249, 115, 22, 0.08)", "#fed7aa", "#fb923c"),
69            Self::Info => ("#06b6d4", "rgba(6, 182, 212, 0.08)", "#a5f3fc", "#22d3ee"),
70        }
71    }
72
73    pub fn border_color(&self) -> &'static str {
74        self.style_tokens().0
75    }
76
77    pub fn bg_tint(&self) -> &'static str {
78        self.style_tokens().1
79    }
80
81    pub fn text_color(&self) -> &'static str {
82        self.style_tokens().2
83    }
84
85    pub fn icon_color(&self) -> &'static str {
86        self.style_tokens().3
87    }
88
89    pub fn from_str_loose(s: &str) -> Self {
90        match s.trim().to_lowercase().as_str() {
91            "tip" => Self::Tip,
92            "warning" => Self::Warning,
93            "danger" | "error" => Self::Danger,
94            "caution" => Self::Caution,
95            "info" => Self::Info,
96            _ => Self::Note,
97        }
98    }
99}
100
101/// Renders a design system Admonition / Callout box with glassmorphism styling.
102pub fn admonition(
103    kind: AdmonitionType,
104    title: Option<&str>,
105    content: DomBuilder,
106) -> DomBuilder {
107    let (border_col, bg_tint, text_col, icon_col) = kind.style_tokens();
108    let display_title = title.unwrap_or_else(|| kind.default_title());
109
110    let header = div()
111        .class("ferrox-admonition-header flex items-center gap-2 mb-2 font-bold text-xs tracking-wider uppercase")
112        .style_attr(&format!("color: {};", text_col))
113        .child(Icon::render(kind.icon_name(), 16, icon_col))
114        .child(span().text(display_title));
115
116    let body = div()
117        .class("ferrox-admonition-body text-sm leading-relaxed text-slate-300")
118        .child(content);
119
120    div()
121        .class("ferrox-admonition my-4 p-4 rounded-xl backdrop-blur-md transition-all duration-200")
122        .style_attr(&format!(
123            "background: {}; border: 1px solid rgba(255,255,255,0.06); border-left: 4px solid {}; box-shadow: 0 4px 20px -2px rgba(0,0,0,0.25);",
124            bg_tint, border_col
125        ))
126        .child(header)
127        .child(body)
128}
129
130pub fn admonition_note(title: Option<&str>, content: DomBuilder) -> DomBuilder {
131    admonition(AdmonitionType::Note, title, content)
132}
133
134pub fn admonition_tip(title: Option<&str>, content: DomBuilder) -> DomBuilder {
135    admonition(AdmonitionType::Tip, title, content)
136}
137
138pub fn admonition_warning(title: Option<&str>, content: DomBuilder) -> DomBuilder {
139    admonition(AdmonitionType::Warning, title, content)
140}
141
142pub fn admonition_danger(title: Option<&str>, content: DomBuilder) -> DomBuilder {
143    admonition(AdmonitionType::Danger, title, content)
144}
145
146pub fn admonition_caution(title: Option<&str>, content: DomBuilder) -> DomBuilder {
147    admonition(AdmonitionType::Caution, title, content)
148}
149
150pub fn admonition_info(title: Option<&str>, content: DomBuilder) -> DomBuilder {
151    admonition(AdmonitionType::Info, title, content)
152}
153
154// =============================================================================
155// 2. CODE BLOCK WITH NATIVE WASM CLIPBOARD COPY
156// =============================================================================
157
158/// Copies text to clipboard using the browser navigator.clipboard Web API.
159pub fn copy_to_clipboard(text: &str) {
160    if let Some(win) = window() {
161        let nav = win.navigator();
162        let clip = nav.clipboard();
163        let _ = clip.write_text(text);
164    }
165}
166
167/// Interactive CodeBlock with language badge, optional filename, and live copy-to-clipboard feedback.
168pub fn code_block(
169    code: &str,
170    lang: Option<&str>,
171    title: Option<&str>,
172) -> DomBuilder {
173    code_block_highlighted(None, code, lang, title)
174}
175
176/// Interactive CodeBlock supporting pre-rendered syntax-highlighted HTML or plain formatted code.
177pub fn code_block_highlighted(
178    highlighted_html: Option<&str>,
179    raw_code: &str,
180    lang: Option<&str>,
181    title: Option<&str>,
182) -> DomBuilder {
183    let raw_code_owned = raw_code.to_string();
184    let copied_signal = Signal::new(false);
185    let lang_display = lang.unwrap_or("text").to_uppercase();
186
187    // Unique ID for copy button
188    let btn_id = format!("copy-btn-{}", (js_sys::Math::random() * 1_000_000.0) as u32);
189    let btn_id_clone = btn_id.clone();
190    let code_to_copy = raw_code_owned.clone();
191    let copied_signal_clone = copied_signal.clone();
192
193    // Copy button
194    let copy_btn = button()
195        .id(&btn_id)
196        .class("ferrox-code-copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-semibold text-slate-400 hover:text-white bg-slate-800/60 hover:bg-slate-700/80 border border-slate-700/50 transition-all duration-150 cursor-pointer user-select-none")
197        .children(vec![
198            Icon::render("copy", 13, "#94a3b8"),
199            span().text("Copy"),
200        ])
201        .on_click(move || {
202            copy_to_clipboard(&code_to_copy);
203            copied_signal_clone.set(true);
204
205            // Directly update button label for instantaneous user feedback
206            if let Some(doc) = window().and_then(|w| w.document()) {
207                if let Some(el) = doc.get_element_by_id(&btn_id_clone) {
208                    el.set_inner_html(
209                        r##"<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="ferrox-icon" style="display: inline-block; vertical-align: middle;"><polyline points="20 6 9 17 4 12"></polyline></svg><span style="color: #10b981;">Copied!</span>"##
210                    );
211                    
212                    let id_restore = btn_id_clone.clone();
213                    let cb = Closure::wrap(Box::new(move || {
214                        if let Some(d) = window().and_then(|w| w.document()) {
215                            if let Some(b) = d.get_element_by_id(&id_restore) {
216                                b.set_inner_html(
217                                    r##"<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ferrox-icon" style="display: inline-block; vertical-align: middle;"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg><span>Copy</span>"##
218                                );
219                            }
220                        }
221                    }) as Box<dyn FnMut()>);
222                    
223                    if let Some(w) = window() {
224                        let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 2000);
225                        cb.forget();
226                    }
227                }
228            }
229        });
230
231    // Top Header Bar
232    let mut header_left = div().class("flex items-center gap-2");
233    
234    // Optional window control dots
235    let dots = div()
236        .class("flex items-center gap-1.5 mr-2")
237        .children(vec![
238            span().class("w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block"),
239            span().class("w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block"),
240            span().class("w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block"),
241        ]);
242    header_left = header_left.child(dots);
243
244    if let Some(t) = title {
245        header_left = header_left.child(
246            span().class("text-xs font-mono font-medium text-slate-300").text(t)
247        );
248    }
249
250    // Language Badge
251    let lang_badge = span()
252        .class("px-2 py-0.5 rounded text-[10px] font-mono font-bold tracking-wider bg-slate-800 text-indigo-300 border border-indigo-500/20")
253        .text(&lang_display);
254
255    let header = div()
256        .class("ferrox-code-header flex items-center justify-between px-4 py-2 border-b border-slate-800/80 bg-slate-900/70")
257        .children(vec![
258            header_left,
259            div().class("flex items-center gap-2.5").child(lang_badge).child(copy_btn),
260        ]);
261
262    // Code Content
263    let code_el = if let Some(html) = highlighted_html {
264        div()
265            .class("p-5 overflow-x-auto text-sm sm:text-base font-mono leading-relaxed text-slate-200 whitespace-pre")
266            .inner_html(html)
267    } else {
268        div()
269            .class("p-5 overflow-x-auto text-sm sm:text-base font-mono leading-relaxed text-slate-200 whitespace-pre")
270            .text(&raw_code_owned)
271    };
272
273    div()
274        .class("ferrox-code-block my-6 rounded-2xl overflow-hidden bg-slate-950/80 border border-slate-800/80 shadow-2xl backdrop-blur-xl transition-all duration-200")
275        .child(header)
276        .child(code_el)
277}
278
279// =============================================================================
280// 3. REACTIVE TAB GROUP & TAB PANELS
281// =============================================================================
282
283/// Single tab panel definition.
284pub struct TabPanelDef {
285    pub id: String,
286    pub label: String,
287    pub icon: Option<String>,
288    pub content: DomBuilder,
289}
290
291impl TabPanelDef {
292    pub fn new(id: impl Into<String>, label: impl Into<String>, content: DomBuilder) -> Self {
293        Self {
294            id: id.into(),
295            label: label.into(),
296            icon: None,
297            content,
298        }
299    }
300
301    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
302        self.icon = Some(icon.into());
303        self
304    }
305}
306
307static TAB_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1000);
308
309/// Renders a dynamic, reactive TabGroup for multi-panel code snippets or documentation sections.
310pub fn tab_group(panels: Vec<TabPanelDef>, default_index: usize) -> DomBuilder {
311    let group_id = format!("tabgroup-{}", TAB_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
312    let active_index = Signal::new(default_index.min(panels.len().saturating_sub(1)));
313
314    let mut header_bar = div()
315        .class("ferrox-tabgroup-header flex items-center gap-1.5 p-1.5 rounded-xl bg-slate-900/60 border border-slate-800/80 mb-3 backdrop-blur-md");
316
317    let mut panels_container = div().class("ferrox-tabgroup-panels w-full");
318
319    let count = panels.len();
320
321    for (idx, panel) in panels.into_iter().enumerate() {
322        let is_active = idx == active_index.get();
323        let idx_owned = idx;
324        let group_id_clone = group_id.clone();
325
326        let mut tab_btn = button()
327            .id(&format!("{}-tab-{}", group_id, idx))
328            .class(&format!(
329                "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all duration-150 cursor-pointer {}",
330                if is_active {
331                    "bg-indigo-600 text-white shadow-md font-bold"
332                } else {
333                    "text-slate-400 hover:text-white hover:bg-slate-800/60"
334                }
335            ))
336            .on_click(move || {
337                if let Some(doc) = window().and_then(|w| w.document()) {
338                    for i in 0..count {
339                        if let Some(btn) = doc.get_element_by_id(&format!("{}-tab-{}", group_id_clone, i)) {
340                            if i == idx_owned {
341                                btn.set_attribute("class", "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-md cursor-pointer transition-all duration-150").unwrap();
342                            } else {
343                                btn.set_attribute("class", "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800/60 cursor-pointer transition-all duration-150").unwrap();
344                            }
345                        }
346                        if let Some(p) = doc.get_element_by_id(&format!("{}-panel-{}", group_id_clone, i)) {
347                            if i == idx_owned {
348                                p.set_attribute("style", "display: block;").unwrap();
349                            } else {
350                                p.set_attribute("style", "display: none;").unwrap();
351                            }
352                        }
353                    }
354                }
355            });
356
357        if let Some(ref ico) = panel.icon {
358            tab_btn = tab_btn.child(Icon::render(ico, 14, if is_active { "#ffffff" } else { "#94a3b8" }));
359        }
360
361        tab_btn = tab_btn.child(span().text(&panel.label));
362        header_bar = header_bar.child(tab_btn);
363
364        // Panel Body
365        let panel_body = div()
366            .id(&format!("{}-panel-{}", group_id, idx))
367            .class("ferrox-tab-panel w-full transition-opacity duration-150")
368            .style_attr(if is_active { "display: block;" } else { "display: none;" })
369            .child(panel.content);
370
371        panels_container = panels_container.child(panel_body);
372    }
373
374    div()
375        .id(&group_id)
376        .class("ferrox-tab-group my-4 w-full")
377        .child(header_bar)
378        .child(panels_container)
379}
380
381// =============================================================================
382// 4. TABLE OF CONTENTS (TOC) WITH SCROLLSPY
383// =============================================================================
384
385/// Single Table of Contents Heading item.
386#[derive(Debug, Clone)]
387pub struct TocItem {
388    pub id: String,
389    pub title: String,
390    pub level: u32,
391}
392
393impl TocItem {
394    pub fn new(id: impl Into<String>, title: impl Into<String>, level: u32) -> Self {
395        Self {
396            id: id.into(),
397            title: title.into(),
398            level,
399        }
400    }
401}
402
403/// Smoothly scrolls the window to an element by its HTML anchor ID.
404pub fn scroll_to_anchor(id: &str) {
405    if let Some(win) = window() {
406        if let Some(doc) = win.document() {
407            if let Some(el) = doc.get_element_by_id(id) {
408                let options = ScrollIntoViewOptions::new();
409                options.set_behavior(ScrollBehavior::Smooth);
410                el.scroll_into_view_with_scroll_into_view_options(&options);
411                
412                // Update URL hash without causing a page jump
413                if let Ok(hist) = win.history() {
414                    let _ = hist.replace_state_with_url(&JsValue::NULL, "", Some(&format!("#{}", id)));
415                }
416            }
417        }
418    }
419}
420
421/// Dynamic Table of Contents component with active anchor highlight and smooth scrolling.
422pub fn table_of_contents(
423    items: Vec<TocItem>,
424    active_id: Signal<Option<String>>,
425) -> DomBuilder {
426    let mut nav_list = div().class("flex flex-col space-y-1.5 text-sm font-medium");
427
428    let cur_active = active_id.get();
429
430    for item in items {
431        let is_active = cur_active.as_deref() == Some(&item.id);
432        let id_owned = item.id.clone();
433        let indent_class = match item.level {
434            1 | 2 => "pl-3",
435            3 => "pl-5",
436            4 => "pl-7",
437            _ => "pl-9",
438        };
439
440        let link_class = if is_active {
441            "text-indigo-400 font-semibold border-l-2 border-indigo-500 -ml-[1px]"
442        } else {
443            "text-slate-400 hover:text-slate-200 border-l-2 border-transparent -ml-[1px]"
444        };
445
446        let anchor_link = div()
447            .class(&format!(
448                "py-1.5 {} {} transition-all duration-150 cursor-pointer flex items-center justify-between",
449                indent_class, link_class
450            ))
451            .child(span().class("break-words leading-snug").text(&item.title))
452            .on_click(move || {
453                scroll_to_anchor(&id_owned);
454            });
455
456        nav_list = nav_list.child(anchor_link);
457    }
458
459    div()
460        .class("ferrox-toc w-full py-4 px-3 border-l border-slate-800/80")
461        .children(vec![
462            div()
463                .class("flex items-center gap-2 mb-3.5 text-xs font-bold uppercase tracking-wider text-slate-400")
464                .children(vec![
465                    Icon::render("layers", 15, "#818cf8"),
466                    span().text("On this page"),
467                ]),
468            nav_list,
469        ])
470}
471
472// =============================================================================
473// 5. DOCUMENTATION SIDEBAR (NAV TREE VIEW)
474// =============================================================================
475
476/// Single navigation item inside the doc sidebar.
477#[derive(Clone)]
478pub struct DocNavItem {
479    pub id: String,
480    pub label: String,
481    pub href: Option<String>,
482    pub icon: Option<String>,
483    pub badge: Option<String>,
484    pub is_expanded: Signal<bool>,
485    pub children: Vec<DocNavItem>,
486}
487
488impl DocNavItem {
489    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
490        Self {
491            id: id.into(),
492            label: label.into(),
493            href: None,
494            icon: None,
495            badge: None,
496            is_expanded: Signal::new(false),
497            children: Vec::new(),
498        }
499    }
500
501    pub fn has_active_child(&self, current_path: &str) -> bool {
502        let clean_cur = current_path.trim_start_matches('/');
503        if let Some(ref h) = self.href {
504            if h.trim_start_matches('/') == clean_cur {
505                return true;
506            }
507        }
508        for child in &self.children {
509            if child.has_active_child(current_path) {
510                return true;
511            }
512        }
513        false
514    }
515
516    pub fn with_href(mut self, href: impl Into<String>) -> Self {
517        self.href = Some(href.into());
518        self
519    }
520
521    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
522        self.icon = Some(icon.into());
523        self
524    }
525
526    pub fn with_badge(mut self, badge: impl Into<String>) -> Self {
527        self.badge = Some(badge.into());
528        self
529    }
530
531    pub fn with_children(mut self, children: Vec<DocNavItem>) -> Self {
532        self.children = children;
533        self
534    }
535
536    pub fn expanded(self, exp: bool) -> Self {
537        self.is_expanded.set(exp);
538        self
539    }
540}
541
542/// Recursively renders a documentation navigation item.
543fn render_doc_nav_item(
544    item: &DocNavItem,
545    current_path: &str,
546    depth: usize,
547    on_navigate: &Rc<dyn Fn(&str)>,
548) -> DomBuilder {
549    let has_children = !item.children.is_empty();
550    let clean_cur = current_path.trim_start_matches('/');
551
552    // On home /overview, all menus start closed!
553    // When navigating to a specific document inside a section, keep that branch expanded.
554    let has_active = if clean_cur.is_empty() || clean_cur == "overview" {
555        false
556    } else {
557        item.has_active_child(current_path)
558    };
559
560    let is_exp = item.is_expanded.get() || has_active;
561    let is_active = item.href.as_deref().map(|h| h.trim_start_matches('/') == clean_cur).unwrap_or(false);
562
563    let on_nav_clone = on_navigate.clone();
564    let href_opt = item.href.clone();
565
566    // Unique DOM IDs for this expandable category
567    let clean_id = item.id.replace('/', "-").replace(' ', "-").replace(':', "-");
568    let box_id = format!("doc-nav-box-{}", clean_id);
569    let chev_id = format!("doc-nav-chev-{}", clean_id);
570    let box_id_clone = box_id.clone();
571    let chev_id_clone = chev_id.clone();
572
573    let (row_class, icon_size) = if depth == 0 {
574        (
575            format!(
576                "flex items-center justify-between py-2.5 px-3.5 rounded-xl cursor-pointer transition-all duration-150 text-[15px] font-semibold tracking-tight {}",
577                if is_active {
578                    "bg-indigo-600/25 text-indigo-300 border border-indigo-500/40 shadow-sm"
579                } else {
580                    "text-slate-200 hover:text-white hover:bg-slate-900/90 border border-transparent"
581                }
582            ),
583            16,
584        )
585    } else if has_children {
586        (
587            format!(
588                "flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-all duration-150 text-sm font-medium {}",
589                if is_active {
590                    "bg-indigo-600/20 text-indigo-300 font-semibold border border-indigo-500/30 shadow-sm"
591                } else {
592                    "text-slate-300 hover:text-white hover:bg-slate-800/70 border border-transparent"
593                }
594            ),
595            15,
596        )
597    } else {
598        (
599            format!(
600                "flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-all duration-150 text-sm {}",
601                if is_active {
602                    "bg-indigo-600/25 text-indigo-300 font-semibold border border-indigo-500/30 shadow-sm"
603                } else {
604                    "text-slate-400 hover:text-slate-100 hover:bg-slate-800/60 border border-transparent font-normal"
605                }
606            ),
607            14,
608        )
609    };
610
611    let mut row = div()
612        .class(&row_class)
613        .style_attr(&format!("padding-left: {}px;", depth * 16 + 12))
614        .on_click(move || {
615            if has_children {
616                if let Some(doc) = window().and_then(|w| w.document()) {
617                    if let Some(b_el) = doc.get_element_by_id(&box_id_clone) {
618                        let style_str = b_el.get_attribute("style").unwrap_or_default();
619                        if style_str.contains("display: none") {
620                            let _ = b_el.set_attribute("style", "display: flex; flex-direction: column;");
621                            if let Some(c_el) = doc.get_element_by_id(&chev_id_clone) {
622                                let _ = c_el.set_attribute("style", "transform: rotate(90deg);");
623                            }
624                        } else {
625                            let _ = b_el.set_attribute("style", "display: none;");
626                            if let Some(c_el) = doc.get_element_by_id(&chev_id_clone) {
627                                let _ = c_el.set_attribute("style", "transform: rotate(0deg);");
628                            }
629                        }
630                    }
631                }
632            }
633            if let Some(ref h) = href_opt {
634                on_nav_clone(h);
635            }
636        });
637
638    let mut left = div().class("flex items-center gap-2.5 min-w-0");
639
640    if has_children {
641        let chevron_style = if is_exp { "transform: rotate(90deg);" } else { "transform: rotate(0deg);" };
642        left = left.child(
643            span()
644                .id(&chev_id)
645                .class("w-4 h-4 flex items-center justify-center shrink-0 transition-transform duration-150")
646                .style_attr(chevron_style)
647                .child(Icon::render("chevron-right", 14, "#94a3b8"))
648        );
649    } else {
650        left = left.child(span().class("w-4 h-4 shrink-0"));
651    }
652
653    if let Some(ref ico) = item.icon {
654        left = left.child(Icon::render(ico, icon_size, if is_active { "#818cf8" } else { "#94a3b8" }));
655    }
656
657    left = left.child(span().class("truncate").text(&item.label));
658    row = row.child(left);
659
660    if let Some(ref b) = item.badge {
661        row = row.child(
662            span()
663                .class("shrink-0 ml-2 px-2 py-0.5 rounded text-[11px] font-mono font-bold bg-slate-800 text-indigo-300 border border-indigo-500/25 shadow-sm")
664                .text(b)
665        );
666    }
667
668    let mut container = div().class("flex flex-col").child(row);
669
670    if has_children {
671        let initial_display = if is_exp { "display: flex; flex-direction: column;" } else { "display: none;" };
672        let mut children_box = div()
673            .id(&box_id)
674            .class("space-y-0.5 mt-0.5")
675            .style_attr(initial_display);
676        for child in &item.children {
677            children_box = children_box.child(render_doc_nav_item(child, current_path, depth + 1, on_navigate));
678        }
679        container = container.child(children_box);
680    }
681
682    container
683}
684
685/// Renders a full collapsible documentation navigation sidebar.
686pub fn doc_sidebar(
687    items: Vec<DocNavItem>,
688    current_path: Signal<String>,
689    on_navigate: impl Fn(&str) + 'static,
690) -> DomBuilder {
691    let on_nav_rc: Rc<dyn Fn(&str)> = Rc::new(on_navigate);
692    let cur = current_path.get();
693
694    let mut root = div().class("ferrox-doc-sidebar w-full flex flex-col space-y-1 p-2");
695
696    for item in &items {
697        root = root.child(render_doc_nav_item(item, &cur, 0, &on_nav_rc));
698    }
699
700    root
701}
702
703// =============================================================================
704// 6. MERMAID DIAGRAM CONTAINER
705// =============================================================================
706
707/// Renders a dedicated interactive Mermaid vector SVG container with pan, zoom, and fullscreen capabilities.
708pub fn mermaid_container(raw_code: &str, title: Option<&str>) -> DomBuilder {
709    let title_str = title.unwrap_or("Mermaid SVG Vector Diagram");
710
711    div()
712        .class("ferrox-mermaid group relative my-6 flex flex-col items-center justify-center p-4 md:p-5 rounded-2xl bg-slate-900/70 border border-slate-800/90 shadow-2xl backdrop-blur-xl transition-all duration-200")
713        .children(vec![
714            // Header with title and interactive toolbar controls
715            div()
716                .class("w-full flex flex-wrap items-center justify-between pb-3 mb-3 border-b border-slate-800/80 text-xs font-mono text-indigo-400 font-semibold gap-2")
717                .children(vec![
718                    span().class("flex items-center gap-1.5").children(vec![
719                        span().text("⚡"),
720                        span().text(title_str),
721                    ]),
722                    div().class("flex items-center gap-1.5 flex-wrap").children(vec![
723                        // Zoom percentage badge
724                        span()
725                            .class("ferrox-mermaid-zoom-badge px-2 py-0.5 rounded text-[10px] bg-slate-950/80 text-cyan-300 border border-cyan-500/30 font-mono shadow-inner")
726                            .text("100%"),
727                        // Zoom in button
728                        button()
729                            .class("ferrox-mermaid-btn ferrox-mermaid-zoom-in px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs font-bold transition-all cursor-pointer shadow-sm")
730                            .attr("title", "Zoom In (+25%)")
731                            .attr("aria-label", "Zoom In")
732                            .text("+"),
733                        // Zoom out button
734                        button()
735                            .class("ferrox-mermaid-btn ferrox-mermaid-zoom-out px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs font-bold transition-all cursor-pointer shadow-sm")
736                            .attr("title", "Zoom Out (-25%)")
737                            .attr("aria-label", "Zoom Out")
738                            .text("-"),
739                        // Reset button
740                        button()
741                            .class("ferrox-mermaid-btn ferrox-mermaid-reset px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs transition-all cursor-pointer shadow-sm flex items-center gap-1")
742                            .attr("title", "Reset Zoom & Pan")
743                            .attr("aria-label", "Reset Zoom and Pan")
744                            .text("↺ Reset"),
745                        // Fullscreen toggle button
746                        button()
747                            .class("ferrox-mermaid-btn ferrox-mermaid-fullscreen px-2.5 py-1 rounded-lg bg-indigo-950/60 hover:bg-indigo-900/80 active:scale-95 text-indigo-300 border border-indigo-500/40 text-xs transition-all cursor-pointer shadow-sm flex items-center gap-1")
748                            .attr("title", "Toggle Fullscreen View")
749                            .attr("aria-label", "Toggle Fullscreen View")
750                            .text("⛶ Expand"),
751                        span()
752                            .class("hidden sm:inline-block px-2 py-0.5 rounded text-[10px] bg-slate-800/80 text-indigo-300 border border-indigo-500/20")
753                            .text("Vector SVG"),
754                    ]),
755                ]),
756            // Interactive Viewport with Pan & Zoom Canvas
757            div()
758                .class("ferrox-mermaid-viewport relative w-full overflow-hidden rounded-xl bg-slate-950/60 border border-slate-800/60 flex items-center justify-center min-h-[240px] select-none")
759                .attr("style", "touch-action: none; cursor: grab;")
760                .children(vec![
761                    div()
762                        .class("ferrox-mermaid-canvas w-full flex items-center justify-center p-2")
763                        .attr("style", "transform-origin: center center; will-change: transform;")
764                        .children(vec![
765                            div()
766                                .class("ferrox-mermaid-raw text-slate-200 text-sm font-mono w-full flex justify-center")
767                                .text(raw_code),
768                        ]),
769                    // Interactive navigation hint overlay
770                    div()
771                        .class("ferrox-mermaid-hint absolute bottom-2 right-2 text-[10px] font-mono text-slate-400 bg-slate-900/90 px-2.5 py-1 rounded-md border border-slate-800 pointer-events-none opacity-50 group-hover:opacity-100 transition-opacity backdrop-blur-sm")
772                        .text("Drag to pan • Scroll to zoom"),
773                ]),
774        ])
775}
776
777// =============================================================================
778// 7. INSTALLATION TABS SWITCHER
779// =============================================================================
780
781/// Builds a ready-made interactive installation tabs switcher (Cargo / npm / pip / composer).
782pub fn installation_tabs(
783    cargo: Option<&str>,
784    npm: Option<&str>,
785    pip: Option<&str>,
786    composer: Option<&str>,
787) -> DomBuilder {
788    let mut panels = Vec::new();
789
790    if let Some(cmd) = cargo {
791        panels.push(
792            TabPanelDef::new(
793                "install-cargo",
794                "Cargo (Rust)",
795                code_block(cmd, Some("bash"), Some("Cargo")),
796            )
797            .with_icon("terminal")
798        );
799    }
800
801    if let Some(cmd) = npm {
802        panels.push(
803            TabPanelDef::new(
804                "install-npm",
805                "npm (Node.js)",
806                code_block(cmd, Some("bash"), Some("npm")),
807            )
808            .with_icon("terminal")
809        );
810    }
811
812    if let Some(cmd) = pip {
813        panels.push(
814            TabPanelDef::new(
815                "install-pip",
816                "pip (Python)",
817                code_block(cmd, Some("bash"), Some("pip")),
818            )
819            .with_icon("terminal")
820        );
821    }
822
823    if let Some(cmd) = composer {
824        panels.push(
825            TabPanelDef::new(
826                "install-composer",
827                "Composer (PHP)",
828                code_block(cmd, Some("bash"), Some("composer")),
829            )
830            .with_icon("terminal")
831        );
832    }
833
834    tab_group(panels, 0)
835}
836
837// =============================================================================
838// 8. MULTI-LANGUAGE (i18n) SELECTOR
839// =============================================================================
840
841/// Renders a glassmorphic language switcher with support for ecosystem locales.
842pub fn language_selector(
843    current_lang: &str,
844    on_select: impl Fn(String) + 'static,
845) -> DomBuilder {
846    let languages = vec![
847        ("en", "English 🇺🇸"),
848        ("it", "Italiano 🇮🇹"),
849        ("es", "Español 🇪🇸"),
850        ("fr", "Français 🇫🇷"),
851        ("de", "Deutsch 🇩🇪"),
852        ("zh", "中文 🇨🇳"),
853        ("ja", "日本語 🇯🇵"),
854        ("ko", "한국어 🇰🇷"),
855    ];
856
857    let on_sel_rc: Rc<dyn Fn(String)> = Rc::new(on_select);
858
859    let mut select_el = select()
860        .class("bg-slate-900/80 border border-slate-700/60 rounded-xl px-2.5 py-1 text-xs font-medium text-slate-200 focus:outline-none focus:border-indigo-500 shadow-sm cursor-pointer");
861
862    for (code, label) in languages {
863        let is_selected = code.eq_ignore_ascii_case(current_lang);
864        let mut opt = option().attr("value", code).text(label);
865        if is_selected {
866            opt = opt.attr("selected", "selected");
867        }
868        select_el = select_el.child(opt);
869    }
870
871    let on_change = on_sel_rc.clone();
872    select_el = select_el.on("change", move |e: web_sys::Event| {
873        if let Some(target) = e.target() {
874            if let Some(sel) = target.dyn_ref::<web_sys::HtmlSelectElement>() {
875                on_change(sel.value());
876            }
877        }
878    });
879
880    div()
881        .class("ferrox-lang-selector flex items-center gap-1.5")
882        .child(span().class("text-xs text-slate-400").text("🌐"))
883        .child(select_el)
884}
885
886// =============================================================================
887// 9. WASM INTERACTIVE SANDBOX PRIMITIVES
888// =============================================================================
889
890/// Renders a responsive glassmorphic sandbox frame for live WebAssembly playgrounds.
891pub fn sandbox_container(
892    title: &str,
893    badge: &str,
894    icon_name: &str,
895    description: &str,
896    content: DomBuilder,
897) -> DomBuilder {
898    let header = div()
899        .class("flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-5 border-b border-slate-800/80 bg-slate-900/60 backdrop-blur-xl rounded-t-2xl")
900        .children(vec![
901            div().class("flex items-center gap-3").children(vec![
902                div()
903                    .class("w-9 h-9 rounded-xl flex items-center justify-center bg-indigo-500/20 text-indigo-400 border border-indigo-500/30")
904                    .child(Icon::render(icon_name, 18, "#818cf8")),
905                div().children(vec![
906                    div().class("flex items-center gap-2").children(vec![
907                        h3().class("text-base font-bold text-white tracking-tight").text(title),
908                        span().class("px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30").text(badge),
909                    ]),
910                    p().class("text-xs text-slate-400 mt-0.5").text(description),
911                ]),
912            ]),
913            div().class("flex items-center gap-2").children(vec![
914                span().class("flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-mono font-medium bg-slate-800/80 text-indigo-300 border border-slate-700/60").children(vec![
915                    span().class("w-2 h-2 rounded-full bg-emerald-400 animate-pulse"),
916                    span().text("LIVE WASM ENGINE"),
917                ]),
918            ]),
919        ]);
920
921    div()
922        .class("ferrox-sandbox-frame my-10 rounded-2xl border border-indigo-500/30 bg-slate-950/80 shadow-2xl shadow-indigo-950/40 overflow-hidden")
923        .children(vec![header, div().class("p-5 sm:p-6").child(content)])
924}
925
926/// Renders a dynamic password strength gauge bar with colored gradient fill.
927pub fn password_strength_bar(entropy_percent: f64) -> DomBuilder {
928    let clamped = entropy_percent.clamp(0.0, 100.0);
929    let (label, color_class, width_style) = if clamped < 25.0 {
930        ("Weak (Low Entropy)", "bg-rose-500 shadow-rose-500/50", "25%")
931    } else if clamped < 50.0 {
932        ("Fair (Basic)", "bg-amber-500 shadow-amber-500/50", "50%")
933    } else if clamped < 75.0 {
934        ("Strong (Recommended)", "bg-cyan-500 shadow-cyan-500/50", "75%")
935    } else {
936        ("Cyber-Secured (High Entropy)", "bg-emerald-500 shadow-emerald-500/50", "100%")
937    };
938
939    div()
940        .class("flex flex-col gap-1.5 w-full mt-2")
941        .children(vec![
942            div().class("flex justify-between items-center text-xs font-mono font-semibold").children(vec![
943                span().class("text-slate-400").text("Strength:"),
944                span().class("text-slate-200").text(label),
945            ]),
946            div()
947                .class("w-full h-2 rounded-full bg-slate-800 overflow-hidden")
948                .child(
949                    div()
950                        .class(&format!("h-full rounded-full transition-all duration-300 shadow-sm {}", color_class))
951                        .attr("style", &format!("width: {};", width_style))
952                ),
953        ])
954}
955
956/// Renders a telemetry metric card with title, big counter, delta, and icon.
957pub fn telemetry_metric_card(
958    label: &str,
959    value: &str,
960    change: Option<&str>,
961    icon_name: &str,
962    color_hex: &str,
963) -> DomBuilder {
964    let mut val_col = div().children(vec![
965        span().class("text-xs font-bold uppercase tracking-wider text-slate-400").text(label),
966        div().class("text-2xl font-black text-white font-mono mt-1").text(value),
967    ]);
968
969    if let Some(ch) = change {
970        val_col = val_col.child(span().class("text-xs font-semibold text-emerald-400").text(ch));
971    }
972
973    div()
974        .class("p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 flex items-center justify-between shadow-lg")
975        .children(vec![
976            val_col,
977            div()
978                .class("w-10 h-10 rounded-xl flex items-center justify-center bg-slate-800/80 border border-slate-700/50")
979                .child(Icon::render(icon_name, 20, color_hex)),
980        ])
981}
982
983