Skip to main content

ferrox_front_ui/
components.rs

1//! # Glassmorphism & Enterprise UI Components (`ferrox-front-ui::components`)
2//!
3//! Provides a comprehensive component library with Shadcn/Reactstrap feature parity:
4//! - Form Controls (Inputs with icons/validity, Textareas with counters, Switches, Custom Checkboxes/Radios, Selects, Sliders)
5//! - Surfaces & Overlays (Modals with sizing, Drawers/Offcanvas, Accordions, Tabs, Tooltips, Popovers, Toasts, Skeletons)
6//! - Navigation (Navbar, Collapsible Sidebar, Breadcrumbs, Pagination)
7//! - Buttons & Button Groups (All variants, sizes, loading spinners)
8
9use ferrox_front_core::dom::{
10    div, span, button as dom_button, input as dom_input, textarea as dom_textarea,
11    select as dom_select, option as dom_option, label as dom_label, nav as dom_nav,
12    a as dom_a, svg, circle, DomBuilder
13};
14use crate::icons::Icon;
15
16// =============================================================================
17// 1. BUTTONS & BUTTON GROUPS
18// =============================================================================
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ButtonVariant {
22    Primary,
23    Secondary,
24    Outline,
25    Glass,
26    Danger,
27    Ghost,
28    Link,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ButtonSize {
33    Xs,
34    Sm,
35    Md,
36    Lg,
37}
38
39/// Renders a Design System Button with variants: "primary", "secondary", "outline", "glass", "danger", "ghost", "link"
40pub fn button(text: &str, variant: &str) -> DomBuilder {
41    button_advanced(text, variant, "md", false, None)
42}
43
44/// Advanced Button supporting sizes ("xs", "sm", "md", "lg"), loading spinner, and icon prefix
45pub fn button_advanced(
46    text: &str,
47    variant: &str,
48    size: &str,
49    loading: bool,
50    icon_name: Option<&str>,
51) -> DomBuilder {
52    let (bg, color, border) = match variant {
53        "primary" => ("var(--btn-bg, linear-gradient(135deg, #7e22ce, #3b82f6))", "#ffffff", "none"),
54        "secondary" => ("rgba(255,255,255,0.1)", "var(--text-primary)", "var(--border-glass)"),
55        "danger" => ("linear-gradient(135deg, #dc2626, #991b1b)", "#ffffff", "none"),
56        "outline" => ("transparent", "var(--accent-cyan, #06b6d4)", "1px solid var(--accent-cyan, #06b6d4)"),
57        "ghost" => ("transparent", "var(--text-primary)", "none"),
58        "link" => ("transparent", "var(--accent-primary, #3b82f6)", "none"),
59        _ => ("var(--bg-glass, rgba(255,255,255,0.06))", "var(--text-primary)", "var(--border-glass)"), // "glass"
60    };
61
62    let (padding, font_size, height) = match size {
63        "xs" => ("4px 8px", "0.75rem", "24px"),
64        "sm" => ("6px 12px", "0.82rem", "32px"),
65        "lg" => ("12px 24px", "1.05rem", "48px"),
66        _ => ("8px 16px", "0.9rem", "40px"), // "md"
67    };
68
69    let style = format!(
70        "background: {}; color: {}; border: {}; padding: {}; min-height: {}; font-size: {}; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); display: inline-flex; align-items: center; justify-content: center; gap: 8px; user-select: none; position: relative;",
71        bg, color, border, padding, height, font_size
72    );
73
74    let mut btn = dom_button()
75        .attr("class", &format!("ferrox-btn ferrox-btn-{} ferrox-btn-{}", variant, size))
76        .attr("style", &style);
77
78    if loading {
79        btn = btn
80            .attr("disabled", "true")
81            .attr("style", &format!("{}; opacity: 0.8; pointer-events: none;", style))
82            .child(micro_spinner(14))
83            .child(span().text(text));
84    } else {
85        if let Some(ico) = icon_name {
86            btn = btn.child(Icon::render(ico, 16, color));
87        }
88        btn = btn.child(span().text(text));
89    }
90
91    btn
92}
93
94/// Renders a segmented Button Group (e.g. timeframe selection: 1D, 1W, 1M, 1Y)
95pub fn button_group(buttons: Vec<DomBuilder>) -> DomBuilder {
96    let mut group = div()
97        .attr("class", "ferrox-button-group")
98        .attr("style", "display: inline-flex; border-radius: 8px; overflow: hidden; border: var(--border-glass); background: rgba(0,0,0,0.2); padding: 2px; gap: 2px;");
99
100    for b in buttons {
101        group = group.child(b);
102    }
103    group
104}
105
106// =============================================================================
107// 2. FORM CONTROLS & ADVANCED INPUTS
108// =============================================================================
109
110#[derive(Debug, Clone)]
111pub struct InputConfig {
112    pub input_type: String,
113    pub placeholder: String,
114    pub value: String,
115    pub prefix_icon: Option<String>,
116    pub suffix_icon: Option<String>,
117    pub is_valid: Option<bool>,
118    pub helper_text: Option<String>,
119    pub clearable: bool,
120}
121
122impl Default for InputConfig {
123    fn default() -> Self {
124        Self {
125            input_type: "text".to_string(),
126            placeholder: String::new(),
127            value: String::new(),
128            prefix_icon: None,
129            suffix_icon: None,
130            is_valid: None,
131            helper_text: None,
132            clearable: false,
133        }
134    }
135}
136
137/// Standard Form Input field
138pub fn input(input_type: &str, placeholder: &str) -> DomBuilder {
139    dom_input()
140        .attr("type", input_type)
141        .attr("placeholder", placeholder)
142        .attr("class", "ferrox-input")
143        .attr("style", "width: 100%; padding: 10px 14px; border-radius: 6px; background: var(--bg-surface, rgba(255,255,255,0.04)); color: var(--text-primary); border: var(--border-glass); font-size: 0.9rem; outline: none; transition: border-color 0.2s, box-shadow 0.2s;")
144}
145
146/// Advanced Input supporting prefix/suffix icons, validity states (is-valid, is-invalid), and helper text
147pub fn input_advanced(config: InputConfig) -> DomBuilder {
148    let border_color = match config.is_valid {
149        Some(true) => "#10b981",
150        Some(false) => "#ef4444",
151        None => "rgba(255,255,255,0.15)",
152    };
153
154    let pl = if config.prefix_icon.is_some() { "38px" } else { "12px" };
155    let pr = if config.suffix_icon.is_some() || config.clearable { "38px" } else { "12px" };
156
157    let input_style = format!(
158        "width: 100%; padding: 10px {} 10px {}; border-radius: 6px; background: var(--bg-surface, rgba(0,0,0,0.25)); color: var(--text-primary); border: 1px solid {}; font-size: 0.9rem; outline: none; transition: all 0.2s;",
159        pr, pl, border_color
160    );
161
162    let mut wrapper = div().attr("style", "position: relative; width: 100%; display: flex; flex-direction: column; gap: 4px;");
163    let mut field_container = div().attr("style", "position: relative; width: 100%; display: flex; align-items: center;");
164
165    if let Some(ref p_icon) = config.prefix_icon {
166        let p_el = span()
167            .attr("style", "position: absolute; left: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.7;")
168            .child(Icon::render(p_icon, 16, "var(--text-secondary, #94a3b8)"));
169        field_container = field_container.child(p_el);
170    }
171
172    let mut field = dom_input()
173        .attr("type", &config.input_type)
174        .attr("placeholder", &config.placeholder)
175        .attr("class", &format!("ferrox-input-adv {}", match config.is_valid {
176            Some(true) => "is-valid",
177            Some(false) => "is-invalid",
178            None => "",
179        }))
180        .attr("style", &input_style);
181
182    if !config.value.is_empty() {
183        field = field.attr("value", &config.value);
184    }
185
186    field_container = field_container.child(field);
187
188    if let Some(ref s_icon) = config.suffix_icon {
189        let s_el = span()
190            .attr("style", "position: absolute; right: 12px; top: 50%; transform: translateY(-50%); opacity: 0.7;")
191            .child(Icon::render(s_icon, 16, "var(--text-secondary, #94a3b8)"));
192        field_container = field_container.child(s_el);
193    }
194
195    wrapper = wrapper.child(field_container);
196
197    if let Some(helper) = config.helper_text {
198        let color = match config.is_valid {
199            Some(true) => "#10b981",
200            Some(false) => "#ef4444",
201            None => "var(--text-secondary, #94a3b8)",
202        };
203        wrapper = wrapper.child(span().attr("style", &format!("font-size: 0.75rem; color: {}; margin-left: 2px;", color)).text(&helper));
204    }
205
206    wrapper
207}
208
209/// Advanced Textarea with character counter and dynamic expansion
210pub fn textarea_advanced(id: &str, placeholder: &str, max_chars: usize, value: &str) -> DomBuilder {
211    let char_count = value.chars().count();
212    let style = "width: 100%; padding: 10px 14px; border-radius: 6px; background: var(--bg-surface, rgba(0,0,0,0.25)); color: var(--text-primary); border: var(--border-glass); font-size: 0.9rem; outline: none; font-family: inherit; resize: vertical; min-height: 80px;";
213
214    div()
215        .attr("class", "ferrox-textarea-wrapper")
216        .attr("style", "width: 100%; display: flex; flex-direction: column; gap: 4px;")
217        .child(
218            dom_textarea()
219                .attr("id", id)
220                .attr("placeholder", placeholder)
221                .attr("maxlength", &max_chars.to_string())
222                .attr("style", style)
223                .text(value)
224        )
225        .child(
226            div()
227                .attr("style", "display: flex; justify-content: flex-end; font-size: 0.75rem; opacity: 0.6;")
228                .text(&format!("{}/{}", char_count, max_chars))
229        )
230}
231
232/// Animated Switch Toggle with smooth CSS transition
233pub fn switch_toggle(id: &str, checked: bool, label_text: &str) -> DomBuilder {
234    let bg = if checked { "var(--accent-primary, #3b82f6)" } else { "rgba(255,255,255,0.2)" };
235    let translate = if checked { "transform: translateX(18px);" } else { "transform: translateX(0px);" };
236
237    let track = div()
238        .attr("id", id)
239        .attr("class", "ferrox-switch-track")
240        .attr("style", &format!("width: 40px; height: 22px; border-radius: 9999px; background: {}; position: relative; transition: background 0.3s cubic-bezier(0.4,0,0.2,1); padding: 2px; cursor: pointer; flex-shrink: 0;", bg))
241        .child(
242            div()
243                .attr("class", "ferrox-switch-thumb")
244                .attr("style", &format!("width: 18px; height: 18px; border-radius: 50%; background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.3); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1); {}", translate))
245        );
246
247    div()
248        .attr("class", "ferrox-switch-wrapper")
249        .attr("style", "display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none;")
250        .child(track)
251        .child(span().attr("style", "font-size: 0.88rem; font-weight: 500;").text(label_text))
252}
253
254/// Custom Styled Checkbox with SVG indicator and indeterminate support
255pub fn checkbox_advanced(id: &str, label_text: &str, checked: bool, indeterminate: bool) -> DomBuilder {
256    let bg = if checked || indeterminate { "var(--accent-primary, #3b82f6)" } else { "rgba(255,255,255,0.1)" };
257    let border = if checked || indeterminate { "none" } else { "1px solid rgba(255,255,255,0.3)" };
258
259    let mut box_indicator = div()
260        .attr("style", &format!("width: 18px; height: 18px; border-radius: 4px; background: {}; border: {}; display: inline-flex; align-items: center; justify-content: center; transition: all 0.2s; flex-shrink: 0;", bg, border));
261
262    if checked {
263        box_indicator = box_indicator.child(Icon::render("check", 14, "#ffffff"));
264    } else if indeterminate {
265        box_indicator = box_indicator.child(Icon::render("minus", 14, "#ffffff"));
266    }
267
268    dom_label()
269        .attr("for", id)
270        .attr("class", "ferrox-checkbox-wrapper")
271        .attr("style", "display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 0.88rem; color: var(--text-primary);")
272        .child(box_indicator)
273        .child(span().text(label_text))
274}
275
276/// Custom Styled Radio Group
277pub fn radio_group(name: &str, options: Vec<(&str, &str)>, selected_value: &str) -> DomBuilder {
278    let mut group = div()
279        .attr("class", "ferrox-radio-group")
280        .attr("data-radio-group", name)
281        .attr("style", "display: flex; flex-direction: column; gap: 8px;");
282
283    for (val, label_text) in options {
284        let is_selected = val == selected_value;
285        let ring_color = if is_selected { "var(--accent-primary, #3b82f6)" } else { "rgba(255,255,255,0.3)" };
286
287        let mut circle_el = div()
288            .attr("style", &format!("width: 18px; height: 18px; border-radius: 50%; border: 2px solid {}; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;", ring_color));
289
290        if is_selected {
291            circle_el = circle_el.child(
292                div().attr("style", "width: 8px; height: 8px; border-radius: 50%; background: var(--accent-primary, #3b82f6);")
293            );
294        }
295
296        let item = dom_label()
297            .attr("data-name", name)
298            .attr("data-value", val)
299            .attr("style", "display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.88rem; user-select: none;")
300            .child(circle_el)
301            .child(span().text(label_text));
302
303        group = group.child(item);
304    }
305
306    group
307}
308
309/// Custom Filterable Select Dropdown
310pub fn select_dropdown(id: &str, options: Vec<(&str, &str)>, selected_value: &str) -> DomBuilder {
311    let style = "width: 100%; padding: 10px 14px; border-radius: 6px; background: var(--bg-surface, rgba(0,0,0,0.25)); color: var(--text-primary); border: var(--border-glass); font-size: 0.9rem; outline: none; cursor: pointer;";
312    let mut sel = dom_select()
313        .attr("id", id)
314        .attr("class", "ferrox-select-dropdown")
315        .attr("style", style);
316
317    for (val, label_text) in options {
318        let mut opt = dom_option()
319            .attr("value", val)
320            .text(label_text);
321        if val == selected_value {
322            opt = opt.attr("selected", "true");
323        }
324        sel = sel.child(opt);
325    }
326    sel
327}
328
329/// Range Slider with interactive tooltip bubble
330pub fn range_slider_with_bubble(id: &str, min: i32, max: i32, value: i32, unit: &str) -> DomBuilder {
331    let pct = ((value - min) as f64 / (max - min) as f64) * 100.0;
332
333    div()
334        .attr("class", "ferrox-slider-bubble-container")
335        .attr("style", "position: relative; width: 100%; padding-top: 24px;")
336        .child(
337            div()
338                .attr("style", &format!("position: absolute; top: 0; left: {}%; transform: translateX(-50%); font-size: 0.75rem; font-weight: 700; background: var(--accent-primary, #3b82f6); color: white; padding: 2px 6px; border-radius: 4px; pointer-events: none;", pct))
339                .text(&format!("{}{}", value, unit))
340        )
341        .child(
342            dom_input()
343                .attr("id", id)
344                .attr("type", "range")
345                .attr("min", &min.to_string())
346                .attr("max", &max.to_string())
347                .attr("value", &value.to_string())
348                .attr("style", "width: 100%; accent-color: var(--accent-primary, #3b82f6); cursor: pointer;")
349        )
350}
351
352// =============================================================================
353// 3. SURFACES, OVERLAYS & FEEDBACK
354// =============================================================================
355
356/// Renders an Enterprise Modal dialog with blurred backdrop, size variants, and close handler
357pub fn modal(title: &str, content: DomBuilder) -> DomBuilder {
358    modal_advanced("modal-default", title, content, None, "md")
359}
360
361pub fn modal_advanced(
362    id: &str,
363    title: &str,
364    content: DomBuilder,
365    footer: Option<DomBuilder>,
366    size: &str,
367) -> DomBuilder {
368    let max_w = match size {
369        "sm" => "380px",
370        "lg" => "720px",
371        "xl" => "960px",
372        "fullscreen" => "98vw",
373        _ => "520px", // "md"
374    };
375
376    let overlay_style = "position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(12px); display: flex; align-items: center; justify-content: center; z-index: 10000; animation: ferrox-fade-in 0.2s ease;";
377    let modal_style = format!(
378        "background: var(--bg-surface, #0f172a); border: var(--border-glass); border-radius: 14px; padding: 24px; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7); width: 90%; max-width: {}; max-height: 90vh; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; position: relative; animation: ferrox-scale-up 0.25s cubic-bezier(0.16, 1, 0.3, 1);",
379        max_w
380    );
381
382    let modal_id_owned = id.to_string();
383    let close_btn = dom_button()
384        .attr("class", "ferrox-btn ferrox-btn-ghost")
385        .attr("style", "position: absolute; top: 16px; right: 16px; padding: 6px; border-radius: 50%; opacity: 0.7;")
386        .on_click(move || {
387            if let Some(el) = ferrox_front_core::dom::get_element_by_id(&modal_id_owned) {
388                el.remove();
389            }
390        })
391        .child(Icon::render("x", 18, "var(--text-primary)"));
392
393    let header = div()
394        .attr("style", "display: flex; align-items: center; border-bottom: var(--border-glass); padding-bottom: 12px; padding-right: 40px;")
395        .child(span().attr("style", "font-size: 1.25rem; font-weight: 700; color: var(--text-primary);").text(title));
396
397    let mut body = div().attr("style", "flex: 1; overflow-y: auto;").child(content);
398
399    if let Some(f) = footer {
400        body = body.child(
401            div().attr("style", "margin-top: 16px; padding-top: 12px; border-top: var(--border-glass); display: flex; justify-content: flex-end; gap: 10px;").child(f)
402        );
403    }
404
405    div()
406        .attr("id", id)
407        .attr("class", "ferrox-modal-backdrop")
408        .attr("style", overlay_style)
409        .child(
410            div()
411                .attr("class", "ferrox-modal-content")
412                .attr("style", &modal_style)
413                .child(close_btn)
414                .child(header)
415                .child(body)
416        )
417}
418
419/// Sliding Drawer / Offcanvas Panel (left or right)
420pub fn drawer(id: &str, title: &str, content: DomBuilder, side: &str) -> DomBuilder {
421    let (pos, transform) = match side {
422        "left" => ("left: 0; top: 0; bottom: 0;", "animation: ferrox-slide-left 0.3s ease;"),
423        _ => ("right: 0; top: 0; bottom: 0;", "animation: ferrox-slide-right 0.3s ease;"),
424    };
425
426    let panel_style = format!(
427        "position: fixed; {}; width: 340px; max-width: 85vw; background: var(--bg-surface, #0f172a); border-{}: var(--border-glass); box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5); z-index: 10001; padding: 20px; display: flex; flex-direction: column; gap: 16px; {}",
428        pos, match side { "left" => "right", _ => "left" }, transform
429    );
430
431    let drawer_id_owned = id.to_string();
432    let close_btn = dom_button()
433        .attr("class", "ferrox-btn ferrox-btn-ghost")
434        .attr("style", "padding: 6px; border-radius: 50%; opacity: 0.7;")
435        .on_click(move || {
436            if let Some(el) = ferrox_front_core::dom::get_element_by_id(&drawer_id_owned) {
437                el.remove();
438            }
439        })
440        .child(Icon::render("x", 18, "var(--text-primary)"));
441
442    let header = div()
443        .attr("style", "display: flex; justify-content: space-between; align-items: center; border-bottom: var(--border-glass); padding-bottom: 12px;")
444        .child(span().attr("style", "font-weight: 700; font-size: 1.15rem;").text(title))
445        .child(close_btn);
446
447    div()
448        .attr("id", id)
449        .attr("class", "ferrox-drawer-backdrop")
450        .attr("style", "position: fixed; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); z-index: 10000;")
451        .child(
452            div()
453                .attr("class", "ferrox-drawer-panel")
454                .attr("style", &panel_style)
455                .child(header)
456                .child(div().attr("style", "flex: 1; overflow-y: auto;").child(content))
457        )
458}
459
460/// Collapsible Accordion section item
461pub struct AccordionItem {
462    pub title: String,
463    pub content: DomBuilder,
464    pub is_expanded: bool,
465}
466
467/// Renders collapsible Accordion sections
468pub fn accordion(items: Vec<AccordionItem>) -> DomBuilder {
469    let mut acc = div()
470        .attr("class", "ferrox-accordion")
471        .attr("style", "display: flex; flex-direction: column; gap: 8px; width: 100%;");
472
473    for (idx, item) in items.into_iter().enumerate() {
474        let chevron_icon = if item.is_expanded { "chevron-up" } else { "chevron-down" };
475        let content_display = if item.is_expanded { "display: block;" } else { "display: none;" };
476
477        let header = div()
478            .attr("class", "ferrox-accordion-header")
479            .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; background: rgba(255,255,255,0.03); border: var(--border-glass); border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;")
480            .child(span().text(&item.title))
481            .child(Icon::render(chevron_icon, 18, "var(--text-secondary)"));
482
483        let body = div()
484            .attr("id", &format!("accordion-body-{}", idx))
485            .attr("class", "ferrox-accordion-body")
486            .attr("style", &format!("padding: 14px 18px; border: var(--border-glass); border-top: none; border-radius: 0 0 8px 8px; background: rgba(0,0,0,0.15); font-size: 0.88rem; line-height: 1.5; {}", content_display))
487            .child(item.content);
488
489        let section = div().child(header).child(body);
490        acc = acc.child(section);
491    }
492
493    acc
494}
495
496/// Tab navigation item
497#[derive(Debug, Clone)]
498pub struct TabItem {
499    pub id: String,
500    pub label: String,
501    pub icon: Option<String>,
502}
503
504/// Renders reactive Tab navigation bar: variants: "line", "pills", "segmented"
505pub fn tabs(items: Vec<TabItem>, active_id: &str, variant: &str) -> DomBuilder {
506    let style = match variant {
507        "pills" => "display: flex; gap: 8px; background: transparent; padding: 4px 0;",
508        "segmented" => "display: inline-flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 3px; border: var(--border-glass);",
509        _ => "display: flex; gap: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 2px;", // "line"
510    };
511
512    let mut tab_bar = div().attr("class", &format!("ferrox-tabs ferrox-tabs-{}", variant)).attr("style", style);
513
514    for item in items {
515        let is_active = item.id == active_id;
516
517        let item_style = match variant {
518            "pills" => {
519                if is_active {
520                    "padding: 8px 16px; border-radius: 6px; background: var(--accent-primary, #3b82f6); color: #fff; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px;"
521                } else {
522                    "padding: 8px 16px; border-radius: 6px; background: rgba(255,255,255,0.04); color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; gap: 6px;"
523                }
524            },
525            "segmented" => {
526                if is_active {
527                    "padding: 6px 14px; border-radius: 6px; background: var(--bg-surface, #1e293b); color: #fff; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);"
528                } else {
529                    "padding: 6px 14px; border-radius: 6px; background: transparent; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; gap: 6px;"
530                }
531            },
532            _ => { // line
533                if is_active {
534                    "padding: 10px 0; border-bottom: 2px solid var(--accent-primary, #3b82f6); color: var(--text-primary); font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 6px;"
535                } else {
536                    "padding: 10px 0; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; gap: 6px;"
537                }
538            }
539        };
540
541        let mut tab_button = div()
542            .attr("class", "ferrox-tab-item")
543            .attr("style", item_style);
544
545        if let Some(ref ico) = item.icon {
546            tab_button = tab_button.child(Icon::render(ico, 16, if is_active { "#ffffff" } else { "var(--text-secondary)" }));
547        }
548
549        tab_button = tab_button.child(span().text(&item.label));
550        tab_bar = tab_bar.child(tab_button);
551    }
552
553    tab_bar
554}
555
556/// Notification Toast with timed progress bar
557pub fn toast(title: &str, message: &str, variant: &str) -> DomBuilder {
558    let (icon_name, border_col) = match variant {
559        "success" => ("check-circle", "#10b981"),
560        "danger" => ("alert-circle", "#ef4444"),
561        "warning" => ("alert-triangle", "#f59e0b"),
562        _ => ("info", "#3b82f6"),
563    };
564
565    div()
566        .attr("class", &format!("ferrox-toast ferrox-toast-{}", variant))
567        .attr("style", &format!("background: var(--bg-surface, #1e293b); border-left: 4px solid {}; border-radius: 8px; padding: 14px 18px; box-shadow: 0 10px 25px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 6px; min-width: 280px; max-width: 380px; position: relative; overflow: hidden;", border_col))
568        .child(
569            div()
570                .attr("style", "display: flex; align-items: center; gap: 8px;")
571                .child(Icon::render(icon_name, 18, border_col))
572                .child(span().attr("style", "font-weight: 700; font-size: 0.92rem; color: var(--text-primary);").text(title))
573        )
574        .child(
575            span().attr("style", "font-size: 0.82rem; opacity: 0.8; line-height: 1.4; color: var(--text-secondary);").text(message)
576        )
577        .child(
578            div()
579                .attr("class", "ferrox-toast-progress")
580                .attr("style", &format!("position: absolute; bottom: 0; left: 0; height: 3px; background: {}; width: 100%; animation: ferrox-toast-shrink 4s linear;", border_col))
581        )
582}
583
584/// Skeleton loading placeholder with pulsed light shimmer
585pub fn skeleton(variant: &str) -> DomBuilder {
586    let (w, h, r) = match variant {
587        "avatar" => ("40px", "40px", "50%"),
588        "title" => ("60%", "24px", "4px"),
589        "card" => ("100%", "160px", "10px"),
590        _ => ("100%", "16px", "4px"), // "text"
591    };
592
593    div()
594        .attr("class", &format!("ferrox-skeleton ferrox-skeleton-{}", variant))
595        .attr("style", &format!("width: {}; height: {}; border-radius: {}; background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 75%); background-size: 200% 100%; animation: ferrox-shimmer 1.5s infinite;", w, h, r))
596}
597
598/// Tooltip wrapper rendering tooltip bubble on hover
599pub fn tooltip(target: DomBuilder, tooltip_text: &str) -> DomBuilder {
600    div()
601        .attr("class", "ferrox-tooltip-wrapper")
602        .attr("style", "position: relative; display: inline-block;")
603        .child(target)
604        .child(
605            span()
606                .attr("class", "ferrox-tooltip-bubble")
607                .attr("style", "position: absolute; bottom: 125%; left: 50%; transform: translateX(-50%); background: #0f172a; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 0.2s; box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1);")
608                .text(tooltip_text)
609        )
610}
611
612/// Circular Progress Ring
613pub fn progress_bar_circular(percentage: f64, size: u32) -> DomBuilder {
614    let pct = percentage.clamp(0.0, 100.0);
615    let r = (size as f64 - 10.0) / 2.0;
616    let circ = 2.0 * std::f64::consts::PI * r;
617    let offset = circ - (pct / 100.0) * circ;
618
619    svg()
620        .attr("width", &size.to_string())
621        .attr("height", &size.to_string())
622        .attr("style", "transform: rotate(-90deg);")
623        .child(
624            circle()
625                .attr("cx", &(size / 2).to_string())
626                .attr("cy", &(size / 2).to_string())
627                .attr("r", &r.to_string())
628                .attr("stroke", "rgba(255,255,255,0.1)")
629                .attr("stroke-width", "6")
630                .attr("fill", "transparent")
631        )
632        .child(
633            circle()
634                .attr("cx", &(size / 2).to_string())
635                .attr("cy", &(size / 2).to_string())
636                .attr("r", &r.to_string())
637                .attr("stroke", "var(--accent-primary, #3b82f6)")
638                .attr("stroke-width", "6")
639                .attr("stroke-dasharray", &circ.to_string())
640                .attr("stroke-dashoffset", &offset.to_string())
641                .attr("stroke-linecap", "round")
642                .attr("fill", "transparent")
643                .attr("style", "transition: stroke-dashoffset 0.5s ease;")
644        )
645}
646
647// =============================================================================
648// 4. NAVIGATION & STRUCTURE
649// =============================================================================
650
651/// Renders an Enterprise Top Navbar with logo/brand, navigation links, and actions
652pub fn navbar(brand_name: &str, links: Vec<(&str, &str)>) -> DomBuilder {
653    let nav = dom_nav()
654        .attr("class", "ferrox-navbar")
655        .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 1rem 2rem; background: var(--bg-surface, rgba(15,23,42,0.8)); backdrop-filter: blur(12px); border-bottom: var(--border-glass); position: sticky; top: 0; z-index: 1000;");
656
657    let brand = div()
658        .attr("style", "display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.2rem; color: var(--text-primary); cursor: pointer;")
659        .child(Icon::render("zap", 22, "var(--accent-cyan, #06b6d4)"))
660        .child(span().text(brand_name));
661
662    let mut link_container = div()
663        .attr("class", "ferrox-nav-links")
664        .attr("style", "display: flex; gap: 20px; align-items: center;");
665
666    for (url, label_text) in links {
667        let l = dom_a()
668            .attr("href", url)
669            .attr("style", "color: var(--text-secondary); text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: color 0.2s;")
670            .text(label_text);
671        link_container = link_container.child(l);
672    }
673
674    nav.child(brand).child(link_container)
675}
676
677pub struct SidebarItem {
678    pub id: String,
679    pub label: String,
680    pub icon: String,
681    pub badge_count: Option<usize>,
682    pub is_active: bool,
683}
684
685/// Renders a Collapsible Sidebar with icons, labels, and badges
686pub fn sidebar(items: Vec<SidebarItem>, collapsed: bool) -> DomBuilder {
687    let w = if collapsed { "68px" } else { "240px" };
688
689    let mut sb = div()
690        .attr("class", "ferrox-sidebar")
691        .attr("style", &format!("width: {}; min-height: 100vh; background: var(--bg-surface, #0f172a); border-right: var(--border-glass); padding: 1.5rem 10px; display: flex; flex-direction: column; gap: 6px; transition: width 0.3s cubic-bezier(0.4,0,0.2,1);", w));
692
693    for item in items {
694        let (bg, col) = if item.is_active {
695            ("var(--accent-primary, #3b82f6)", "#ffffff")
696        } else {
697            ("transparent", "var(--text-secondary, #94a3b8)")
698        };
699
700        let mut row = div()
701            .attr("class", &format!("ferrox-sidebar-item {}", if item.is_active { "active" } else { "" }))
702            .attr("style", &format!("display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; background: {}; color: {}; cursor: pointer; transition: all 0.2s;", bg, col))
703            .child(Icon::render(&item.icon, 20, col));
704
705        if !collapsed {
706            row = row.child(span().attr("style", "flex: 1; font-size: 0.88rem; font-weight: 500;").text(&item.label));
707
708            if let Some(cnt) = item.badge_count {
709                let b = span()
710                    .attr("style", "background: rgba(255,255,255,0.2); font-size: 0.72rem; font-weight: 700; padding: 2px 6px; border-radius: 999px;")
711                    .text(&cnt.to_string());
712                row = row.child(b);
713            }
714        }
715
716        sb = sb.child(row);
717    }
718
719    sb
720}
721
722pub struct BreadcrumbItem {
723    pub label: String,
724    pub url: Option<String>,
725}
726
727/// Renders a Breadcrumb navigation path
728pub fn breadcrumb(items: Vec<BreadcrumbItem>) -> DomBuilder {
729    let mut bc = div()
730        .attr("class", "ferrox-breadcrumb")
731        .attr("style", "display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-secondary);");
732
733    let count = items.len();
734    for (i, item) in items.into_iter().enumerate() {
735        if let Some(url) = item.url {
736            bc = bc.child(
737                dom_a()
738                    .attr("href", &url)
739                    .attr("style", "color: var(--text-secondary); text-decoration: none;")
740                    .text(&item.label)
741            );
742        } else {
743            bc = bc.child(
744                span().attr("style", "color: var(--text-primary); font-weight: 600;").text(&item.label)
745            );
746        }
747
748        if i + 1 < count {
749            bc = bc.child(Icon::render("chevron-right", 14, "var(--text-secondary)"));
750        }
751    }
752
753    bc
754}
755
756/// Renders a Complete Pagination Bar with ellipsis and page numbers
757pub fn pagination(current_page: u32, total_pages: u32) -> DomBuilder {
758    let mut p_bar = div()
759        .attr("class", "ferrox-pagination")
760        .attr("style", "display: flex; align-items: center; gap: 6px;");
761
762    // Prev
763    let prev = dom_button()
764        .attr("class", "ferrox-btn ferrox-btn-secondary")
765        .attr("style", "padding: 6px 10px; border-radius: 6px;")
766        .child(Icon::render("chevron-left", 14, "var(--text-primary)"));
767    p_bar = p_bar.child(prev);
768
769    for p in 1..=total_pages {
770        let is_current = p == current_page;
771        let (bg, col) = if is_current {
772            ("var(--accent-primary, #3b82f6)", "#ffffff")
773        } else {
774            ("rgba(255,255,255,0.05)", "var(--text-primary)")
775        };
776
777        let btn = dom_button()
778            .attr("style", &format!("width: 32px; height: 32px; border-radius: 6px; background: {}; color: {}; border: var(--border-glass); font-weight: 600; font-size: 0.85rem; cursor: pointer;", bg, col))
779            .text(&p.to_string());
780        p_bar = p_bar.child(btn);
781    }
782
783    // Next
784    let next = dom_button()
785        .attr("class", "ferrox-btn ferrox-btn-secondary")
786        .attr("style", "padding: 6px 10px; border-radius: 6px;")
787        .child(Icon::render("chevron-right", 14, "var(--text-primary)"));
788    p_bar = p_bar.child(next);
789
790    p_bar
791}
792
793// =============================================================================
794// 5. EXISTING SURFACES & CONTAINERS (PRESERVED FOR BACKWARD COMPATIBILITY)
795// =============================================================================
796
797pub fn card() -> DomBuilder {
798    div()
799        .attr("class", "ferrox-glass-card")
800        .attr("style", "background: var(--bg-glass, rgba(255,255,255,0.04)); backdrop-filter: blur(12px); border: var(--border-glass); border-radius: 12px; padding: 1.25rem; box-shadow: 0 8px 32px rgba(0,0,0,0.37); display: flex; flex-direction: column;")
801}
802
803pub fn card_header(title: &str) -> DomBuilder {
804    div()
805        .attr("style", "font-weight: 700; font-size: 1.15rem; margin-bottom: 1rem; border-bottom: var(--border-glass); padding-bottom: 0.6rem; color: var(--text-primary);")
806        .text(title)
807}
808
809pub fn card_body() -> DomBuilder {
810    div().attr("style", "flex: 1;")
811}
812
813pub fn card_footer() -> DomBuilder {
814    div().attr("style", "margin-top: 1rem; padding-top: 0.6rem; border-top: var(--border-glass); display: flex; gap: 8px; justify-content: flex-end;")
815}
816
817pub fn badge(text: &str, variant: &str) -> DomBuilder {
818    let (bg, color) = match variant {
819        "success" => ("rgba(16, 185, 129, 0.2)", "#34d399"),
820        "danger" => ("rgba(239, 68, 68, 0.2)", "#fca5a5"),
821        "warning" => ("rgba(245, 158, 11, 0.2)", "#fde047"),
822        "info" => ("rgba(14, 165, 233, 0.2)", "#7dd3fc"),
823        _ => ("rgba(147, 51, 234, 0.2)", "#c084fc"),
824    };
825
826    span()
827        .attr("class", &format!("ferrox-badge ferrox-badge-{}", variant))
828        .attr("style", &format!("background: {}; color: {}; border: var(--border-glass); padding: 3px 10px; border-radius: 9999px; font-size: 0.78rem; font-weight: 700; display: inline-block; white-space: nowrap;", bg, color))
829        .text(text)
830}
831
832pub fn alert(message: &str, variant: &str) -> DomBuilder {
833    let (bg, color) = match variant {
834        "danger" => ("rgba(239, 68, 68, 0.15)", "#fca5a5"),
835        "success" => ("rgba(16, 185, 129, 0.15)", "#34d399"),
836        "warning" => ("rgba(245, 158, 11, 0.15)", "#fde047"),
837        _ => ("rgba(147, 51, 234, 0.15)", "#c084fc"),
838    };
839
840    div()
841        .attr("class", &format!("ferrox-alert ferrox-alert-{}", variant))
842        .attr("style", &format!("padding: 1rem; border-radius: 8px; background: {}; color: {}; border: var(--border-glass); margin-bottom: 1rem; font-weight: 500; font-size: 0.92rem;", bg, color))
843        .text(message)
844}
845
846pub fn form_group(label_text: &str, input_element: DomBuilder) -> DomBuilder {
847    div()
848        .attr("class", "ferrox-form-group")
849        .attr("style", "margin-bottom: 1rem; display: flex; flex-direction: column; gap: 6px;")
850        .child(dom_label().attr("style", "font-size: 0.85rem; font-weight: 600; color: var(--accent-cyan);").text(label_text))
851        .child(input_element)
852}
853
854pub fn progress_bar(percentage: f64) -> DomBuilder {
855    let pct = percentage.clamp(0.0, 100.0);
856    div()
857        .attr("class", "ferrox-progress-track")
858        .attr("style", "width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 9999px; overflow: hidden;")
859        .child(
860            div()
861                .attr("class", "ferrox-progress-fill")
862                .attr("style", &format!("width: {}%; height: 100%; background: var(--btn-bg, linear-gradient(135deg, #7e22ce, #3b82f6)); transition: width 0.3s ease;", pct))
863        )
864}
865
866pub fn avatar(initials: &str, img_url: Option<&str>) -> DomBuilder {
867    let size = 40;
868    let mut builder = div()
869        .attr("class", "ferrox-avatar")
870        .attr("style", &format!("width: {}px; height: {}px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; color: #fff; background: var(--btn-bg, linear-gradient(135deg, #7e22ce, #3b82f6)); overflow: hidden; border: 2px solid rgba(255,255,255,0.1);", size, size));
871
872    if let Some(url) = img_url {
873        let img = ferrox_front_core::dom::img()
874            .attr("src", url)
875            .attr("style", "width: 100%; height: 100%; object-fit: cover;");
876        builder = builder.child(img);
877    } else {
878        builder = builder.text(initials);
879    }
880    builder
881}
882
883pub fn user_pill(name: &str, role: &str) -> DomBuilder {
884    let initials = name.split_whitespace().filter_map(|w| w.chars().next()).collect::<String>();
885    div()
886        .attr("class", "ferrox-user-pill")
887        .attr("style", "display: inline-flex; align-items: center; gap: 10px; padding: 4px 12px 4px 4px; background: var(--bg-glass); border: var(--border-glass); border-radius: 9999px;")
888        .child(avatar(&initials, None))
889        .child(
890            div()
891                .attr("style", "display: flex; flex-direction: column;")
892                .child(span().attr("style", "font-weight: 600; font-size: 0.85rem; color: var(--text-primary);").text(name))
893                .child(span().attr("style", "font-size: 0.72rem; opacity: 0.7; color: var(--accent-cyan);").text(role))
894        )
895}
896
897pub fn spinner() -> DomBuilder {
898    micro_spinner(24)
899}
900
901pub fn micro_spinner(size: u32) -> DomBuilder {
902    div()
903        .attr("class", "ferrox-spinner")
904        .attr("style", &format!("width: {}px; height: {}px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #ffffff; border-radius: 50%; animation: ferrox-spin 0.8s linear infinite; flex-shrink: 0;", size, size))
905}
906
907pub fn textarea(placeholder: &str, rows: u32) -> DomBuilder {
908    dom_textarea()
909        .attr("rows", &rows.to_string())
910        .attr("placeholder", placeholder)
911        .attr("class", "ferrox-textarea")
912        .attr("style", "width: 100%; padding: 10px 14px; border-radius: 6px; background: var(--bg-surface, rgba(0,0,0,0.25)); color: var(--text-primary); border: var(--border-glass); font-size: 0.9rem; outline: none; font-family: inherit; resize: vertical;")
913}
914
915pub fn select(options: Vec<(&str, &str)>) -> DomBuilder {
916    select_dropdown("select-default", options, "")
917}
918
919pub fn checkbox(label_text: &str, checked: bool) -> DomBuilder {
920    checkbox_advanced("cb-default", label_text, checked, false)
921}
922
923pub fn radio(name: &str, value: &str, label_text: &str, checked: bool) -> DomBuilder {
924    let mut rb = dom_input()
925        .attr("type", "radio")
926        .attr("name", name)
927        .attr("value", value)
928        .attr("style", "accent-color: var(--accent-primary); width: 16px; height: 16px; cursor: pointer;");
929    if checked {
930        rb = rb.attr("checked", "true");
931    }
932
933    dom_label()
934        .attr("style", "display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; cursor: pointer; color: var(--text-primary);")
935        .child(rb)
936        .child(span().text(label_text))
937}
938
939pub fn toggle_switch(label_text: &str, checked: bool) -> DomBuilder {
940    switch_toggle("toggle-default", checked, label_text)
941}
942
943pub fn range_slider(min: i32, max: i32, value: i32) -> DomBuilder {
944    range_slider_with_bubble("slider-default", min, max, value, "")
945}
946
947pub fn search_input(placeholder: &str) -> DomBuilder {
948    input_advanced(InputConfig {
949        input_type: "search".to_string(),
950        placeholder: placeholder.to_string(),
951        prefix_icon: Some("search".to_string()),
952        clearable: true,
953        ..Default::default()
954    })
955}
956
957pub fn file_upload(accept: &str) -> DomBuilder {
958    div()
959        .attr("style", "border: 2px dashed var(--border-glass); border-radius: 8px; padding: 1.5rem; text-align: center; background: rgba(0,0,0,0.2); cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;")
960        .child(Icon::render("upload", 28, "var(--accent-primary, #3b82f6)"))
961        .child(span().attr("style", "font-size: 0.88rem; font-weight: 600; color: var(--text-primary);").text("Click or drop files to upload"))
962        .child(span().attr("style", "font-size: 0.75rem; opacity: 0.6;").text(&format!("Supported formats: {}", accept)))
963        .child(
964            dom_input()
965                .attr("type", "file")
966                .attr("accept", accept)
967                .attr("style", "display: none;")
968        )
969}