dioxus_ui_system/organisms/
data_table.rs1use dioxus::prelude::*;
6use crate::theme::{use_theme, use_style};
7use crate::styles::Style;
8use crate::atoms::{Label, TextSize, TextColor, Button, ButtonVariant, ButtonSize, Icon, IconSize, IconColor};
9
10#[derive(Clone, PartialEq)]
12pub struct TableColumn<T: 'static> {
13 pub key: String,
14 pub header: String,
15 pub width: Option<String>,
16 pub align: ColumnAlign,
17 pub sortable: bool,
18 pub render: Option<fn(&T) -> Element>,
19}
20
21#[derive(Default, Clone, PartialEq)]
23pub enum ColumnAlign {
24 #[default]
25 Left,
26 Center,
27 Right,
28}
29
30impl ColumnAlign {
31 fn as_str(&self) -> &'static str {
32 match self {
33 ColumnAlign::Left => "left",
34 ColumnAlign::Center => "center",
35 ColumnAlign::Right => "right",
36 }
37 }
38}
39
40#[derive(Props, Clone, PartialEq)]
42pub struct DataTableProps<T: Clone + PartialEq + 'static> {
43 pub columns: Vec<TableColumn<T>>,
45 pub data: Vec<T>,
47 pub key_extractor: fn(&T) -> String,
49 #[props(default)]
51 pub selectable: bool,
52 #[props(default)]
54 pub selected_keys: Vec<String>,
55 #[props(default)]
57 pub on_selection_change: Option<EventHandler<Vec<String>>>,
58 #[props(default)]
60 pub on_row_click: Option<EventHandler<T>>,
61 #[props(default = "No data available")]
63 pub empty_message: &'static str,
64 #[props(default)]
66 pub loading: bool,
67 #[props(default)]
69 pub style: Option<String>,
70}
71
72#[component]
117pub fn DataTable<T: Clone + PartialEq + 'static>(props: DataTableProps<T>) -> Element {
118 let style = use_style(|t| {
119 Style::new()
120 .w_full()
121 .rounded(&t.radius, "md")
122 .border(1, &t.colors.border)
123 .overflow_hidden()
124 .build()
125 });
126
127 let final_style = if let Some(custom) = &props.style {
128 format!("{} {}", style(), custom)
129 } else {
130 style()
131 };
132
133 let table_style = use_style(|t| {
134 Style::new()
135 .w_full()
136 .text(&t.typography, "sm")
137 .build()
138 });
139
140 if props.loading {
142 return rsx! {
143 div {
144 style: "{final_style}",
145 div {
146 style: "padding: 48px; text-align: center;",
147 Icon {
148 name: "spinner".to_string(),
149 size: IconSize::Large,
150 color: IconColor::Muted,
151 }
152 }
153 }
154 };
155 }
156
157 if props.data.is_empty() {
159 return rsx! {
160 div {
161 style: "{final_style}",
162 div {
163 style: "padding: 48px; text-align: center;",
164 Label {
165 size: TextSize::Small,
166 color: TextColor::Muted,
167 "{props.empty_message}"
168 }
169 }
170 }
171 };
172 }
173
174 let columns = props.columns.clone();
175 let data = props.data.clone();
176 let selectable = props.selectable;
177 let selected_keys = props.selected_keys.clone();
178
179 rsx! {
180 div {
181 style: "{final_style}",
182
183 table {
184 style: "{table_style}",
185
186 DataTableHeader {
187 columns: columns.clone(),
188 selectable: selectable,
189 selected_count: selected_keys.len(),
190 total_count: data.len(),
191 on_select_all: props.on_selection_change.clone(),
192 }
193
194 tbody {
195 for row in data {
196 DataTableRow {
197 row: row.clone(),
198 columns: columns.clone(),
199 key_extractor: props.key_extractor,
200 selectable: selectable,
201 is_selected: selected_keys.contains(&(props.key_extractor)(&row)),
202 on_select: props.on_selection_change.clone(),
203 on_click: props.on_row_click.clone(),
204 }
205 }
206 }
207 }
208 }
209 }
210}
211
212#[derive(Props, Clone, PartialEq)]
214pub struct DataTableHeaderProps<T: Clone + PartialEq + 'static> {
215 pub columns: Vec<TableColumn<T>>,
216 pub selectable: bool,
217 pub selected_count: usize,
218 pub total_count: usize,
219 pub on_select_all: Option<EventHandler<Vec<String>>>,
220}
221
222#[component]
223pub fn DataTableHeader<T: Clone + PartialEq>(props: DataTableHeaderProps<T>) -> Element {
224 let _theme = use_theme();
225
226 let header_style = use_style(|t| {
227 Style::new()
228 .bg(&t.colors.muted)
229 .text_color(&t.colors.foreground)
230 .font_weight(500)
231 .build()
232 });
233
234 let th_style = use_style(|t| {
235 Style::new()
236 .p(&t.spacing, "md")
237 .text_align("left")
238 .border_bottom(1, &t.colors.border)
239 .build()
240 });
241
242 let all_selected = props.selected_count == props.total_count && props.total_count > 0;
243
244 rsx! {
245 thead {
246 style: "{header_style}",
247
248 tr {
249 if props.selectable {
250 th {
251 style: "width: 48px; padding: 12px;",
252 input {
253 r#type: "checkbox",
254 checked: all_selected,
255 onchange: move |_| {
256 },
258 }
259 }
260 }
261
262 for col in props.columns {
263 th {
264 style: "{th_style} text-align: {col.align.as_str()}; width: {col.width.clone().unwrap_or_default()};",
265
266 if col.sortable {
267 div {
268 style: "display: inline-flex; align-items: center; gap: 4px; cursor: pointer;",
269 "{col.header}"
270 Icon {
271 name: "chevron-down".to_string(),
272 size: IconSize::Small,
273 color: IconColor::Muted,
274 }
275 }
276 } else {
277 "{col.header}"
278 }
279 }
280 }
281 }
282 }
283 }
284}
285
286#[derive(Props, Clone, PartialEq)]
288pub struct DataTableRowProps<T: Clone + PartialEq + 'static> {
289 pub row: T,
290 pub columns: Vec<TableColumn<T>>,
291 pub key_extractor: fn(&T) -> String,
292 pub selectable: bool,
293 pub is_selected: bool,
294 pub on_select: Option<EventHandler<Vec<String>>>,
295 pub on_click: Option<EventHandler<T>>,
296}
297
298#[component]
299pub fn DataTableRow<T: Clone + PartialEq + 'static>(props: DataTableRowProps<T>) -> Element {
300 let _theme = use_theme();
301 let _key = (props.key_extractor)(&props.row);
302 let is_selected = props.is_selected;
303 let _has_onclick = props.on_click.is_some();
304
305 let mut is_hovered = use_signal(|| false);
306
307 let row_style = use_style(move |t| {
308 let base = Style::new()
309 .border_bottom(1, &t.colors.border)
310 .transition("background-color 150ms ease");
311
312 if is_selected {
313 base.bg(&t.colors.primary.blend(&t.colors.background, 0.9))
314 } else if is_hovered() {
315 base.bg(&t.colors.muted)
316 } else {
317 base
318 }.build()
319 });
320
321 let td_style = use_style(|t| {
322 Style::new()
323 .p(&t.spacing, "md")
324 .build()
325 });
326
327 let row_data = props.row.clone();
328 let onclick_handler = props.on_click.clone();
329
330 rsx! {
331 tr {
332 style: "{row_style}",
333 onmouseenter: move |_| is_hovered.set(true),
334 onmouseleave: move |_| is_hovered.set(false),
335 onclick: move |_| {
336 if let Some(handler) = &onclick_handler {
337 handler.call(row_data.clone());
338 }
339 },
340
341 if props.selectable {
342 td {
343 style: "width: 48px; padding: 12px;",
344 input {
345 r#type: "checkbox",
346 checked: is_selected,
347 onchange: move |_| {
348 },
350 }
351 }
352 }
353
354 for col in props.columns {
355 DataTableCell {
356 row: props.row.clone(),
357 column: col,
358 base_style: td_style(),
359 }
360 }
361 }
362 }
363}
364
365#[derive(Props, Clone, PartialEq)]
367pub struct DataTableCellProps<T: Clone + PartialEq + 'static> {
368 pub row: T,
369 pub column: TableColumn<T>,
370 pub base_style: String,
371}
372
373#[component]
374pub fn DataTableCell<T: Clone + PartialEq>(props: DataTableCellProps<T>) -> Element {
375 let col = props.column.clone();
376 let align = col.align.as_str();
377
378 let cell_content = if let Some(render_fn) = col.render {
380 render_fn(&props.row)
381 } else {
382 rsx! {
385 Label {
386 size: TextSize::Small,
387 "-"
388 }
389 }
390 };
391
392 rsx! {
393 td {
394 style: "{props.base_style} text-align: {align};",
395 {cell_content}
396 }
397 }
398}
399
400#[derive(Props, Clone, PartialEq)]
402pub struct PaginationProps {
403 pub current_page: usize,
404 pub total_pages: usize,
405 pub on_page_change: EventHandler<usize>,
406 #[props(default)]
407 pub show_first_last: bool,
408}
409
410#[component]
411pub fn Pagination(props: PaginationProps) -> Element {
412 let current = props.current_page;
413 let total = props.total_pages;
414
415 let container_style = use_style(|t| {
416 Style::new()
417 .flex()
418 .items_center()
419 .justify_between()
420 .px(&t.spacing, "md")
421 .py(&t.spacing, "sm")
422 .border_top(1, &t.colors.border)
423 .build()
424 });
425
426 let info_style = use_style(|t| {
427 Style::new()
428 .text(&t.typography, "sm")
429 .text_color(&t.colors.muted_foreground)
430 .build()
431 });
432
433 rsx! {
434 div {
435 style: "{container_style}",
436
437 div {
438 style: "{info_style}",
439 "Page {current + 1} of {total}"
440 }
441
442 div {
443 style: "display: flex; align-items: center; gap: 4px;",
444
445 if props.show_first_last && current > 0 {
446 Button {
447 variant: ButtonVariant::Ghost,
448 size: ButtonSize::Sm,
449 onclick: move |_| props.on_page_change.call(0),
450 Icon {
451 name: "chevron-left".to_string(),
452 size: IconSize::Small,
453 color: IconColor::Current,
454 }
455 }
456 }
457
458 Button {
459 variant: ButtonVariant::Ghost,
460 size: ButtonSize::Sm,
461 disabled: current == 0,
462 onclick: move |_| props.on_page_change.call(current.saturating_sub(1)),
463 Icon {
464 name: "chevron-left".to_string(),
465 size: IconSize::Small,
466 color: IconColor::Current,
467 }
468 }
469
470 Button {
471 variant: ButtonVariant::Ghost,
472 size: ButtonSize::Sm,
473 disabled: current >= total - 1,
474 onclick: move |_| props.on_page_change.call((current + 1).min(total - 1)),
475 Icon {
476 name: "chevron-right".to_string(),
477 size: IconSize::Small,
478 color: IconColor::Current,
479 }
480 }
481
482 if props.show_first_last && current < total - 1 {
483 Button {
484 variant: ButtonVariant::Ghost,
485 size: ButtonSize::Sm,
486 onclick: move |_| props.on_page_change.call(total - 1),
487 Icon {
488 name: "chevron-right".to_string(),
489 size: IconSize::Small,
490 color: IconColor::Current,
491 }
492 }
493 }
494 }
495 }
496 }
497}
498