From ed7af4eeda348105ce8e858586ce29fff0f3f3ef Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 3 Nov 2025 13:15:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9B=B4=E5=A4=9A=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styling/shadcn.css | 362 +++++++++++++++++++++++++++ src/components/ui/breadcrumb.rs | 43 ++++ src/components/ui/command.rs | 110 ++++++++ src/components/ui/context_menu.rs | 82 ++++++ src/components/ui/dialog.rs | 60 +++++ src/components/ui/hover_card.rs | 22 ++ src/components/ui/menubar.rs | 116 +++++++++ src/components/ui/mod.rs | 24 ++ src/components/ui/navigation_menu.rs | 75 ++++++ src/components/ui/pagination.rs | 101 ++++++++ src/components/ui/popover.rs | 35 +++ src/components/ui/sheet.rs | 81 ++++++ src/components/ui/steps.rs | 55 ++++ src/components/ui/toast.rs | 42 ++++ src/views/home.rs | 284 ++++++++++++++++++++- 15 files changed, 1486 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/breadcrumb.rs create mode 100644 src/components/ui/command.rs create mode 100644 src/components/ui/context_menu.rs create mode 100644 src/components/ui/dialog.rs create mode 100644 src/components/ui/hover_card.rs create mode 100644 src/components/ui/menubar.rs create mode 100644 src/components/ui/navigation_menu.rs create mode 100644 src/components/ui/pagination.rs create mode 100644 src/components/ui/popover.rs create mode 100644 src/components/ui/sheet.rs create mode 100644 src/components/ui/steps.rs create mode 100644 src/components/ui/toast.rs diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 198fe3b..6003216 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -462,6 +462,245 @@ gap: 1rem; } +.ui-breadcrumb { + display: inline-flex; + align-items: center; + gap: 0.6rem; + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); +} + +.ui-breadcrumb a { + color: hsl(var(--foreground)); + text-decoration: none; +} + +.ui-breadcrumb a:hover { + color: hsl(var(--primary)); +} + +.ui-breadcrumb-separator { + opacity: 0.6; +} + +.ui-pagination { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.ui-page-button { + min-width: 2.25rem; + height: 2.25rem; + padding: 0 0.5rem; + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.ui-page-button:hover { + background-color: hsl(var(--muted)); +} + +.ui-page-button[data-active="true"] { + border-color: hsl(var(--primary)); + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.ui-page-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-steps { + display: flex; + align-items: center; + gap: 1rem; +} + +.ui-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: hsl(var(--muted-foreground)); +} + +.ui-step-indicator { + width: 2rem; + height: 2rem; + border-radius: 999px; + border: 2px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: center; + background-color: hsl(var(--background)); + font-weight: 600; +} + +.ui-step[data-state="active"] .ui-step-indicator { + border-color: hsl(var(--primary)); + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.ui-step[data-state="complete"] .ui-step-indicator { + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); +} + +.ui-navmenu, +.ui-menubar { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem; + border-radius: calc(var(--radius) - 2px); + background-color: hsl(var(--muted)); + position: relative; +} + +.ui-navmenu-trigger, +.ui-menubar-trigger { + appearance: none; + border: none; + background: transparent; + padding: 0.45rem 0.75rem; + border-radius: calc(var(--radius) - 4px); + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.ui-navmenu-trigger[data-open="true"], +.ui-menubar-trigger[data-open="true"] { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + box-shadow: var(--shadow-sm); +} + +.ui-navmenu-content, +.ui-menubar-content { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + min-width: 12rem; + background-color: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + box-shadow: var(--shadow-md); + padding: 0.5rem 0.4rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + z-index: 35; +} + +.ui-navmenu-item, +.ui-menubar-item { + padding: 0.4rem 0.6rem; + border-radius: calc(var(--radius) - 4px); + font-size: 0.85rem; + color: hsl(var(--foreground)); + cursor: pointer; +} + +.ui-navmenu-item:hover, +.ui-menubar-item:hover { + background-color: hsl(var(--muted)); +} + +.ui-command { + width: 100%; + max-width: 420px; + background-color: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 2px); + box-shadow: var(--shadow-md); + overflow: hidden; +} + +.ui-command-header { + padding: 0.6rem 0.9rem; + border-bottom: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + gap: 0.6rem; +} + +.ui-command-input { + flex: 1; + border: none; + background: transparent; + font-size: 0.9rem; + color: hsl(var(--foreground)); + outline: none; +} + +.ui-command-list { + max-height: 14rem; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.ui-command-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.55rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; + color: hsl(var(--foreground)); +} + +.ui-command-item:hover, +.ui-command-item[data-state="active"] { + background-color: hsl(var(--muted)); +} + +.ui-context-trigger { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ui-context-menu { + position: fixed; + min-width: 220px; + background-color: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + box-shadow: var(--shadow-md); + padding: 0.3rem 0.2rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + z-index: 40; +} + +.ui-context-item { + padding: 0.45rem 0.75rem; + border-radius: calc(var(--radius) - 4px); + font-size: 0.85rem; + color: hsl(var(--foreground)); + cursor: pointer; +} + +.ui-context-item[data-variant="destructive"] { + color: hsl(var(--destructive)); +} + +.ui-context-item:hover { + background-color: hsl(var(--muted)); +} + .ui-alert { border-radius: calc(var(--radius) + 2px); border: 1px solid hsl(var(--border)); @@ -684,6 +923,129 @@ letter-spacing: 0.03em; } +.ui-overlay-backdrop { + position: fixed; + inset: 0; + background-color: hsl(var(--foreground) / 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 48; +} + +.ui-dialog { + background-color: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 4px); + padding: 1.5rem; + width: min(480px, 92vw); + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + gap: 1rem; + color: hsl(var(--popover-foreground)); +} + +.ui-dialog-header { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.ui-dialog-title { + font-size: 1.1rem; + font-weight: 600; +} + +.ui-dialog-description { + font-size: 0.9rem; + color: hsl(var(--muted-foreground)); +} + +.ui-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.ui-popover, +.ui-hovercard { + position: absolute; + min-width: 220px; + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--popover)); + box-shadow: var(--shadow-md); + padding: 0.75rem; + z-index: 45; +} + +.ui-popover[data-placement="top"] { + transform: translate(-50%, -0.75rem); +} + +.ui-popover[data-placement="bottom"] { + transform: translate(-50%, 0.75rem); +} + +.ui-hovercard { + transform: translate(-50%, -0.75rem); +} + +.ui-sheet-backdrop { + position: fixed; + inset: 0; + background-color: hsl(var(--foreground) / 0.45); + z-index: 49; +} + +.ui-sheet { + position: fixed; + top: 0; + bottom: 0; + width: min(360px, 85vw); + background-color: hsl(var(--popover)); + border-left: 1px solid hsl(var(--border)); + box-shadow: -16px 0 40px -28px rgb(15 23 42 / 0.65); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + z-index: 50; +} + +.ui-sheet[data-side="left"] { + left: 0; + border-left: none; + border-right: 1px solid hsl(var(--border)); + box-shadow: 16px 0 40px -28px rgb(15 23 42 / 0.65); +} + +.ui-sheet[data-side="right"] { + right: 0; +} + +.ui-toast-container { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + z-index: 60; +} + +.ui-toast { + background-color: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 2px); + padding: 0.85rem 1.1rem; + box-shadow: var(--shadow-md); + min-width: 240px; + color: hsl(var(--popover-foreground)); +} + .ui-separator { background-color: hsl(var(--border)); display: block; diff --git a/src/components/ui/breadcrumb.rs b/src/components/ui/breadcrumb.rs new file mode 100644 index 0000000..a283f01 --- /dev/null +++ b/src/components/ui/breadcrumb.rs @@ -0,0 +1,43 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct Crumb { + pub label: String, + pub href: Option, +} + +impl Crumb { + pub fn new(label: impl Into, href: Option>) -> Self { + Self { + label: label.into(), + href: href.map(|value| value.into()), + } + } +} + +#[component] +pub fn Breadcrumb( + #[props(into)] items: Vec, + #[props(into, default = "/".to_string())] separator: String, +) -> Element { + rsx! { + nav { + role: "navigation", + aria_label: "Breadcrumb", + span { + class: "ui-breadcrumb", + for (index, item) in items.iter().enumerate() { + if let Some(href) = &item.href { + a { href: href.clone(), "{item.label}" } + } else { + span { "{item.label}" } + } + + if index < items.len().saturating_sub(1) { + span { class: "ui-breadcrumb-separator", "{separator}" } + } + } + } + } + } +} diff --git a/src/components/ui/command.rs b/src/components/ui/command.rs new file mode 100644 index 0000000..fdd575c --- /dev/null +++ b/src/components/ui/command.rs @@ -0,0 +1,110 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct CommandItem { + pub label: String, + pub value: String, + pub shortcut: Option, + pub group: Option, +} + +impl CommandItem { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + shortcut: None, + group: None, + } + } + + pub fn shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.into()); + self + } + + pub fn group(mut self, group: impl Into) -> Self { + self.group = Some(group.into()); + self + } +} + +#[component] +pub fn CommandPalette( + #[props(into)] items: Vec, + #[props(optional)] on_select: Option>, + #[props(into, default = "Search commands".to_string())] placeholder: String, +) -> Element { + let mut query = use_signal(|| String::new()); + + let mut filtered = items.clone(); + let q = query().to_lowercase(); + if !q.is_empty() { + filtered.retain(|item| { + item.label.to_lowercase().contains(&q) + || item + .group + .as_ref() + .map(|g| g.to_lowercase().contains(&q)) + .unwrap_or(false) + }); + } + + let handler = on_select.clone(); + + let command_nodes: Vec<_> = filtered + .iter() + .map(|item| { + let value = item.value.clone(); + let shortcut = item.shortcut.clone(); + let group = item.group.clone(); + let handler_clone = handler.clone(); + + rsx! { + div { + class: "ui-command-item", + "data-state": "inactive", + onclick: move |_| { + if let Some(cb) = handler_clone.clone() { + cb.call(value.clone()); + } + }, + div { + style: "display: flex; flex-direction: column; gap: 0.2rem;", + span { "{item.label}" } + if let Some(group) = group { + span { style: "font-size: 0.7rem; color: hsl(var(--muted-foreground));", "{group}" } + } + } + if let Some(shortcut) = shortcut { + span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" } + } + } + } + }) + .collect(); + + rsx! { + div { + class: "ui-command", + div { + class: "ui-command-header", + span { style: "font-size: 0.85rem; opacity: 0.6;", "⌘K" } + input { + class: "ui-command-input", + value: query(), + placeholder: placeholder.clone(), + oninput: move |event| query.set(event.value()), + } + } + div { + class: "ui-command-list", + if command_nodes.is_empty() { + span { style: "padding: 0.6rem 0.9rem; color: hsl(var(--muted-foreground));", "No results" } + } else { + {command_nodes.into_iter()} + } + } + } + } +} diff --git a/src/components/ui/context_menu.rs b/src/components/ui/context_menu.rs new file mode 100644 index 0000000..56e49c6 --- /dev/null +++ b/src/components/ui/context_menu.rs @@ -0,0 +1,82 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct ContextItem { + pub label: String, + pub value: String, + pub destructive: bool, +} + +impl ContextItem { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + destructive: false, + } + } + + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } +} + +#[component] +pub fn ContextMenu( + #[props(into)] items: Vec, + #[props(optional)] on_select: Option>, + children: Element, +) -> Element { + let mut position = use_signal(|| None::<(f32, f32)>); + let handler = on_select.clone(); + + let menu_portal = position().map(|(x, y)| { + let nodes: Vec<_> = items + .iter() + .map(|item| { + let value = item.value.clone(); + let mut pos_signal = position.clone(); + let handler_clone = handler.clone(); + + rsx! { + button { + class: "ui-context-item", + "data-variant": if item.destructive { "destructive" } else { "default" }, + onclick: move |_| { + if let Some(cb) = handler_clone.clone() { + cb.call(value.clone()); + } + pos_signal.set(None); + }, + "{item.label}" + } + } + }) + .collect(); + (x, y, nodes) + }); + + rsx! { + span { + class: "ui-context-trigger", + oncontextmenu: move |event| { + event.prevent_default(); + let coords = event.client_coordinates(); + position.set(Some((coords.x as f32, coords.y as f32))); + }, + {children} + } + if let Some((x, y, nodes)) = menu_portal { + div { + style: "position: fixed; inset: 0; z-index: 39;", + onclick: move |_| position.set(None), + } + div { + class: "ui-context-menu", + style: format!("top: {y}px; left: {x}px;"), + {nodes.into_iter()} + } + } + } +} diff --git a/src/components/ui/dialog.rs b/src/components/ui/dialog.rs new file mode 100644 index 0000000..55d5f47 --- /dev/null +++ b/src/components/ui/dialog.rs @@ -0,0 +1,60 @@ +use crate::components::ui::{Button, ButtonSize, ButtonVariant}; +use dioxus::prelude::*; + +#[component] +pub fn Dialog( + mut open: Signal, + #[props(into, default)] title: Option, + #[props(into, default)] description: Option, + #[props(optional)] on_close: Option>, + children: Element, +) -> Element { + if !open() { + return rsx! { Fragment {} }; + } + + let mut overlay_signal = open.clone(); + let overlay_handler = on_close.clone(); + let mut button_signal = open.clone(); + let button_handler = on_close.clone(); + + rsx! { + div { + class: "ui-overlay-backdrop", + onclick: move |_| { + overlay_signal.set(false); + if let Some(cb) = overlay_handler.clone() { + cb.call(()); + } + }, + div { + class: "ui-dialog", + onclick: move |event| event.stop_propagation(), + if let Some(title) = title.clone() { + div { + class: "ui-dialog-header", + h3 { class: "ui-dialog-title", "{title}" } + if let Some(desc) = description.clone() { + p { class: "ui-dialog-description", "{desc}" } + } + } + } + {children} + div { + class: "ui-dialog-footer", + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + on_click: move |_| { + button_signal.set(false); + if let Some(cb) = button_handler.clone() { + cb.call(()); + } + }, + "Close" + } + } + } + } + } +} diff --git a/src/components/ui/hover_card.rs b/src/components/ui/hover_card.rs new file mode 100644 index 0000000..b7ffac6 --- /dev/null +++ b/src/components/ui/hover_card.rs @@ -0,0 +1,22 @@ +use dioxus::prelude::*; + +#[component] +pub fn HoverCard(trigger: Element, content: Element) -> Element { + let mut open = use_signal(|| false); + + rsx! { + span { + style: "position: relative; display: inline-flex;", + onmouseenter: move |_| open.set(true), + onmouseleave: move |_| open.set(false), + {trigger} + if open() { + div { + class: "ui-hovercard", + style: "left: 50%; bottom: 100%; transform: translate(-50%, -0.75rem);", + {content} + } + } + } + } +} diff --git a/src/components/ui/menubar.rs b/src/components/ui/menubar.rs new file mode 100644 index 0000000..4323d3f --- /dev/null +++ b/src/components/ui/menubar.rs @@ -0,0 +1,116 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct MenubarItem { + pub label: String, + pub value: String, + pub shortcut: Option, + pub destructive: bool, +} + +impl MenubarItem { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + shortcut: None, + destructive: false, + } + } + + pub fn shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.into()); + self + } + + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } +} + +#[derive(Clone, PartialEq)] +pub struct MenubarMenu { + pub label: String, + pub items: Vec, +} + +impl MenubarMenu { + pub fn new(label: impl Into, items: Vec) -> Self { + Self { + label: label.into(), + items, + } + } +} + +#[component] +pub fn Menubar( + #[props(into)] menus: Vec, + #[props(optional)] on_select: Option>, +) -> Element { + let mut open = use_signal(|| None::); + let handler = on_select.clone(); + + let menu_nodes: Vec<_> = menus + .iter() + .enumerate() + .map(|(index, menu)| { + let mut open_signal_hover = open.clone(); + let mut open_signal_click = open.clone(); + let is_open = open() == Some(index); + let item_nodes: Vec<_> = menu + .items + .iter() + .map(|item| { + let value = item.value.clone(); + let shortcut = item.shortcut.clone(); + let destructive = item.destructive; + let handler_clone = handler.clone(); + let mut open_close = open.clone(); + + rsx! { + button { + class: "ui-menubar-item", + "data-variant": if destructive { "destructive" } else { "default" }, + onclick: move |_| { + if let Some(cb) = handler_clone.clone() { + cb.call(value.clone()); + } + open_close.set(None); + }, + span { "{item.label}" } + if let Some(shortcut) = shortcut.clone() { + span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" } + } + } + } + }) + .collect(); + + rsx! { + span { + style: "position: relative;", + button { + class: "ui-menubar-trigger", + "data-open": if is_open { "true" } else { "false" }, + onmouseenter: move |_| open_signal_hover.set(Some(index)), + onclick: move |_| open_signal_click.set(Some(index)), + "{menu.label}" + } + if is_open { + div { class: "ui-menubar-content", {item_nodes.into_iter()} } + } + } + } + }) + .collect(); + + rsx! { + div { + class: "ui-menubar", + onmouseleave: move |_| open.set(None), + {menu_nodes.into_iter()} + } + } +} diff --git a/src/components/ui/mod.rs b/src/components/ui/mod.rs index cf791a3..f4d13bb 100644 --- a/src/components/ui/mod.rs +++ b/src/components/ui/mod.rs @@ -6,38 +6,62 @@ mod accordion; mod alert; mod avatar; mod badge; +mod breadcrumb; mod button; mod card; mod checkbox; +mod command; +mod context_menu; +mod dialog; mod dropdown_menu; +mod hover_card; mod input; mod label; +mod menubar; +mod navigation_menu; +mod pagination; +mod popover; mod progress; mod radio_group; mod select; mod separator; +mod sheet; mod slider; +mod steps; mod switch; mod tabs; mod textarea; +mod toast; mod tooltip; pub use accordion::*; pub use alert::*; pub use avatar::*; pub use badge::*; +pub use breadcrumb::*; pub use button::*; pub use card::*; pub use checkbox::*; +pub use command::*; +pub use context_menu::*; +pub use dialog::*; pub use dropdown_menu::*; +pub use hover_card::*; pub use input::*; pub use label::*; +pub use menubar::*; +pub use navigation_menu::*; +pub use pagination::*; +pub use popover::*; pub use progress::*; pub use radio_group::*; pub use select::*; pub use separator::*; +pub use sheet::*; pub use slider::*; +pub use steps::*; pub use switch::*; pub use tabs::*; pub use textarea::*; +pub use toast::*; pub use tooltip::*; diff --git a/src/components/ui/navigation_menu.rs b/src/components/ui/navigation_menu.rs new file mode 100644 index 0000000..316185c --- /dev/null +++ b/src/components/ui/navigation_menu.rs @@ -0,0 +1,75 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct NavigationItem { + pub label: String, + pub description: Option, + pub href: String, +} + +impl NavigationItem { + pub fn new( + label: impl Into, + href: impl Into, + description: Option>, + ) -> Self { + Self { + label: label.into(), + href: href.into(), + description: description.map(|d| d.into()), + } + } +} + +#[component] +pub fn NavigationMenu(#[props(into)] items: Vec) -> Element { + let active = use_signal(|| 0usize); + + let trigger_nodes: Vec<_> = items + .iter() + .enumerate() + .map(|(index, item)| { + let mut active_signal = active.clone(); + let is_active = active() == index; + + rsx! { + button { + class: "ui-navmenu-trigger", + "data-open": if is_active { "true" } else { "false" }, + onmouseenter: move |_| active_signal.set(index), + onclick: move |_| active_signal.set(index), + "{item.label}" + } + } + }) + .collect(); + + let selected_content = items + .get(active().min(items.len().saturating_sub(1))) + .map(|item| { + let description = item.description.clone(); + rsx! { + div { + class: "ui-navmenu-content", + a { + href: item.href.clone(), + class: "ui-navmenu-item", + "{item.label}" + } + if let Some(desc) = description { + span { style: "font-size: 0.75rem; color: hsl(var(--muted-foreground));", "{desc}" } + } + } + } + }); + + rsx! { + div { + class: "ui-navmenu", + {trigger_nodes.into_iter()} + if let Some(content) = selected_content { + {content} + } + } + } +} diff --git a/src/components/ui/pagination.rs b/src/components/ui/pagination.rs new file mode 100644 index 0000000..f191cd6 --- /dev/null +++ b/src/components/ui/pagination.rs @@ -0,0 +1,101 @@ +use dioxus::prelude::*; + +#[component] +pub fn Pagination( + total_pages: usize, + current_page: usize, + #[props(optional)] on_page_change: Option>, +) -> Element { + let mut current = use_signal(move || current_page.max(1).min(total_pages.max(1))); + use_effect(move || { + if current() != current_page { + current.set(current_page.max(1).min(total_pages.max(1))); + } + }); + + let on_change = on_page_change.clone(); + + let mut buttons = vec![]; + if total_pages <= 7 { + buttons.extend(1..=total_pages); + } else { + let active = current(); + buttons.extend([1, 2]); + if active > 4 { + buttons.push(0); // ellipsis indicator + } + let start = active.saturating_sub(1).max(3); + let end = (active + 1).min(total_pages - 2); + for page in start..=end { + buttons.push(page); + } + if active + 2 < total_pages - 1 { + buttons.push(0); + } + buttons.extend([total_pages - 1, total_pages]); + } + + let page_nodes: Vec<_> = buttons + .iter() + .map(|page| { + if *page == 0 { + rsx! { span { class: "ui-page-button", style: "pointer-events: none;", "…" } } + } else { + let mut page_signal = current.clone(); + let page_handler = on_change.clone(); + let target = *page; + rsx! { + button { + class: "ui-page-button", + "data-active": if page_signal() == target { "true" } else { "false" }, + onclick: move |_| { + let new_page = target.max(1).min(total_pages.max(1)); + page_signal.set(new_page); + if let Some(cb) = page_handler.clone() { + cb.call(new_page); + } + }, + "{target}" + } + } + } + }) + .collect(); + + let mut prev_signal = current.clone(); + let prev_handler = on_change.clone(); + let mut next_signal = current.clone(); + let next_handler = on_change.clone(); + + rsx! { + nav { + class: "ui-pagination", + aria_label: "Pagination", + button { + class: "ui-page-button", + disabled: prev_signal() <= 1, + onclick: move |_| { + let new_page = prev_signal().saturating_sub(1).max(1); + prev_signal.set(new_page); + if let Some(cb) = prev_handler.clone() { + cb.call(new_page); + } + }, + "Prev" + } + {page_nodes.into_iter()} + button { + class: "ui-page-button", + disabled: next_signal() >= total_pages.max(1), + onclick: move |_| { + let new_page = (next_signal() + 1).min(total_pages.max(1)); + next_signal.set(new_page); + if let Some(cb) = next_handler.clone() { + cb.call(new_page); + } + }, + "Next" + } + } + } +} diff --git a/src/components/ui/popover.rs b/src/components/ui/popover.rs new file mode 100644 index 0000000..9788963 --- /dev/null +++ b/src/components/ui/popover.rs @@ -0,0 +1,35 @@ +use dioxus::prelude::*; + +#[component] +pub fn Popover( + trigger: Element, + content: Element, + #[props(into, default = "bottom".to_string())] placement: String, +) -> Element { + let mut open = use_signal(|| false); + + rsx! { + span { + style: "position: relative; display: inline-flex;", + onclick: move |_| open.set(!open()), + tabindex: 0, + onfocusout: move |_| open.set(false), + {trigger} + if open() { + div { + class: "ui-popover", + "data-placement": placement.clone(), + style: match placement.as_str() { + "top" => "left: 50%; bottom: 100%;", + "bottom" => "left: 50%; top: 100%;", + "left" => "right: 100%; top: 50%; transform: translate(-0.75rem, -50%);", + "right" => "left: 100%; top: 50%; transform: translate(0.75rem, -50%);", + _ => "left: 50%; top: 100%;" + }, + onclick: move |event| event.stop_propagation(), + {content} + } + } + } + } +} diff --git a/src/components/ui/sheet.rs b/src/components/ui/sheet.rs new file mode 100644 index 0000000..edafcd8 --- /dev/null +++ b/src/components/ui/sheet.rs @@ -0,0 +1,81 @@ +use crate::components::ui::{Button, ButtonSize, ButtonVariant}; +use dioxus::prelude::*; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SheetSide { + Left, + Right, +} + +impl SheetSide { + fn as_str(&self) -> &'static str { + match self { + SheetSide::Left => "left", + SheetSide::Right => "right", + } + } +} + +impl Default for SheetSide { + fn default() -> Self { + SheetSide::Right + } +} + +#[component] +pub fn Sheet( + mut open: Signal, + #[props(default)] side: SheetSide, + #[props(into, default)] title: Option, + #[props(into, default)] description: Option, + #[props(optional)] on_close: Option>, + children: Element, +) -> Element { + if !open() { + return rsx! { Fragment {} }; + } + + let mut overlay_signal = open.clone(); + let overlay_handler = on_close.clone(); + let mut button_signal = open.clone(); + let button_handler = on_close.clone(); + + rsx! { + div { + class: "ui-sheet-backdrop", + onclick: move |_| { + overlay_signal.set(false); + if let Some(cb) = overlay_handler.clone() { + cb.call(()); + } + }, + } + div { + class: "ui-sheet", + "data-side": side.as_str(), + onclick: move |event| event.stop_propagation(), + if let Some(title) = title.clone() { + h3 { class: "ui-dialog-title", "{title}" } + } + if let Some(desc) = description.clone() { + p { class: "ui-dialog-description", "{desc}" } + } + {children} + div { + class: "ui-dialog-footer", + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + on_click: move |_| { + button_signal.set(false); + if let Some(cb) = button_handler.clone() { + cb.call(()); + } + }, + "Close" + } + } + } + } +} diff --git a/src/components/ui/steps.rs b/src/components/ui/steps.rs new file mode 100644 index 0000000..12296ee --- /dev/null +++ b/src/components/ui/steps.rs @@ -0,0 +1,55 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct StepItem { + pub title: String, + pub description: Option, +} + +impl StepItem { + pub fn new(title: impl Into, description: Option>) -> Self { + Self { + title: title.into(), + description: description.map(|d| d.into()), + } + } +} + +#[component] +pub fn Steps(#[props(into)] steps: Vec, #[props(default = 1)] current: usize) -> Element { + let rendered_steps: Vec<_> = steps + .iter() + .enumerate() + .map(|(index, step)| { + let position = index + 1; + let state = if position < current { + "complete" + } else if position == current { + "active" + } else { + "upcoming" + }; + let indicator_text = if position < current { + "✓".to_string() + } else { + position.to_string() + }; + + rsx! { + div { + class: "ui-step", + "data-state": state, + span { class: "ui-step-indicator", "{indicator_text}" } + span { "{step.title}" } + if let Some(description) = &step.description { + span { style: "font-size: 0.72rem; color: hsl(var(--muted-foreground));", "{description}" } + } + } + } + }) + .collect(); + + let step_nodes = rendered_steps; + + rsx! { div { class: "ui-steps", {step_nodes.into_iter()} } } +} diff --git a/src/components/ui/toast.rs b/src/components/ui/toast.rs new file mode 100644 index 0000000..1fe05ea --- /dev/null +++ b/src/components/ui/toast.rs @@ -0,0 +1,42 @@ +use dioxus::prelude::*; + +#[component] +pub fn ToastViewport(children: Element) -> Element { + rsx! { div { class: "ui-toast-container", {children} } } +} + +#[component] +pub fn Toast( + #[props(default)] open: bool, + #[props(into, default)] title: Option, + #[props(into, default)] description: Option, + #[props(optional)] on_close: Option>, +) -> Element { + if !open { + return rsx! { Fragment {} }; + } + + rsx! { + div { + class: "ui-toast", + if let Some(title) = title { + h4 { style: "font-weight: 600; font-size: 0.95rem;", "{title}" } + } + if let Some(desc) = description { + p { style: "font-size: 0.8rem; color: hsl(var(--muted-foreground));", "{desc}" } + } + if on_close.is_some() { + button { + class: "ui-page-button", + style: "align-self: flex-end; height: 2rem;", + onclick: move |_| { + if let Some(cb) = on_close.clone() { + cb.call(()); + } + }, + "Dismiss" + } + } + } + } +} diff --git a/src/views/home.rs b/src/views/home.rs index 3004af2..8dc2c66 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -1,11 +1,13 @@ use crate::components::{ ui::{ Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar, - Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, Checkbox, DropdownMenu, DropdownMenuItem, Input, Label, - Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator, - SeparatorOrientation, Slider, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, - Tooltip, + Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Card, CardContent, + CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, CommandItem, CommandPalette, + ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input, + Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination, + Popover, Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator, + SeparatorOrientation, Sheet, SheetSide, Slider, StepItem, Steps, Switch, Tabs, TabsContent, + TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Tooltip, }, Echo, Hero, }; @@ -31,6 +33,14 @@ fn UiShowcase() -> Element { let dark_mode = use_signal(|| false); let theme_choice = use_signal(|| Some("system".to_string())); let menu_selection = use_signal(|| "Select a menu action".to_string()); + let menubar_selection = use_signal(|| "Choose a menu item".to_string()); + let pagination_current = use_signal(|| 3usize); + let steps_current = use_signal(|| 2usize); + let command_selection = use_signal(|| "Nothing selected yet".to_string()); + let context_selection = use_signal(|| "Right click the area to choose an action".to_string()); + let dialog_open = use_signal(|| false); + let sheet_open = use_signal(|| false); + let toast_open = use_signal(|| false); let slider_value_signal = slider_value.clone(); let slider_value_setter = slider_value.clone(); let contact_method_signal = contact_method.clone(); @@ -42,6 +52,14 @@ fn UiShowcase() -> Element { let dark_mode_setter = dark_mode.clone(); let theme_choice_setter = theme_choice.clone(); let menu_selection_setter = menu_selection.clone(); + let menubar_selection_setter = menubar_selection.clone(); + let pagination_setter = pagination_current.clone(); + let steps_setter = steps_current.clone(); + let command_selection_setter = command_selection.clone(); + let context_selection_setter = context_selection.clone(); + let dialog_signal = dialog_open.clone(); + let sheet_signal = sheet_open.clone(); + let toast_signal = toast_open.clone(); let intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal()); let contact_text = move || format!("Preferred contact: {}", contact_method_signal()); let select_options = vec![ @@ -55,6 +73,70 @@ fn UiShowcase() -> Element { DropdownMenuItem::new("Team", "team"), DropdownMenuItem::new("Sign out", "logout").destructive(), ]; + let breadcrumb_items = vec![ + Crumb::new("Dashboard", Some("#")), + Crumb::new("Settings", Some("#settings")), + Crumb::new("Team", None::), + ]; + let navigation_items = vec![ + NavigationItem::new( + "Overview", + "#overview", + Some("Project snapshots and quick metrics"), + ), + NavigationItem::new( + "Playground", + "#playground", + Some("Prototype new ideas and components"), + ), + NavigationItem::new( + "Documentation", + "https://dioxuslabs.com/learn", + Some("Dive into the latest Dioxus 0.7 docs"), + ), + ]; + let menubar_menus = vec![ + MenubarMenu::new( + "File", + vec![ + MenubarItem::new("New Tab", "new_tab").shortcut("⌘T"), + MenubarItem::new("Open Workspace", "open_workspace"), + MenubarItem::new("Save", "save").shortcut("⌘S"), + ], + ), + MenubarMenu::new( + "Edit", + vec![ + MenubarItem::new("Undo", "undo").shortcut("⌘Z"), + MenubarItem::new("Redo", "redo").shortcut("⇧⌘Z"), + MenubarItem::new("Delete", "delete").destructive(), + ], + ), + ]; + let command_items = vec![ + CommandItem::new("Create project", "create_project") + .shortcut("⌘N") + .group("Actions"), + CommandItem::new("Invite teammate", "invite").group("Actions"), + CommandItem::new("Open documentation", "docs").group("Resources"), + CommandItem::new("Keyboard shortcuts", "shortcuts").group("Resources"), + ]; + let context_items = vec![ + ContextItem::new("Rename", "rename"), + ContextItem::new("Duplicate", "duplicate"), + ContextItem::new("Archive", "archive"), + ContextItem::new("Delete", "delete").destructive(), + ]; + let steps_items = vec![ + StepItem::new("Plan", Some("Outline requirements")), + StepItem::new("Build", Some("Implement features")), + StepItem::new("Review", Some("QA and ship")), + ]; + let total_pages = 8usize; + let pagination_summary = + move || format!("Showing page {} of {total_pages}", pagination_current()); + let steps_total = steps_items.len(); + let steps_summary = move || format!("Stage {} of {steps_total}", steps_current()); let theme_display = { let current = theme_choice(); current @@ -202,7 +284,7 @@ fn UiShowcase() -> Element { Card { CardHeader { - CardTitle { "Select & menus" } + CardTitle { "Select & dropdowns" } CardDescription { "Select, dropdown menu, tooltip and dynamic feedback." } } CardContent { @@ -245,6 +327,75 @@ fn UiShowcase() -> Element { } } + Card { + CardHeader { + CardTitle { "Navigation patterns" } + CardDescription { "Breadcrumbs, menus, pagination, and progress steps." } + } + CardContent { + div { class: "ui-stack", + SpanHelper { "Breadcrumb" } + Breadcrumb { items: breadcrumb_items.clone(), separator: ">".to_string() } + } + div { class: "ui-stack", + SpanHelper { "Navigation menu" } + NavigationMenu { items: navigation_items.clone() } + } + div { class: "ui-stack", + SpanHelper { "Menubar" } + Menubar { + menus: menubar_menus.clone(), + on_select: move |value| { + let mut signal = menubar_selection_setter.clone(); + signal.set(format!("Menubar selected: {value}")); + }, + } + SpanHelper { "{menubar_selection()}" } + } + div { class: "ui-stack", + SpanHelper { "Pagination" } + Pagination { + total_pages: total_pages, + current_page: pagination_current(), + on_page_change: move |page| { + let mut signal = pagination_setter.clone(); + signal.set(page); + }, + } + SpanHelper { "{pagination_summary()}" } + } + div { class: "ui-stack", + SpanHelper { "Steps" } + Steps { + steps: steps_items.clone(), + current: steps_current(), + } + div { class: "ui-cluster", + Button { + variant: ButtonVariant::Outline, + size: ButtonSize::Sm, + on_click: move |_| { + let mut signal = steps_setter.clone(); + let prev = signal().saturating_sub(1).max(1); + signal.set(prev); + }, + "Previous" + } + Button { + size: ButtonSize::Sm, + on_click: move |_| { + let mut signal = steps_setter.clone(); + let next = (signal() + 1).min(steps_total); + signal.set(next); + }, + "Next" + } + } + SpanHelper { "{steps_summary()}" } + } + } + } + Card { CardHeader { CardTitle { "Selection controls" } @@ -292,6 +443,41 @@ fn UiShowcase() -> Element { } } + Card { + CardHeader { + CardTitle { "Command & context" } + CardDescription { "Command palette filtering and contextual menus." } + } + CardContent { + div { class: "ui-stack", + SpanHelper { "Command palette" } + CommandPalette { + items: command_items.clone(), + on_select: move |value| { + let mut signal = command_selection_setter.clone(); + signal.set(format!("Command selected: {value}")); + }, + } + SpanHelper { "{command_selection()}" } + } + div { class: "ui-stack", + SpanHelper { "Context menu" } + ContextMenu { + items: context_items.clone(), + on_select: move |value| { + let mut signal = context_selection_setter.clone(); + signal.set(format!("Context action: {value}")); + }, + div { + style: "padding: 1.5rem; border: 1px dashed hsl(var(--border)); border-radius: var(--radius); text-align: center;", + "Right click anywhere in this box" + } + } + SpanHelper { "{context_selection()}" } + } + } + } + Card { CardHeader { CardTitle { "Tabs & panels" } @@ -331,6 +517,56 @@ fn UiShowcase() -> Element { } } + Card { + CardHeader { + CardTitle { "Dialogs & overlays" } + CardDescription { "Popover, hover card, dialogs, sheet, and toast examples." } + } + CardContent { + div { class: "ui-cluster", + Button { + variant: ButtonVariant::Secondary, + on_click: move |_| { + let mut signal = dialog_signal.clone(); + signal.set(true); + }, + "Open dialog" + } + Button { + variant: ButtonVariant::Outline, + on_click: move |_| { + let mut signal = sheet_signal.clone(); + signal.set(true); + }, + "Open sheet" + } + Button { + variant: ButtonVariant::Ghost, + on_click: move |_| { + let mut signal = toast_signal.clone(); + signal.set(true); + }, + "Notify me" + } + } + div { class: "ui-stack", + SpanHelper { "Popover" } + Popover { + placement: "bottom".to_string(), + trigger: rsx! { Button { variant: ButtonVariant::Outline, size: ButtonSize::Sm, "Toggle popover" } }, + content: rsx! { SpanHelper { "Choose the dialog or sheet you want to configure." } }, + } + } + div { class: "ui-stack", + SpanHelper { "Hover card" } + HoverCard { + trigger: rsx! { Badge { variant: BadgeVariant::Secondary, "Hover me" } }, + content: rsx! { span { style: "font-size: 0.8rem; color: hsl(var(--muted-foreground));", "Preview contextual information instantly." } }, + } + } + } + } + Card { CardHeader { CardTitle { "Alerts & extras" } @@ -384,6 +620,42 @@ fn UiShowcase() -> Element { } } } + Dialog { + open: dialog_signal.clone(), + title: Some("Create project".to_string()), + description: Some("Configure the new analytics workspace.".to_string()), + div { class: "ui-stack", + Label { html_for: "dialog-name", "Project name" } + Input { id: "dialog-name", placeholder: "Analytics redesign" } + } + } + Sheet { + open: sheet_signal.clone(), + side: SheetSide::Right, + title: Some("Activity log".to_string()), + description: Some("Review the latest changes from your teammates.".to_string()), + div { + class: "ui-stack", + SpanHelper { "Today" } + ul { + style: "display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.85rem;", + li { "Maria added new metrics to the dashboard." } + li { "Evan approved the Q2 launch plan." } + li { "Ada commented on revenue projections." } + } + } + } + ToastViewport { + Toast { + open: toast_open(), + title: Some("Changes saved".to_string()), + description: Some("We synced your workspace preferences.".to_string()), + on_close: move |_| { + let mut signal = toast_signal.clone(); + signal.set(false); + }, + } + } } } }