qorzen_oxide/ui/components/
mod.rs

1// src/ui/components/mod.rs - Reusable UI components
2
3use dioxus::prelude::*;
4
5/// Button component with consistent styling
6#[component]
7pub fn Button(
8    #[props(default = "button".to_string())] button_type: String,
9    #[props(default = "primary".to_string())] variant: String,
10    #[props(default = "md".to_string())] size: String,
11    #[props(default = false)] disabled: bool,
12    #[props(default = false)] loading: bool,
13    #[props(default = "".to_string())] class: String,
14    #[props(default = None)] onclick: Option<Callback<MouseEvent>>,
15    children: Element,
16) -> Element {
17    let base_classes = "inline-flex items-center border font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors";
18
19    let variant_classes = match variant.as_str() {
20        "primary" => {
21            "border-transparent text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500"
22        }
23        "secondary" => {
24            "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-blue-500"
25        }
26        "danger" => "border-transparent text-white bg-red-600 hover:bg-red-700 focus:ring-red-500",
27        "success" => {
28            "border-transparent text-white bg-green-600 hover:bg-green-700 focus:ring-green-500"
29        }
30        "warning" => {
31            "border-transparent text-white bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500"
32        }
33        "ghost" => "border-transparent text-gray-700 hover:bg-gray-100 focus:ring-blue-500",
34        _ => "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-blue-500",
35    };
36
37    let size_classes = match size.as_str() {
38        "xs" => "px-2.5 py-1.5 text-xs",
39        "sm" => "px-3 py-2 text-sm leading-4",
40        "md" => "px-4 py-2 text-sm",
41        "lg" => "px-4 py-2 text-base",
42        "xl" => "px-6 py-3 text-base",
43        _ => "px-4 py-2 text-sm",
44    };
45
46    let disabled_classes = if disabled || loading {
47        "opacity-50 cursor-not-allowed"
48    } else {
49        ""
50    };
51
52    rsx! {
53        button {
54            r#type: "{button_type}",
55            class: format!("{} {} {} {} {}", base_classes, variant_classes, size_classes, disabled_classes, class),
56            disabled: disabled || loading,
57            onclick: move |evt| {
58                if let Some(handler) = &onclick {
59                    handler.call(evt);
60                }
61            },
62
63            if loading {
64                svg {
65                    class: "animate-spin -ml-1 mr-2 h-4 w-4",
66                    xmlns: "http://www.w3.org/2000/svg",
67                    fill: "none",
68                    view_box: "0 0 24 24",
69                    circle {
70                        class: "opacity-25",
71                        cx: "12",
72                        cy: "12",
73                        r: "10",
74                        stroke: "currentColor",
75                        stroke_width: "4"
76                    }
77                    path {
78                        class: "opacity-75",
79                        fill: "currentColor",
80                        d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
81                    }
82                }
83            }
84
85            {children}
86        }
87    }
88}
89
90/// Input component with consistent styling
91#[component]
92pub fn Input(
93    #[props(default = "text".to_string())] input_type: String,
94    #[props(default = "".to_string())] name: String,
95    #[props(default = "".to_string())] id: String,
96    #[props(default = "".to_string())] placeholder: String,
97    #[props(default = "".to_string())] value: String,
98    #[props(default = false)] required: bool,
99    #[props(default = false)] disabled: bool,
100    #[props(default = "".to_string())] class: String,
101    #[props(default = None)] oninput: Option<Callback<FormEvent>>,
102    #[props(default = None)] onchange: Option<Callback<FormEvent>>,
103) -> Element {
104    let base_classes = "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm";
105    let disabled_classes = if disabled {
106        "bg-gray-50 text-gray-500"
107    } else {
108        ""
109    };
110
111    rsx! {
112        input {
113            r#type: "{input_type}",
114            name: "{name}",
115            id: "{id}",
116            placeholder: "{placeholder}",
117            value: "{value}",
118            required: required,
119            disabled: disabled,
120            class: format!("{} {} {}", base_classes, disabled_classes, class),
121            oninput: move |evt| {
122                if let Some(handler) = &oninput {
123                    handler.call(evt);
124                }
125            },
126            onchange: move |evt| {
127                if let Some(handler) = &onchange {
128                    handler.call(evt);
129                }
130            }
131        }
132    }
133}
134
135/// Label component
136#[component]
137pub fn Label(
138    #[props(default = "".to_string())] html_for: String,
139    #[props(default = false)] required: bool,
140    #[props(default = "".to_string())] class: String,
141    children: Element,
142) -> Element {
143    rsx! {
144        label {
145            r#for: "{html_for}",
146            class: format!("block text-sm font-medium text-gray-700 {}", class),
147            {children}
148            if required {
149                span {
150                    class: "text-red-500 ml-1",
151                    "*"
152                }
153            }
154        }
155    }
156}
157
158/// Form field wrapper component
159#[component]
160pub fn FormField(
161    #[props(default = "".to_string())] label: String,
162    #[props(default = "".to_string())] id: String,
163    #[props(default = false)] required: bool,
164    #[props(default = None)] error: Option<String>,
165    #[props(default = None)] help_text: Option<String>,
166    #[props(default = "".to_string())] class: String,
167    children: Element,
168) -> Element {
169    rsx! {
170        div {
171            class: format!("space-y-1 {}", class),
172
173            if !label.is_empty() {
174                Label {
175                    html_for: id.clone(),
176                    required: required,
177                    "{label}"
178                }
179            }
180
181            {children}
182
183            if let Some(error_msg) = error {
184                p {
185                    class: "text-sm text-red-600",
186                    "{error_msg}"
187                }
188            }
189
190            if let Some(help) = help_text {
191                p {
192                    class: "text-sm text-gray-500",
193                    "{help}"
194                }
195            }
196        }
197    }
198}
199
200/// Modal component
201#[component]
202pub fn Modal(
203    #[props(default = false)] show: bool,
204    #[props(default = "".to_string())] title: String,
205    #[props(default = None)] on_close: Option<Callback<()>>,
206    #[props(default = "".to_string())] class: String,
207    children: Element,
208) -> Element {
209    if !show {
210        return rsx! { div { style: "display: none;" } };
211    }
212
213    rsx! {
214        div {
215            class: "fixed inset-0 z-50 overflow-y-auto",
216
217            // Backdrop
218            div {
219                class: "fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity",
220                onclick: move |_| {
221                    if let Some(handler) = &on_close {
222                        handler.call(());
223                    }
224                }
225            }
226
227            // Modal content
228            div {
229                class: "flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0",
230                div {
231                    class: format!(
232                        "relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg {}",
233                        class
234                    ),
235                    onclick: |evt| evt.stop_propagation(),
236
237                    if !title.is_empty() {
238                        div {
239                            class: "bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4",
240                            div {
241                                class: "flex items-start justify-between",
242                                h3 {
243                                    class: "text-lg font-medium leading-6 text-gray-900",
244                                    "{title}"
245                                }
246                                if on_close.is_some() {
247                                    button {
248                                        r#type: "button",
249                                        class: "rounded-md bg-white text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500",
250                                        onclick: move |_| {
251                                            if let Some(handler) = &on_close {
252                                                handler.call(());
253                                            }
254                                        },
255                                        span {
256                                            class: "sr-only",
257                                            "Close"
258                                        }
259                                        svg {
260                                            class: "h-6 w-6",
261                                            xmlns: "http://www.w3.org/2000/svg",
262                                            fill: "none",
263                                            view_box: "0 0 24 24",
264                                            stroke: "currentColor",
265                                            path {
266                                                stroke_linecap: "round",
267                                                stroke_linejoin: "round",
268                                                stroke_width: "2",
269                                                d: "M6 18L18 6M6 6l12 12"
270                                            }
271                                        }
272                                    }
273                                }
274                            }
275                        }
276                    }
277
278                    div {
279                        class: "px-4 pb-4 pt-5 sm:p-6",
280                        {children}
281                    }
282                }
283            }
284        }
285    }
286}
287
288/// Alert/Banner component
289#[component]
290pub fn Alert(
291    #[props(default = "info".to_string())] variant: String,
292    #[props(default = "".to_string())] title: String,
293    #[props(default = false)] dismissible: bool,
294    #[props(default = None)] on_dismiss: Option<Callback<()>>,
295    #[props(default = "".to_string())] class: String,
296    children: Element,
297) -> Element {
298    let (bg_color, border_color, text_color, title_color, icon) = match variant.as_str() {
299        "success" => (
300            "bg-green-50",
301            "border-green-200",
302            "text-green-700",
303            "text-green-800",
304            "✅",
305        ),
306        "warning" => (
307            "bg-yellow-50",
308            "border-yellow-200",
309            "text-yellow-700",
310            "text-yellow-800",
311            "⚠️",
312        ),
313        "error" => (
314            "bg-red-50",
315            "border-red-200",
316            "text-red-700",
317            "text-red-800",
318            "❌",
319        ),
320        "info" => (
321            "bg-blue-50",
322            "border-blue-200",
323            "text-blue-700",
324            "text-blue-800",
325            "ℹ️",
326        ),
327        _ => (
328            "bg-gray-50",
329            "border-gray-200",
330            "text-gray-700",
331            "text-gray-800",
332            "📌",
333        ),
334    };
335
336    rsx! {
337        div {
338            class: format!("rounded-md {} border {} p-4 {}", bg_color, border_color, class),
339            div {
340                class: "flex",
341                div {
342                    class: "flex-shrink-0",
343                    span {
344                        class: "text-lg",
345                        "{icon}"
346                    }
347                }
348                div {
349                    class: "ml-3 flex-1",
350                    if !title.is_empty() {
351                        h3 {
352                            class: format!("text-sm font-medium {}", title_color),
353                            "{title}"
354                        }
355                    }
356                    div {
357                        class: format!("text-sm {}", text_color),
358                        {children}
359                    }
360                }
361                if dismissible {
362                    div {
363                        class: "ml-auto pl-3",
364                        button {
365                            r#type: "button",
366                            class: format!("inline-flex rounded-md {} hover:bg-opacity-20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-green-50 focus:ring-green-600 p-1.5", text_color),
367                            onclick: move |_| {
368                                if let Some(handler) = &on_dismiss {
369                                    handler.call(());
370                                }
371                            },
372                            span {
373                                class: "sr-only",
374                                "Dismiss"
375                            }
376                            svg {
377                                class: "h-5 w-5",
378                                xmlns: "http://www.w3.org/2000/svg",
379                                view_box: "0 0 20 20",
380                                fill: "currentColor",
381                                path {
382                                    fill_rule: "evenodd",
383                                    d: "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z",
384                                    clip_rule: "evenodd"
385                                }
386                            }
387                        }
388                    }
389                }
390            }
391        }
392    }
393}
394
395/// Badge component
396#[component]
397pub fn Badge(
398    #[props(default = "gray".to_string())] variant: String,
399    #[props(default = "".to_string())] class: String,
400    children: Element,
401) -> Element {
402    let variant_classes = match variant.as_str() {
403        "red" => "bg-red-100 text-red-800",
404        "yellow" => "bg-yellow-100 text-yellow-800",
405        "green" => "bg-green-100 text-green-800",
406        "blue" => "bg-blue-100 text-blue-800",
407        "indigo" => "bg-indigo-100 text-indigo-800",
408        "purple" => "bg-purple-100 text-purple-800",
409        "pink" => "bg-pink-100 text-pink-800",
410        _ => "bg-gray-100 text-gray-800",
411    };
412
413    rsx! {
414        span {
415            class: format!("inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {} {}", variant_classes, class),
416            {children}
417        }
418    }
419}
420
421/// Loading spinner component
422#[component]
423pub fn Spinner(
424    #[props(default = "md".to_string())] size: String,
425    #[props(default = "".to_string())] class: String,
426) -> Element {
427    let size_classes = match size.as_str() {
428        "xs" => "h-3 w-3",
429        "sm" => "h-4 w-4",
430        "md" => "h-6 w-6",
431        "lg" => "h-8 w-8",
432        "xl" => "h-12 w-12",
433        _ => "h-6 w-6",
434    };
435
436    rsx! {
437        svg {
438            class: format!("animate-spin {} {}", size_classes, class),
439            xmlns: "http://www.w3.org/2000/svg",
440            fill: "none",
441            view_box: "0 0 24 24",
442            circle {
443                class: "opacity-25",
444                cx: "12",
445                cy: "12",
446                r: "10",
447                stroke: "currentColor",
448                stroke_width: "4"
449            }
450            path {
451                class: "opacity-75",
452                fill: "currentColor",
453                d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
454            }
455        }
456    }
457}
458
459/// Card component
460#[component]
461pub fn Card(
462    #[props(default = "".to_string())] title: String,
463    #[props(default = None)] subtitle: Option<String>,
464    #[props(default = None)] actions: Option<Element>,
465    #[props(default = "".to_string())] class: String,
466    children: Element,
467) -> Element {
468    rsx! {
469        div {
470            class: format!("bg-white overflow-hidden shadow rounded-lg {}", class),
471
472            if !title.is_empty() || actions.is_some() {
473                div {
474                    class: "px-4 py-5 sm:px-6 border-b border-gray-200",
475                    div {
476                        class: "flex items-center justify-between",
477                        div {
478                            h3 {
479                                class: "text-lg leading-6 font-medium text-gray-900",
480                                "{title}"
481                            }
482                            if let Some(sub) = subtitle {
483                                p {
484                                    class: "mt-1 max-w-2xl text-sm text-gray-500",
485                                    "{sub}"
486                                }
487                            }
488                        }
489                        if let Some(actions_el) = actions {
490                            div {
491                                class: "flex space-x-3",
492                                {actions_el}
493                            }
494                        }
495                    }
496                }
497            }
498
499            div {
500                class: "px-4 py-5 sm:p-6",
501                {children}
502            }
503        }
504    }
505}
506
507/// Dropdown menu component
508#[component]
509pub fn Dropdown(
510    #[props(default = false)] open: bool,
511    #[props(default = None)] on_toggle: Option<Callback<()>>,
512    #[props(default = "".to_string())] button_class: String,
513    #[props(default = "".to_string())] menu_class: String,
514    trigger: Element,
515    children: Element,
516) -> Element {
517    rsx! {
518        div {
519            class: "relative inline-block text-left",
520
521            // Trigger button
522            button {
523                r#type: "button",
524                class: format!("inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 {}", button_class),
525                onclick: move |_| {
526                    if let Some(handler) = &on_toggle {
527                        handler.call(());
528                    }
529                },
530                {trigger}
531                svg {
532                    class: "-mr-1 h-5 w-5 text-gray-400",
533                    xmlns: "http://www.w3.org/2000/svg",
534                    view_box: "0 0 20 20",
535                    fill: "currentColor",
536                    path {
537                        fill_rule: "evenodd",
538                        d: "M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z",
539                        clip_rule: "evenodd"
540                    }
541                }
542            }
543
544            // Dropdown menu
545            if open {
546                div {
547                    class: format!("absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none {}", menu_class),
548                    {children}
549                }
550            }
551        }
552    }
553}
554
555/// Tabs component
556#[component]
557pub fn Tabs(
558    #[props(default = "".to_string())] active_tab: String,
559    #[props(default = None)] on_tab_change: Option<Callback<String>>,
560    tabs: Vec<TabItem>,
561    #[props(default = "".to_string())] class: String,
562) -> Element {
563    rsx! {
564        div {
565            class: format!("border-b border-gray-200 {}", class),
566            nav {
567                class: "-mb-px flex space-x-8",
568                for tab in tabs.iter() {  // Use .iter() instead of consuming
569                    button {
570                        key: "{tab.id}",
571                        r#type: "button",
572                        class: format!(
573                            "py-2 px-1 border-b-2 font-medium text-sm {}",
574                            if active_tab == tab.id {
575                                "border-blue-500 text-blue-600"
576                            } else {
577                                "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
578                            }
579                        ),
580                        onclick: {
581                            let tab_id = tab.id.clone();
582                            let handler = on_tab_change;
583                            move |_| {
584                                if let Some(ref handler) = handler {
585                                    handler.call(tab_id.clone());
586                                }
587                            }
588                        },
589                        "{tab.label}"
590                        if let Some(count) = tab.count {
591                            span {
592                                class: format!(
593                                    "ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {}",
594                                    if active_tab == tab.id {
595                                        "bg-blue-100 text-blue-600"
596                                    } else {
597                                        "bg-gray-100 text-gray-900"
598                                    }
599                                ),
600                                "{count}"
601                            }
602                        }
603                    }
604                }
605            }
606        }
607    }
608}
609
610/// Tab item data structure
611#[derive(Debug, Clone, PartialEq)]
612pub struct TabItem {
613    pub id: String,
614    pub label: String,
615    pub count: Option<u32>,
616}
617
618/// Toggle/Switch component
619#[component]
620pub fn Toggle(
621    #[props(default = false)] checked: bool,
622    #[props(default = false)] disabled: bool,
623    #[props(default = None)] on_change: Option<Callback<bool>>,
624    #[props(default = "".to_string())] class: String,
625) -> Element {
626    rsx! {
627        button {
628            r#type: "button",
629            class: format!(
630                "relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {} {}",
631                if checked { "bg-blue-600" } else { "bg-gray-200" },
632                if disabled { "opacity-50 cursor-not-allowed" } else { "" }
633            ),
634            disabled: disabled,
635            onclick: move |_| {
636                if !disabled {
637                    if let Some(handler) = &on_change {
638                        handler.call(!checked);
639                    }
640                }
641            },
642            span {
643                class: format!(
644                    "pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {}",
645                    if checked { "translate-x-5" } else { "translate-x-0" }
646                )
647            }
648        }
649    }
650}
651
652/// Tooltip component (simple implementation)
653#[component]
654pub fn Tooltip(
655    #[props(default = "".to_string())] text: String,
656    #[props(default = "top".to_string())] position: String,
657    #[props(default = "".to_string())] class: String,
658    children: Element,
659) -> Element {
660    rsx! {
661        div {
662            class: format!("relative inline-block {}", class),
663            title: "{text}",
664            {children}
665        }
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn test_button_component() {
675        let _button = rsx! {
676            Button {
677                variant: "primary".to_string(),
678                "Click me"
679            }
680        };
681    }
682
683    #[test]
684    fn test_input_component() {
685        let _input = rsx! {
686            Input {
687                input_type: "email".to_string(),
688                placeholder: "Enter email".to_string()
689            }
690        };
691    }
692
693    #[test]
694    fn test_alert_component() {
695        let _alert = rsx! {
696            Alert {
697                variant: "success".to_string(),
698                title: "Success".to_string(),
699                "Operation completed"
700            }
701        };
702    }
703
704    #[test]
705    fn test_tab_item() {
706        let tab = TabItem {
707            id: "test".to_string(),
708            label: "Test Tab".to_string(),
709            count: Some(5),
710        };
711        assert_eq!(tab.id, "test");
712        assert_eq!(tab.count, Some(5));
713    }
714}