diff --git a/Cargo.lock b/Cargo.lock index f981747..f8bdd29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1871,6 +1871,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "dx-admin-template" version = "0.1.0" dependencies = [ + "chrono", "dioxus 0.7.0", "dioxus-motion", ] diff --git a/Cargo.toml b/Cargo.toml index 0d83bea..cc01449 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" [dependencies] dioxus = { version = "0.7.0", features = ["router", "fullstack"] } dioxus-motion = "0.3.1" +chrono = { version = "0.4", default-features = false, features = ["std"] } [features] default = ["web"] diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 79958e8..10c22b5 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -192,6 +192,37 @@ pointer-events: none; } +.ui-toggle { + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + border-radius: calc(var(--radius) - 0.25rem); + padding: 0.35rem 0.75rem; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, + transform 0.2s ease; +} + +.ui-toggle[data-state="on"] { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); +} + +.ui-toggle:hover { + transform: translateY(-1px); +} + +.ui-toggle:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + .ui-badge { align-items: center; background-color: hsl(var(--primary) / 0.1); @@ -559,6 +590,30 @@ gap: 0.75rem; } +.ui-scroll-area { + position: relative; + overflow: auto; + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + padding: 0.75rem; + box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.35); +} + +.ui-scroll-area::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; +} + +.ui-scroll-area::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted)); + border-radius: 9999px; +} + +.ui-scroll-area::-webkit-scrollbar-track { + background: transparent; +} + .ui-bleed { display: flex; align-items: center; @@ -930,6 +985,284 @@ color: hsl(var(--destructive)); } +.ui-combobox { + position: relative; + display: inline-flex; + flex-direction: column; + gap: 0.6rem; + width: 100%; +} + +.ui-combobox[data-disabled="true"] { + opacity: 0.6; + pointer-events: none; +} + +.ui-combobox-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + height: 2.5rem; + padding: 0 0.85rem; + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +.ui-combobox-trigger:hover { + background-color: hsl(var(--accent) / 0.4); +} + +.ui-combobox-content { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + z-index: 40; + display: flex; + flex-direction: column; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background-color: hsl(var(--popover)); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.ui-combobox-search { + padding: 0.5rem 0.65rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.ui-combobox-input { + width: 100%; + border: 1px solid hsl(var(--border) / 0.6); + border-radius: calc(var(--radius) - 4px); + padding: 0.45rem 0.6rem; + font-size: 0.875rem; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.ui-combobox-input:focus-visible { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.35); +} + +.ui-combobox-list { + max-height: 14rem; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.ui-combobox-item { + display: flex; + align-items: flex-start; + width: 100%; + padding: 0.65rem 0.85rem; + border: none; + background: transparent; +} + +.ui-combobox-item button { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + gap: 0.2rem; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + color: hsl(var(--foreground)); + font-size: 0.85rem; + transition: background-color 0.2s ease; +} + +.ui-combobox-item[data-state="active"] button { + background-color: hsl(var(--muted)); +} + +.ui-combobox-item button:hover { + background-color: hsl(var(--muted) / 0.8); +} + +.ui-combobox-label { + font-weight: 500; +} + +.ui-combobox-description { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.ui-combobox-empty { + padding: 1rem 0.85rem; + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + +.ui-combobox-caret { + font-size: 0.8rem; + opacity: 0.6; +} + +.ui-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + overflow: hidden; + background-color: hsl(var(--background)); + box-shadow: var(--shadow-sm); +} + +.ui-table caption { + caption-side: bottom; +} + +.ui-table-header, +.ui-table-footer { + background-color: hsl(var(--muted) / 0.3); + color: hsl(var(--muted-foreground)); +} + +.ui-table-row { + border-bottom: 1px solid hsl(var(--border)); +} + +.ui-table-row:hover { + background-color: hsl(var(--muted) / 0.3); +} + +.ui-table-head { + text-align: left; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.75rem 1rem; + color: hsl(var(--muted-foreground)); + border-bottom: 1px solid hsl(var(--border)); +} + +.ui-table-cell { + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); +} + +.ui-table-caption { + font-size: 0.8rem; + padding: 0.85rem; + color: hsl(var(--muted-foreground)); +} + +.ui-calendar { + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + padding: 1rem; + background-color: hsl(var(--background)); + display: flex; + flex-direction: column; + gap: 0.8rem; + box-shadow: var(--shadow-sm); +} + +.ui-calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.ui-calendar-title { + font-weight: 600; +} + +.ui-calendar-nav { + background-color: hsl(var(--muted)); + border: none; + color: hsl(var(--foreground)); + width: 2rem; + height: 2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: calc(var(--radius) - 4px); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.ui-calendar-nav:hover { + background-color: hsl(var(--muted) / 0.8); +} + +.ui-calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.35rem; + font-size: 0.7rem; + text-transform: uppercase; + color: hsl(var(--muted-foreground)); +} + +.ui-calendar-weekday { + text-align: center; + font-weight: 600; + letter-spacing: 0.05em; +} + +.ui-calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.35rem; +} + +.ui-calendar-day { + border: none; + border-radius: calc(var(--radius) - 4px); + padding: 0.55rem 0; + font-size: 0.85rem; + background-color: hsl(var(--muted) / 0.2); + color: hsl(var(--foreground)); + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease, + transform 0.2s ease; +} + +.ui-calendar-day[data-outside="true"] { + color: hsl(var(--muted-foreground)); + background-color: transparent; +} + +.ui-calendar-day[data-state="selected"] { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.ui-calendar-day:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ui-calendar-day:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + .ui-tooltip-wrapper { position: relative; display: inline-flex; @@ -1319,6 +1652,36 @@ transition: transform 0.2s ease; } +.ui-skeleton { + position: relative; + overflow: hidden; + background-color: hsl(var(--muted)); + border-radius: calc(var(--radius) - 4px); +} + +.ui-skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + hsl(var(--muted) / 0.5), + transparent + ); + animation: ui-skeleton-shimmer 1.5s infinite; +} + +@keyframes ui-skeleton-shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + .ui-form-field { display: flex; flex-direction: column; @@ -1330,6 +1693,16 @@ font-size: 0.75rem; } +.ui-form-message { + font-size: 0.75rem; + line-height: 1.2; + color: hsl(var(--muted-foreground)); +} + +.ui-form-message[data-variant="error"] { + color: hsl(var(--destructive)); +} + .ui-sidebar-layout { background-color: hsl(var(--card)); border: 1px solid hsl(var(--border)); diff --git a/src/components/ui/calendar.rs b/src/components/ui/calendar.rs new file mode 100644 index 0000000..053f744 --- /dev/null +++ b/src/components/ui/calendar.rs @@ -0,0 +1,140 @@ +use chrono::{Datelike, Duration, NaiveDate}; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +fn first_day_of_month(date: NaiveDate) -> NaiveDate { + date.with_day(1).unwrap_or(date) +} + +fn next_month(date: NaiveDate) -> NaiveDate { + let year = if date.month() == 12 { + date.year() + 1 + } else { + date.year() + }; + let month = if date.month() == 12 { + 1 + } else { + date.month() + 1 + }; + NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date) +} + +fn previous_month(date: NaiveDate) -> NaiveDate { + let year = if date.month() == 1 { + date.year() - 1 + } else { + date.year() + }; + let month = if date.month() == 1 { + 12 + } else { + date.month() - 1 + }; + NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date) +} + +#[component] +pub fn Calendar( + #[props(into)] initial_month: NaiveDate, + #[props(optional)] selected: Option, + #[props(default)] show_outside_days: bool, + #[props(optional)] on_select: Option>, + #[props(into, default)] class: Option, +) -> Element { + let starting_month = first_day_of_month(initial_month); + let month = use_signal(move || starting_month); + let selection = use_signal(move || selected); + let mut month_signal = month.clone(); + let on_select_handler = on_select.clone(); + + let active_month = month(); + let month_label = active_month.format("%B %Y").to_string(); + let start_weekday = active_month.weekday().num_days_from_monday() as i64; + let mut first_visible = active_month - Duration::days(start_weekday); + let mut days = Vec::with_capacity(42); + for _ in 0..42 { + days.push(first_visible); + first_visible = first_visible + Duration::days(1); + } + + let current_selection = selection(); + + rsx! { + div { + class: merge_class("ui-calendar", class), + div { + class: "ui-calendar-header", + button { + class: "ui-calendar-nav", + r#type: "button", + "aria-label": "Go to previous month", + onclick: move |_| { + month_signal.set(previous_month(active_month)); + }, + "‹" + } + span { class: "ui-calendar-title", "{month_label}" } + button { + class: "ui-calendar-nav", + r#type: "button", + "aria-label": "Go to next month", + onclick: move |_| { + month_signal.set(next_month(active_month)); + }, + "›" + } + } + div { + class: "ui-calendar-weekdays", + for label in WEEKDAY_LABELS { + span { class: "ui-calendar-weekday", "{label}" } + } + } + div { + class: "ui-calendar-grid", + for day in days { + { + let is_current_month = day.month() == active_month.month(); + let is_selected = current_selection + .as_ref() + .map(|selected| *selected == day) + .unwrap_or(false); + let is_disabled = !show_outside_days && !is_current_month; + let day_display = day.day(); + let mut selection_signal = selection.clone(); + let handler = on_select_handler.clone(); + + rsx! { + button { + class: "ui-calendar-day", + r#type: "button", + "data-state": if is_selected { "selected" } else { "idle" }, + "data-outside": if is_current_month { "false" } else { "true" }, + disabled: is_disabled, + onclick: move |_| { + if !is_disabled { + selection_signal.set(Some(day)); + if let Some(callback) = handler.clone() { + callback.call(day); + } + } + }, + "{day_display}" + } + } + } + } + } + } + } +} diff --git a/src/components/ui/combobox.rs b/src/components/ui/combobox.rs new file mode 100644 index 0000000..fe9a033 --- /dev/null +++ b/src/components/ui/combobox.rs @@ -0,0 +1,176 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone, PartialEq)] +pub struct ComboboxOption { + pub label: String, + pub value: String, + pub description: Option, +} + +impl ComboboxOption { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + description: None, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } +} + +#[component] +pub fn Combobox( + #[props(into, default)] class: Option, + #[props(into, default)] id: Option, + #[props(into)] placeholder: String, + #[props(into, default)] search_placeholder: Option, + #[props(into)] options: Vec, + #[props(into, default)] selected: Option, + #[props(default)] disabled: bool, + #[props(optional)] on_select: Option>, +) -> Element { + let classes = merge_class("ui-combobox", class); + let trigger_id = id.unwrap_or_default(); + let search_placeholder = search_placeholder.unwrap_or_else(|| "Search...".to_string()); + let open = use_signal(|| false); + let current_selection = use_signal(move || selected.clone()); + let query = use_signal(|| String::new()); + let on_select_handler = on_select.clone(); + + let current_value = current_selection(); + let display_label = current_value + .as_ref() + .and_then(|value| { + options + .iter() + .find(|option| option.value == *value) + .map(|option| option.label.clone()) + }) + .unwrap_or_else(|| placeholder.clone()); + + let filtered_options: Vec = { + let query_text = query().to_lowercase(); + if query_text.is_empty() { + options.clone() + } else { + options + .iter() + .cloned() + .filter(|option| option.label.to_lowercase().contains(&query_text)) + .collect() + } + }; + + rsx! { + div { + class: classes, + "data-disabled": disabled, + onfocusout: { + let mut open_signal = open.clone(); + move |_| open_signal.set(false) + }, + button { + class: "ui-combobox-trigger", + id: trigger_id.clone(), + "aria-haspopup": "dialog", + "aria-expanded": if open() { "true" } else { "false" }, + disabled, + onclick: { + let mut open_signal = open.clone(); + move |_| { + if !disabled { + let next_state = !open_signal(); + open_signal.set(next_state); + if !next_state { + let mut query_signal = query.clone(); + query_signal.set(String::new()); + } + } + } + }, + span { "{display_label}" } + span { class: "ui-combobox-caret", if open() { "▲" } else { "▼" } } + } + if open() { + div { + class: "ui-combobox-content", + div { + class: "ui-combobox-search", + input { + class: "ui-combobox-input", + placeholder: search_placeholder.clone(), + r#type: "text", + autofocus: true, + value: "{query()}", + oninput: { + let mut query_signal = query.clone(); + move |event| query_signal.set(event.value()) + }, + } + } + if filtered_options.is_empty() { + div { + class: "ui-combobox-empty", + "No results found" + } + } else { + ul { + class: "ui-combobox-list", + for option in filtered_options { + { + let is_active = current_value + .as_ref() + .map(|value| value == &option.value) + .unwrap_or(false); + let option_value = option.value.clone(); + let option_label = option.label.clone(); + let option_description = option.description.clone(); + rsx! { + li { + class: "ui-combobox-item", + "data-state": if is_active { "active" } else { "inactive" }, + button { + r#type: "button", + onclick: { + let option_value = option_value.clone(); + let handler = on_select_handler.clone(); + let mut open_signal = open.clone(); + let mut current_signal = current_selection.clone(); + let mut query_signal = query.clone(); + move |_| { + current_signal.set(Some(option_value.clone())); + if let Some(callback) = handler.clone() { + callback.call(option_value.clone()); + } + open_signal.set(false); + query_signal.set(String::new()); + } + }, + span { class: "ui-combobox-label", "{option_label}" } + if let Some(description) = option_description { + span { class: "ui-combobox-description", "{description}" } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/components/ui/form_field.rs b/src/components/ui/form_field.rs new file mode 100644 index 0000000..f09acb3 --- /dev/null +++ b/src/components/ui/form_field.rs @@ -0,0 +1,97 @@ +use crate::components::ui::Label; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FormMessageVariant { + Helper, + Error, +} + +impl FormMessageVariant { + fn as_str(&self) -> &'static str { + match self { + FormMessageVariant::Helper => "helper", + FormMessageVariant::Error => "error", + } + } +} + +impl Default for FormMessageVariant { + fn default() -> Self { + FormMessageVariant::Helper + } +} + +#[component] +pub fn FormField( + #[props(into, default)] class: Option, + #[props(optional)] label: Option, + #[props(optional)] helper_text: Option, + #[props(optional)] description: Option, + #[props(optional)] error: Option>>, + #[props(into, default)] id: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-form-field", class); + let id_attr = id.unwrap_or_default(); + let error_signal = error; + let current_error = error_signal.map(|signal| signal()).flatten(); + + rsx! { + div { + class: classes, + if let Some(label_text) = label { + Label { + html_for: id_attr.clone(), + "{label_text}" + } + } + if let Some(description_text) = description { + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{description_text}" + } + } + {children} + if let Some(helper) = helper_text { + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{helper}" + } + } + if let Some(message) = current_error { + FormMessage { + variant: FormMessageVariant::Error, + "{message}" + } + } + } + } +} + +#[component] +pub fn FormMessage( + #[props(default)] variant: FormMessageVariant, + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-form-message", class); + + rsx! { + div { + class: classes, + "data-variant": variant.as_str(), + {children} + } + } +} diff --git a/src/components/ui/mod.rs b/src/components/ui/mod.rs index a7710cc..47de13d 100644 --- a/src/components/ui/mod.rs +++ b/src/components/ui/mod.rs @@ -8,12 +8,15 @@ mod avatar; mod badge; mod breadcrumb; mod button; +mod calendar; mod card; mod checkbox; +mod combobox; mod command; mod context_menu; mod dialog; mod dropdown_menu; +mod form_field; mod hover_card; mod input; mod label; @@ -23,16 +26,20 @@ mod pagination; mod popover; mod progress; mod radio_group; +mod scroll_area; mod select; mod separator; mod sheet; mod sidebar; +mod skeleton; mod slider; mod steps; mod switch; +mod table; mod tabs; mod textarea; mod toast; +mod toggle; mod tooltip; pub use accordion::*; @@ -41,12 +48,15 @@ pub use avatar::*; pub use badge::*; pub use breadcrumb::*; pub use button::*; +pub use calendar::*; pub use card::*; pub use checkbox::*; +pub use combobox::*; pub use command::*; pub use context_menu::*; pub use dialog::*; pub use dropdown_menu::*; +pub use form_field::*; pub use hover_card::*; pub use input::*; pub use label::*; @@ -56,14 +66,18 @@ pub use pagination::*; pub use popover::*; pub use progress::*; pub use radio_group::*; +pub use scroll_area::*; pub use select::*; pub use separator::*; pub use sheet::*; pub use sidebar::*; +pub use skeleton::*; pub use slider::*; pub use steps::*; pub use switch::*; +pub use table::*; pub use tabs::*; pub use textarea::*; pub use toast::*; +pub use toggle::*; pub use tooltip::*; diff --git a/src/components/ui/scroll_area.rs b/src/components/ui/scroll_area.rs new file mode 100644 index 0000000..f57ddea --- /dev/null +++ b/src/components/ui/scroll_area.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[component] +pub fn ScrollArea( + #[props(into, default)] class: Option, + #[props(into, default)] style: Option, + #[props(into, default)] max_height: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-scroll-area", class); + + let mut styles = Vec::new(); + if let Some(style) = style.filter(|style| !style.trim().is_empty()) { + styles.push(style); + } + if let Some(max_height) = max_height.filter(|height| !height.trim().is_empty()) { + styles.push(format!("max-height: {max_height};")); + } + let style_attr = styles.join(" "); + + rsx! { + div { + class: classes, + style: style_attr, + {children} + } + } +} diff --git a/src/components/ui/skeleton.rs b/src/components/ui/skeleton.rs new file mode 100644 index 0000000..a98909a --- /dev/null +++ b/src/components/ui/skeleton.rs @@ -0,0 +1,38 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[component] +pub fn Skeleton( + #[props(into, default)] class: Option, + #[props(into, default)] width: Option, + #[props(into, default)] height: Option, + #[props(into, default)] radius: Option, +) -> Element { + let classes = merge_class("ui-skeleton", class); + + let mut styles = Vec::new(); + if let Some(width) = width.filter(|value| !value.trim().is_empty()) { + styles.push(format!("width: {width};")); + } + if let Some(height) = height.filter(|value| !value.trim().is_empty()) { + styles.push(format!("height: {height};")); + } + if let Some(radius) = radius.filter(|value| !value.trim().is_empty()) { + styles.push(format!("border-radius: {radius};")); + } + let style_attr = styles.join(" "); + + rsx! { + div { + class: classes, + style: style_attr, + } + } +} diff --git a/src/components/ui/table.rs b/src/components/ui/table.rs new file mode 100644 index 0000000..16ad23c --- /dev/null +++ b/src/components/ui/table.rs @@ -0,0 +1,106 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[component] +pub fn Table(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table", class); + + rsx! { + table { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableHeader(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-header", class); + + rsx! { + thead { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableBody(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-body", class); + + rsx! { + tbody { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableFooter(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-footer", class); + + rsx! { + tfoot { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableRow(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-row", class); + + rsx! { + tr { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableHead(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-head", class); + + rsx! { + th { + class: classes, + scope: "col", + {children} + } + } +} + +#[component] +pub fn TableCell(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-cell", class); + + rsx! { + td { + class: classes, + {children} + } + } +} + +#[component] +pub fn TableCaption(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-table-caption", class); + + rsx! { + caption { + class: classes, + {children} + } + } +} diff --git a/src/components/ui/toggle.rs b/src/components/ui/toggle.rs new file mode 100644 index 0000000..dc46ead --- /dev/null +++ b/src/components/ui/toggle.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[component] +pub fn Toggle( + #[props(default)] pressed: bool, + #[props(default)] disabled: bool, + #[props(into, default)] class: Option, + #[props(optional)] on_pressed_change: Option>, + children: Element, +) -> Element { + let classes = merge_class("ui-toggle", class); + let handler = on_pressed_change.clone(); + + rsx! { + button { + class: classes, + "data-state": if pressed { "on" } else { "off" }, + "aria-pressed": pressed, + disabled, + onclick: move |_| { + if let Some(callback) = handler.clone() { + callback.call(!pressed); + } + }, + {children} + } + } +} diff --git a/src/views/components.rs b/src/views/components.rs index 783e682..a7a1494 100644 --- a/src/views/components.rs +++ b/src/views/components.rs @@ -1,15 +1,19 @@ use crate::components::ui::{ Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar, - Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Card, CardContent, - CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, CommandItem, CommandPalette, - ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input, + Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Calendar, Card, + CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Combobox, + ComboboxOption, CommandItem, CommandPalette, ContextItem, ContextMenu, Crumb, Dialog, + DropdownMenu, DropdownMenuItem, FormField, FormMessage, FormMessageVariant, HoverCard, Input, Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination, Popover, - Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator, SeparatorOrientation, - Sheet, SheetSide, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout, SidebarMenu, SidebarMenuButton, - SidebarMenuItem, SidebarSeparator, SidebarTrigger, Slider, StepItem, Steps, Switch, Tabs, - TabsContent, TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Tooltip, + Progress, RadioGroup, RadioGroupItem, ScrollArea, Select, SelectOption, Separator, + SeparatorOrientation, Sheet, SheetSide, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, + SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout, + SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator, SidebarTrigger, Skeleton, + Slider, StepItem, Steps, Switch, Table, TableBody, TableCaption, TableCell, TableFooter, + TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, Toast, + ToastViewport, Toggle, Tooltip, }; +use chrono::NaiveDate; use dioxus::prelude::*; #[component] @@ -46,6 +50,13 @@ fn UiShowcase() -> Element { let toast_open = use_signal(|| false); let sidebar_collapsed = use_signal(|| false); let sidebar_active = use_signal(|| "analytics".to_string()); + let profile_name = use_signal(|| "".to_string()); + let name_error = + use_signal(|| Some("Use at least 3 characters to stay descriptive.".to_string())); + let combobox_selection = use_signal(|| Some("analytics".to_string())); + let toggle_active = use_signal(|| true); + let calendar_selection = + use_signal(|| Some(NaiveDate::from_ymd_opt(2024, 6, 11).expect("valid date"))); let slider_value_signal = slider_value.clone(); let slider_value_setter = slider_value.clone(); let contact_method_signal = contact_method.clone(); @@ -67,13 +78,63 @@ fn UiShowcase() -> Element { let toast_signal = toast_open.clone(); let sidebar_collapsed_setter = sidebar_collapsed.clone(); let sidebar_active_setter = sidebar_active.clone(); + let profile_name_signal = profile_name.clone(); + let profile_name_setter = profile_name.clone(); + let name_error_signal = name_error.clone(); + let name_error_setter = name_error.clone(); + let combobox_selection_signal = combobox_selection.clone(); + let combobox_selection_setter = combobox_selection.clone(); + let toggle_active_signal = toggle_active.clone(); + let toggle_active_setter = toggle_active.clone(); + let calendar_selection_signal = calendar_selection.clone(); + let calendar_selection_setter = calendar_selection.clone(); let intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal()); let contact_text = move || format!("Preferred contact: {}", contact_method_signal()); + let profile_preview = move || { + let value = profile_name_signal(); + if value.trim().is_empty() { + "Name is currently empty".to_string() + } else { + format!("Display name preview: {}", value.trim()) + } + }; + let combobox_summary = move || { + if let Some(value) = combobox_selection_signal() { + format!("Project owner: {value}") + } else { + "Assign a project owner to sync permissions.".to_string() + } + }; + let calendar_summary = move || { + if let Some(date) = calendar_selection_signal() { + format!("Next milestone: {}", date.format("%b %d, %Y")) + } else { + "Pick a date to keep the timeline on track.".to_string() + } + }; + let toggle_summary = move || { + if toggle_active_signal() { + "Emails are enabled for this workflow.".to_string() + } else { + "Emails are paused until you re-enable them.".to_string() + } + }; let select_options = vec![ SelectOption::new("System", "system"), SelectOption::new("Light", "light"), SelectOption::new("Dark", "dark"), ]; + let calendar_month = NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid date"); + let combobox_options = vec![ + ComboboxOption::new("Analytics", "analytics") + .with_description("Dashboards, funnels, and trend alerts"), + ComboboxOption::new("Growth", "growth") + .with_description("Lifecycle campaigns and experiments"), + ComboboxOption::new("Infrastructure", "infrastructure") + .with_description("Runtime, deploys, and observability"), + ComboboxOption::new("Support", "support") + .with_description("Queues, macros, and response goals"), + ]; let menu_items = vec![ DropdownMenuItem::new("Profile", "profile").with_shortcut("⌘P"), DropdownMenuItem::new("Billing", "billing").with_shortcut("⌘B"), @@ -102,6 +163,26 @@ fn UiShowcase() -> Element { Some("Dive into the latest Dioxus 0.7 docs"), ), ]; + let table_rows = vec![ + ("DW-9021", "Realtime dashboard", "Shipping", "2 minutes ago"), + ( + "DB-1740", + "AI campaign assistant", + "Review", + "14 minutes ago", + ), + ("MR-1183", "Metrics service", "Building", "38 minutes ago"), + ("PK-9422", "Payments ledger", "Queued", "58 minutes ago"), + ("XD-7710", "Access gateway", "Paused", "2 hours ago"), + ]; + let activity_items = vec![ + ("09:05", "Jesse", "merged \"navigation cleanups\" into main"), + ("10:18", "Mia", "scheduled the weekly metrics export"), + ("11:42", "Arjun", "paused the experiment \"Pricing v2\""), + ("12:03", "Ivy", "restarted the realtime analytics workers"), + ("12:44", "Kai", "commented on the onboarding funnel deck"), + ("13:27", "Lena", "acknowledged alert \"Queue depth\""), + ]; let menubar_menus = vec![ MenubarMenu::new( "File", @@ -281,6 +362,87 @@ fn UiShowcase() -> Element { } } + div { + style: single_column_style, + Card { + CardHeader { + CardTitle { "Form helpers" } + CardDescription { "FormField, Combobox, and Toggle wire up validation with shadcn styling." } + } + CardContent { + div { class: "ui-stack", + FormField { + id: Some("helper-name".to_string()), + label: Some("Project name".to_string()), + helper_text: Some(profile_preview()), + error: Some(name_error_signal), + Input { + id: "helper-name", + placeholder: "Launch analytics workspace", + value: profile_name_signal(), + on_input: { + let mut value_signal = profile_name_setter.clone(); + let mut error_signal = name_error_setter.clone(); + move |event: FormEvent| { + let value = event.value(); + let trimmed_len = value.trim().len(); + value_signal.set(value.clone()); + if trimmed_len >= 3 { + error_signal.set(None); + } else { + error_signal.set(Some("Use at least 3 characters to stay descriptive.".to_string())); + } + } + }, + } + } + FormField { + id: Some("helper-brief".to_string()), + label: Some("Summary".to_string()), + description: Some("Share quick context for the owners reviewing this request.".to_string()), + helper_text: Some("You can mention teammates with @ and use Markdown formatting.".to_string()), + Textarea { + id: "helper-brief", + placeholder: "Outline the goal, stakeholders, and success signal...", + rows: 4, + } + } + FormField { + id: Some("owner-combobox".to_string()), + label: Some("Assign owner".to_string()), + description: Some("Search across teams to hand off this initiative.".to_string()), + helper_text: Some(combobox_summary()), + Combobox { + id: Some("owner-combobox".to_string()), + placeholder: "Search by team...", + options: combobox_options.clone(), + selected: combobox_selection_signal(), + on_select: { + let mut setter = combobox_selection_setter.clone(); + move |value| setter.set(Some(value)) + }, + } + } + div { class: "ui-stack", + Toggle { + pressed: toggle_active_signal(), + on_pressed_change: { + let mut setter = toggle_active_setter.clone(); + move |state| setter.set(state) + }, + "Email alerts" + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{toggle_summary()}" + } + } + } + } + } + } + div { style: single_column_style, Card { @@ -372,6 +534,89 @@ fn UiShowcase() -> Element { } } + div { + style: full_width_style, + Card { + CardHeader { + CardTitle { "Data timelines" } + CardDescription { "Tables, scroll areas, calendars, and skeleton loaders keep admin dashboards responsive." } + } + CardContent { + div { class: "ui-stack", + SpanHelper { "Deploys" } + ScrollArea { + max_height: Some("220px".to_string()), + Table { + TableCaption { "Latest updates from the delivery pipeline." } + TableHeader { + TableRow { + TableHead { "ID" } + TableHead { "Project" } + TableHead { "Status" } + TableHead { "Updated" } + } + } + TableBody { + for (id, name, status, updated) in table_rows.iter().copied() { + TableRow { + TableCell { "{id}" } + TableCell { "{name}" } + TableCell { "{status}" } + TableCell { "{updated}" } + } + } + } + TableFooter { + TableRow { + TableCell { "Total" } + TableCell { "{table_rows.len()} pipelines" } + TableCell { class: Some("ui-field-helper".to_string()), "Automated checks" } + TableCell { class: Some("ui-field-helper".to_string()), "Past hour" } + } + } + } + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "Keep automation quick by streaming the hottest rows into view." + } + Separator { style: "margin: 1rem 0;" } + SpanHelper { "Activity feed" } + ScrollArea { + max_height: Some("140px".to_string()), + ul { + style: "display: flex; flex-direction: column; gap: 0.6rem; font-size: 0.85rem;", + for (time, author, action) in activity_items.iter().copied() { + li { + style: "display: flex; gap: 0.5rem; align-items: baseline;", + span { style: "font-weight: 600; font-variant-numeric: tabular-nums;", "{time}" } + span { style: "font-weight: 600;", "{author}" } + span { style: "color: hsl(var(--muted-foreground));", "{action}" } + } + } + } + } + Separator { style: "margin: 1rem 0;" } + SpanHelper { "{calendar_summary()}" } + Calendar { + initial_month: calendar_month, + selected: calendar_selection_signal(), + on_select: { + let mut setter = calendar_selection_setter.clone(); + move |day| setter.set(Some(day)) + }, + } + div { class: "ui-cluster", + Skeleton { width: Some("160px".to_string()), height: Some("1rem".to_string()) } + Skeleton { width: Some("120px".to_string()), height: Some("1rem".to_string()) } + Skeleton { width: Some("200px".to_string()), height: Some("1rem".to_string()) } + } + } + } + } + } + div { style: full_width_style, Card {