1use 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#[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
39pub fn button(text: &str, variant: &str) -> DomBuilder {
41 button_advanced(text, variant, "md", false, None)
42}
43
44pub 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)"), };
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"), };
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
94pub 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#[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
137pub 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
146pub 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
209pub 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
232pub 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
254pub 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
276pub 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
309pub 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
329pub 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
352pub 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", };
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
419pub 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
460pub struct AccordionItem {
462 pub title: String,
463 pub content: DomBuilder,
464 pub is_expanded: bool,
465}
466
467pub 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#[derive(Debug, Clone)]
498pub struct TabItem {
499 pub id: String,
500 pub label: String,
501 pub icon: Option<String>,
502}
503
504pub 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;", };
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 _ => { 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
556pub 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
584pub 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"), };
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
598pub 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
612pub 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
647pub 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
685pub 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
727pub 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
756pub 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 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 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
793pub 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;", 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}