1#![allow(unpredictable_function_pointer_comparisons)]
6
7use crate::atoms::{
8 Button, ButtonSize, ButtonVariant, Icon, IconColor, IconSize, Label, TextColor, TextSize,
9};
10use crate::styles::Style;
11use crate::theme::{use_style, use_theme};
12use dioxus::prelude::*;
13
14#[derive(Clone, PartialEq)]
16pub struct FilterOption {
17 pub label: String,
18 pub value: String,
19}
20
21#[derive(Clone, PartialEq)]
23pub struct TableFilter {
24 pub key: String,
25 pub label: String,
26 pub options: Vec<FilterOption>,
27}
28
29#[derive(Clone, PartialEq)]
31pub struct TableColumn<T: 'static> {
32 pub key: String,
33 pub header: String,
34 pub width: Option<String>,
35 pub align: ColumnAlign,
36 pub sortable: bool,
37 pub render: Option<fn(&T) -> Element>,
38}
39
40#[derive(Default, Clone, PartialEq)]
42pub enum ColumnAlign {
43 #[default]
44 Left,
45 Center,
46 Right,
47}
48
49impl ColumnAlign {
50 fn as_str(&self) -> &'static str {
51 match self {
52 ColumnAlign::Left => "left",
53 ColumnAlign::Center => "center",
54 ColumnAlign::Right => "right",
55 }
56 }
57}
58
59#[derive(Props, Clone, PartialEq)]
61pub struct DataTableProps<T: Clone + PartialEq + 'static> {
62 pub columns: Vec<TableColumn<T>>,
64 pub data: Vec<T>,
66 pub key_extractor: fn(&T) -> String,
68 #[props(default)]
70 pub selectable: bool,
71 #[props(default)]
73 pub selected_keys: Vec<String>,
74 #[props(default)]
76 pub on_selection_change: Option<EventHandler<Vec<String>>>,
77 #[props(default)]
79 pub on_row_click: Option<EventHandler<T>>,
80 #[props(default = "No data available")]
82 pub empty_message: &'static str,
83 #[props(default)]
85 pub loading: bool,
86 #[props(default)]
88 pub style: Option<String>,
89 #[props(default = "Search...")]
91 pub search_placeholder: &'static str,
92 #[props(default)]
94 pub search_query: Option<String>,
95 #[props(default)]
97 pub on_search_change: Option<EventHandler<String>>,
98 #[props(default)]
100 pub filters: Vec<TableFilter>,
101 #[props(default)]
103 pub active_filters: std::collections::HashMap<String, String>,
104 #[props(default)]
106 pub on_filter_change: Option<EventHandler<(String, String)>>,
107 #[props(default = true)]
109 pub show_search: bool,
110 #[props(default = true)]
112 pub show_filters: bool,
113}
114
115#[component]
160pub fn DataTable<T: Clone + PartialEq + 'static>(props: DataTableProps<T>) -> Element {
161 let style = use_style(|t| {
162 Style::new()
163 .w_full()
164 .rounded(&t.radius, "md")
165 .border(1, &t.colors.border)
166 .overflow_hidden()
167 .build()
168 });
169
170 let final_style = if let Some(custom) = &props.style {
171 format!("{} {}", style(), custom)
172 } else {
173 style()
174 };
175
176 let table_style = use_style(|t| Style::new().w_full().text(&t.typography, "sm").build());
177
178 let toolbar_style = use_style(|t| {
179 Style::new()
180 .flex()
181 .items_center()
182 .justify_between()
183 .px(&t.spacing, "md")
184 .py(&t.spacing, "sm")
185 .border_bottom(1, &t.colors.border)
186 .bg(&t.colors.background)
187 .gap(&t.spacing, "sm")
188 .build()
189 });
190
191 let search_container_style = use_style(|t| {
192 Style::new()
193 .flex()
194 .items_center()
195 .gap(&t.spacing, "sm")
196 .build()
197 });
198
199 let filters_container_style = use_style(|t| {
200 Style::new()
201 .flex()
202 .items_center()
203 .gap(&t.spacing, "sm")
204 .build()
205 });
206
207 let show_toolbar = (props.show_search && props.on_search_change.is_some())
208 || (props.show_filters && !props.filters.is_empty() && props.on_filter_change.is_some());
209
210 if props.loading {
212 return rsx! {
213 div {
214 style: "{final_style}",
215 div {
216 style: "padding: 48px; text-align: center;",
217 Icon {
218 name: "spinner".to_string(),
219 size: IconSize::Large,
220 color: IconColor::Muted,
221 }
222 }
223 }
224 };
225 }
226
227 if props.data.is_empty() {
229 return rsx! {
230 div {
231 style: "{final_style}",
232 div {
233 style: "padding: 48px; text-align: center;",
234 Label {
235 size: TextSize::Small,
236 color: TextColor::Muted,
237 "{props.empty_message}"
238 }
239 }
240 }
241 };
242 }
243
244 let columns = props.columns.clone();
245 let data = props.data.clone();
246 let selectable = props.selectable;
247 let selected_keys = props.selected_keys.clone();
248 let search_query = props.search_query.clone().unwrap_or_default();
249
250 rsx! {
251 div {
252 style: "{final_style}",
253
254 if show_toolbar {
255 div {
256 style: "{toolbar_style}",
257
258 if props.show_search && props.on_search_change.is_some() {
259 div {
260 style: "{search_container_style} flex: 1;",
261 Icon {
262 name: "search".to_string(),
263 size: IconSize::Small,
264 color: IconColor::Muted,
265 }
266 input {
267 r#type: "text",
268 placeholder: "{props.search_placeholder}",
269 value: "{search_query}",
270 style: "flex: 1; min-width: 200px; padding: 8px 12px; border: 1px solid rgb(226,232,240); border-radius: 6px; font-size: 14px; outline: none; &:focus {{ border-color: rgb(59,130,246); }}",
271 oninput: move |e| {
272 if let Some(handler) = &props.on_search_change {
273 handler.call(e.value());
274 }
275 },
276 }
277 if !search_query.is_empty() {
278 Button {
279 variant: ButtonVariant::Ghost,
280 size: ButtonSize::Sm,
281 onclick: move |_| {
282 if let Some(handler) = &props.on_search_change {
283 handler.call("".to_string());
284 }
285 },
286 Icon {
287 name: "x".to_string(),
288 size: IconSize::Small,
289 color: IconColor::Muted,
290 }
291 }
292 }
293 }
294 }
295
296 if props.show_filters && !props.filters.is_empty() && props.on_filter_change.is_some() {
297 div {
298 style: "{filters_container_style}",
299 for filter in props.filters.clone() {
300 DataTableFilter {
301 filter: filter.clone(),
302 active_value: props.active_filters.get(&filter.key).cloned().unwrap_or_default(),
303 on_change: props.on_filter_change.clone(),
304 }
305 }
306 }
307 }
308 }
309 }
310
311 table {
312 style: "{table_style}",
313
314 DataTableHeader {
315 columns: columns.clone(),
316 selectable: selectable,
317 selected_count: selected_keys.len(),
318 total_count: data.len(),
319 on_select_all: props.on_selection_change.clone(),
320 }
321
322 tbody {
323 for row in data {
324 DataTableRow {
325 row: row.clone(),
326 columns: columns.clone(),
327 key_extractor: props.key_extractor,
328 selectable: selectable,
329 is_selected: selected_keys.contains(&(props.key_extractor)(&row)),
330 on_select: props.on_selection_change.clone(),
331 on_click: props.on_row_click.clone(),
332 }
333 }
334 }
335 }
336 }
337 }
338}
339
340#[derive(Props, Clone, PartialEq)]
342pub struct DataTableFilterProps {
343 pub filter: TableFilter,
344 pub active_value: String,
345 pub on_change: Option<EventHandler<(String, String)>>,
346}
347
348#[component]
349pub fn DataTableFilter(props: DataTableFilterProps) -> Element {
350 let filter = props.filter.clone();
351 let active_value = props.active_value.clone();
352 let has_value = !active_value.is_empty();
353
354 rsx! {
355 select {
356 style: if has_value {
357 "padding: 8px 12px; border: 1px solid rgb(59,130,246); border-radius: 6px; font-size: 14px; background: white; cursor: pointer; outline: none; color: rgb(15,23,42);"
358 } else {
359 "padding: 8px 12px; border: 1px solid rgb(226,232,240); border-radius: 6px; font-size: 14px; background: white; cursor: pointer; outline: none; color: rgb(100,116,139);"
360 },
361 onchange: move |e| {
362 if let Some(handler) = &props.on_change {
363 handler.call((filter.key.clone(), e.value()));
364 }
365 },
366 option {
367 value: "",
368 selected: active_value.is_empty(),
369 "{filter.label}"
370 }
371 for option in filter.options {
372 option {
373 value: "{option.value}",
374 selected: active_value == option.value,
375 "{option.label}"
376 }
377 }
378 }
379 }
380}
381
382#[derive(Props, Clone, PartialEq)]
384pub struct DataTableHeaderProps<T: Clone + PartialEq + 'static> {
385 pub columns: Vec<TableColumn<T>>,
386 pub selectable: bool,
387 pub selected_count: usize,
388 pub total_count: usize,
389 pub on_select_all: Option<EventHandler<Vec<String>>>,
390}
391
392#[component]
393pub fn DataTableHeader<T: Clone + PartialEq>(props: DataTableHeaderProps<T>) -> Element {
394 let _theme = use_theme();
395
396 let header_style = use_style(|t| {
397 Style::new()
398 .bg(&t.colors.muted)
399 .text_color(&t.colors.foreground)
400 .font_weight(500)
401 .build()
402 });
403
404 let th_style = use_style(|t| {
405 Style::new()
406 .p(&t.spacing, "md")
407 .text_align("left")
408 .border_bottom(1, &t.colors.border)
409 .build()
410 });
411
412 let all_selected = props.selected_count == props.total_count && props.total_count > 0;
413
414 rsx! {
415 thead {
416 style: "{header_style}",
417
418 tr {
419 if props.selectable {
420 th {
421 style: "width: 48px; padding: 12px;",
422 input {
423 r#type: "checkbox",
424 checked: all_selected,
425 onchange: move |_| {
426 },
428 }
429 }
430 }
431
432 for col in props.columns {
433 th {
434 style: "{th_style} text-align: {col.align.as_str()}; width: {col.width.clone().unwrap_or_default()};",
435
436 if col.sortable {
437 div {
438 style: "display: inline-flex; align-items: center; gap: 4px; cursor: pointer;",
439 "{col.header}"
440 Icon {
441 name: "chevron-down".to_string(),
442 size: IconSize::Small,
443 color: IconColor::Muted,
444 }
445 }
446 } else {
447 "{col.header}"
448 }
449 }
450 }
451 }
452 }
453 }
454}
455
456#[derive(Props, Clone, PartialEq)]
458pub struct DataTableRowProps<T: Clone + PartialEq + 'static> {
459 pub row: T,
460 pub columns: Vec<TableColumn<T>>,
461 pub key_extractor: fn(&T) -> String,
462 pub selectable: bool,
463 pub is_selected: bool,
464 pub on_select: Option<EventHandler<Vec<String>>>,
465 pub on_click: Option<EventHandler<T>>,
466}
467
468#[component]
469pub fn DataTableRow<T: Clone + PartialEq + 'static>(props: DataTableRowProps<T>) -> Element {
470 let _theme = use_theme();
471 let _key = (props.key_extractor)(&props.row);
472 let is_selected = props.is_selected;
473 let _has_onclick = props.on_click.is_some();
474
475 let mut is_hovered = use_signal(|| false);
476
477 let row_style = use_style(move |t| {
478 let base = Style::new()
479 .border_bottom(1, &t.colors.border)
480 .transition("background-color 150ms ease");
481
482 if is_selected {
483 base.bg(&t.colors.primary.blend(&t.colors.background, 0.9))
484 } else if is_hovered() {
485 base.bg(&t.colors.muted)
486 } else {
487 base
488 }
489 .build()
490 });
491
492 let td_style = use_style(|t| Style::new().p(&t.spacing, "md").build());
493
494 let row_data = props.row.clone();
495 let onclick_handler = props.on_click.clone();
496
497 rsx! {
498 tr {
499 style: "{row_style}",
500 onmouseenter: move |_| is_hovered.set(true),
501 onmouseleave: move |_| is_hovered.set(false),
502 onclick: move |_| {
503 if let Some(handler) = &onclick_handler {
504 handler.call(row_data.clone());
505 }
506 },
507
508 if props.selectable {
509 td {
510 style: "width: 48px; padding: 12px;",
511 input {
512 r#type: "checkbox",
513 checked: is_selected,
514 onchange: move |_| {
515 },
517 }
518 }
519 }
520
521 for col in props.columns {
522 DataTableCell {
523 row: props.row.clone(),
524 column: col,
525 base_style: td_style(),
526 }
527 }
528 }
529 }
530}
531
532#[derive(Props, Clone, PartialEq)]
534pub struct DataTableCellProps<T: Clone + PartialEq + 'static> {
535 pub row: T,
536 pub column: TableColumn<T>,
537 pub base_style: String,
538}
539
540#[component]
541pub fn DataTableCell<T: Clone + PartialEq>(props: DataTableCellProps<T>) -> Element {
542 let col = props.column.clone();
543 let align = col.align.as_str();
544
545 let cell_content = if let Some(render_fn) = col.render {
547 render_fn(&props.row)
548 } else {
549 rsx! {
552 Label {
553 size: TextSize::Small,
554 "-"
555 }
556 }
557 };
558
559 rsx! {
560 td {
561 style: "{props.base_style} text-align: {align};",
562 {cell_content}
563 }
564 }
565}
566
567#[derive(Props, Clone, PartialEq)]
569pub struct PaginationProps {
570 pub current_page: usize,
571 pub total_pages: usize,
572 pub on_page_change: EventHandler<usize>,
573 #[props(default)]
574 pub show_first_last: bool,
575}
576
577#[component]
578pub fn Pagination(props: PaginationProps) -> Element {
579 let current = props.current_page;
580 let total = props.total_pages;
581
582 let container_style = use_style(|t| {
583 Style::new()
584 .flex()
585 .items_center()
586 .justify_between()
587 .px(&t.spacing, "md")
588 .py(&t.spacing, "sm")
589 .border_top(1, &t.colors.border)
590 .build()
591 });
592
593 let info_style = use_style(|t| {
594 Style::new()
595 .text(&t.typography, "sm")
596 .text_color(&t.colors.muted_foreground)
597 .build()
598 });
599
600 rsx! {
601 div {
602 style: "{container_style}",
603
604 div {
605 style: "{info_style}",
606 "Page {current + 1} of {total}"
607 }
608
609 div {
610 style: "display: flex; align-items: center; gap: 4px;",
611
612 if props.show_first_last && current > 0 {
613 Button {
614 variant: ButtonVariant::Ghost,
615 size: ButtonSize::Sm,
616 onclick: move |_| props.on_page_change.call(0),
617 Icon {
618 name: "chevron-left".to_string(),
619 size: IconSize::Small,
620 color: IconColor::Current,
621 }
622 }
623 }
624
625 Button {
626 variant: ButtonVariant::Ghost,
627 size: ButtonSize::Sm,
628 disabled: current == 0,
629 onclick: move |_| props.on_page_change.call(current.saturating_sub(1)),
630 Icon {
631 name: "chevron-left".to_string(),
632 size: IconSize::Small,
633 color: IconColor::Current,
634 }
635 }
636
637 Button {
638 variant: ButtonVariant::Ghost,
639 size: ButtonSize::Sm,
640 disabled: current >= total - 1,
641 onclick: move |_| props.on_page_change.call((current + 1).min(total - 1)),
642 Icon {
643 name: "chevron-right".to_string(),
644 size: IconSize::Small,
645 color: IconColor::Current,
646 }
647 }
648
649 if props.show_first_last && current < total - 1 {
650 Button {
651 variant: ButtonVariant::Ghost,
652 size: ButtonSize::Sm,
653 onclick: move |_| props.on_page_change.call(total - 1),
654 Icon {
655 name: "chevron-right".to_string(),
656 size: IconSize::Small,
657 color: IconColor::Current,
658 }
659 }
660 }
661 }
662 }
663 }
664}