1use dioxus::prelude::*;
4
5#[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#[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#[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#[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#[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 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 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#[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#[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#[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#[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#[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 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 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#[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() { 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#[derive(Debug, Clone, PartialEq)]
612pub struct TabItem {
613 pub id: String,
614 pub label: String,
615 pub count: Option<u32>,
616}
617
618#[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#[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}