diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 6530a86..acfa02e 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -1183,3 +1183,238 @@ color: hsl(var(--muted-foreground)); font-size: 0.75rem; } + +.ui-sidebar-layout { + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 0.5rem); + box-shadow: var(--shadow-md); + display: grid; + grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr); + min-height: 20rem; + overflow: hidden; +} + +.ui-sidebar { + background-color: hsl(var(--card)); + border-right: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + transition: width 0.25s ease; + width: 18rem; +} + +.ui-sidebar[data-collapsed="true"] { + padding-left: 0.75rem; + padding-right: 0.75rem; + width: 4.5rem; +} + +.ui-sidebar-header, +.ui-sidebar-footer { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ui-sidebar-footer { + border-top: 1px solid hsl(var(--border)); + margin-top: auto; + padding-top: 1rem; +} + +.ui-sidebar-content { + display: flex; + flex-direction: column; + gap: 1.25rem; + overflow-y: auto; +} + +.ui-sidebar-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ui-sidebar-group-label { + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ui-sidebar-group-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ui-sidebar-separator { + background-color: hsl(var(--border)); + border: none; + height: 1px; + width: 100%; +} + +.ui-sidebar-menu { + width: 100%; +} + +.ui-sidebar-menu-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + list-style: none; + margin: 0; + padding: 0; +} + +.ui-sidebar-menu-item { + display: contents; +} + +.ui-sidebar-menu-button { + align-items: center; + background-color: transparent; + border: none; + border-radius: calc(var(--radius) - 4px); + color: inherit; + cursor: pointer; + display: flex; + gap: 0.75rem; + justify-content: flex-start; + padding: 0.5rem 0.75rem; + text-align: left; + text-decoration: none; + transition: background-color 0.2s ease, color 0.2s ease; + width: 100%; +} + +.ui-sidebar-menu-button:hover { + background-color: hsl(var(--muted)); +} + +.ui-sidebar-menu-button[data-active="true"] { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + font-weight: 600; +} + +.ui-sidebar-button-body { + align-items: center; + display: flex; + gap: 0.75rem; + width: 100%; +} + +.ui-sidebar-icon { + align-items: center; + background-color: hsl(var(--accent)); + border-radius: calc(var(--radius) - 6px); + color: hsl(var(--accent-foreground)); + display: inline-flex; + font-size: 1.05rem; + height: 2.1rem; + justify-content: center; + min-width: 2.1rem; + width: 2.1rem; +} + +.ui-sidebar-text { + display: flex; + flex-direction: column; + gap: 0.2rem; + line-height: 1.2; +} + +.ui-sidebar-label { + font-weight: 500; +} + +.ui-sidebar-description { + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; +} + +.ui-sidebar-badge { + background-color: hsl(var(--secondary)); + border-radius: 999px; + color: hsl(var(--secondary-foreground)); + font-size: 0.75rem; + font-weight: 500; + padding: 0.15rem 0.55rem; + white-space: nowrap; +} + +.ui-sidebar-trigger { + align-items: center; + background-color: transparent; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 4px); + color: hsl(var(--muted-foreground)); + cursor: pointer; + display: inline-flex; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.ui-sidebar-trigger:hover { + background-color: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +.ui-sidebar-trigger-icon { + font-size: 0.9rem; +} + +.ui-sidebar-inset { + background-color: hsl(var(--muted)); + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; +} + +.ui-sidebar-rail { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ui-sidebar[data-collapsed="true"] .ui-sidebar-text, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-description, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-badge, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-group-label, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-trigger-label { + display: none; +} + +.ui-sidebar[data-collapsed="true"] .ui-sidebar-menu-button { + justify-content: center; + padding: 0.5rem; +} + +.ui-sidebar[data-collapsed="true"] .ui-sidebar-icon { + background-color: transparent; +} + +@media (max-width: 960px) { + .ui-sidebar-layout { + grid-template-columns: minmax(0, 1fr); + } + + .ui-sidebar { + border-bottom: 1px solid hsl(var(--border)); + border-right: none; + width: 100%; + } + + .ui-sidebar[data-collapsed="true"] { + width: 100%; + } +} diff --git a/src/components/ui/mod.rs b/src/components/ui/mod.rs index f4d13bb..a7710cc 100644 --- a/src/components/ui/mod.rs +++ b/src/components/ui/mod.rs @@ -26,6 +26,7 @@ mod radio_group; mod select; mod separator; mod sheet; +mod sidebar; mod slider; mod steps; mod switch; @@ -58,6 +59,7 @@ pub use radio_group::*; pub use select::*; pub use separator::*; pub use sheet::*; +pub use sidebar::*; pub use slider::*; pub use steps::*; pub use switch::*; diff --git a/src/components/ui/sidebar.rs b/src/components/ui/sidebar.rs new file mode 100644 index 0000000..baf8f6c --- /dev/null +++ b/src/components/ui/sidebar.rs @@ -0,0 +1,325 @@ +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() + } +} + +fn data_bool(value: bool) -> &'static str { + if value { + "true" + } else { + "false" + } +} + +#[component] +pub fn Sidebar( + #[props(default)] collapsed: bool, + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-sidebar", class); + rsx! { + aside { + class: classes, + "data-collapsed": data_bool(collapsed), + {children} + } + } +} + +#[component] +pub fn SidebarLayout(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-layout", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarInset(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-inset", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarRail(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-rail", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarHeader(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-header", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarContent(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-content", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarFooter(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-footer", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarSeparator(#[props(into, default)] class: Option) -> Element { + let classes = merge_class("ui-sidebar-separator", class); + rsx! { + div { + class: classes, + role: "separator", + } + } +} + +#[component] +pub fn SidebarGroup(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-group", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarGroupLabel( + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-sidebar-group-label", class); + rsx! { + span { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarGroupContent( + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-sidebar-group-content", class); + rsx! { + div { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarMenu(#[props(into, default)] class: Option, children: Element) -> Element { + let classes = merge_class("ui-sidebar-menu", class); + rsx! { + nav { + class: classes, + ul { + class: "ui-sidebar-menu-list", + {children} + } + } + } +} + +#[component] +pub fn SidebarMenuItem( + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-sidebar-menu-item", class); + rsx! { + li { + class: classes, + {children} + } + } +} + +#[component] +pub fn SidebarMenuButton( + #[props(into)] label: String, + #[props(into, default)] description: Option, + #[props(into, default)] badge: Option, + #[props(into, default)] icon: Option, + #[props(into, default)] href: Option, + #[props(default)] active: bool, + #[props(into, default)] class: Option, + #[props(optional)] on_click: Option>, +) -> Element { + let classes = merge_class("ui-sidebar-menu-button", class); + let description_text = description.clone(); + let badge_text = badge.clone(); + let icon_text = icon + .clone() + .or_else(|| label.chars().next().map(|character| character.to_string())); + + let handler_for_link = on_click.clone(); + let handler_for_button = on_click.clone(); + + let label_clone = label.clone(); + + if let Some(href_value) = href.clone() { + rsx! { + a { + class: classes, + href: href_value, + "data-active": data_bool(active), + onclick: move |event| { + if let Some(handler) = handler_for_link.clone() { + handler.call(event); + } + }, + span { class: "ui-sidebar-button-body", + span { + class: "ui-sidebar-icon", + if let Some(icon_value) = icon_text.clone() { + "{icon_value}" + } + } + span { + class: "ui-sidebar-text", + span { class: "ui-sidebar-label", "{label_clone}" } + if let Some(description_value) = description_text.clone() { + span { + class: "ui-sidebar-description", + "{description_value}" + } + } + } + if let Some(badge_value) = badge_text.clone() { + span { + class: "ui-sidebar-badge", + "{badge_value}" + } + } + } + } + } + } else { + rsx! { + button { + class: classes, + "data-active": data_bool(active), + r#type: "button", + onclick: move |event| { + if let Some(handler) = handler_for_button.clone() { + handler.call(event); + } + }, + span { class: "ui-sidebar-button-body", + span { + class: "ui-sidebar-icon", + if let Some(icon_value) = icon_text.clone() { + "{icon_value}" + } + } + span { + class: "ui-sidebar-text", + span { class: "ui-sidebar-label", "{label}" } + if let Some(description_value) = description_text.clone() { + span { + class: "ui-sidebar-description", + "{description_value}" + } + } + } + if let Some(badge_value) = badge_text.clone() { + span { + class: "ui-sidebar-badge", + "{badge_value}" + } + } + } + } + } + } +} + +#[component] +pub fn SidebarMenuBadge( + #[props(into)] text: String, + #[props(into, default)] class: Option, +) -> Element { + let classes = merge_class("ui-sidebar-badge", class); + rsx! { + span { + class: classes, + "{text}" + } + } +} + +#[component] +pub fn SidebarTrigger( + #[props(default)] collapsed: bool, + #[props(into, default)] class: Option, + #[props(into, default)] label: Option, + #[props(optional)] on_toggle: Option>, +) -> Element { + let classes = merge_class("ui-sidebar-trigger", class); + let label_text = label.unwrap_or_else(|| { + if collapsed { + "展开侧边栏".to_string() + } else { + "收起侧边栏".to_string() + } + }); + let state = !collapsed; + let handler = on_toggle.clone(); + + rsx! { + button { + class: classes, + r#type: "button", + "aria-pressed": data_bool(!collapsed), + onclick: move |_| { + if let Some(handler) = handler.clone() { + handler.call(state); + } + }, + span { class: "ui-sidebar-trigger-icon", if collapsed { "→" } else { "←" } } + span { class: "ui-sidebar-trigger-label", "{label_text}" } + } + } +} diff --git a/src/views/home.rs b/src/views/home.rs index 8dc2c66..ba2374f 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -6,8 +6,11 @@ use crate::components::{ 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, + 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, }, Echo, Hero, }; @@ -41,6 +44,8 @@ fn UiShowcase() -> Element { let dialog_open = use_signal(|| false); let sheet_open = use_signal(|| false); let toast_open = use_signal(|| false); + let sidebar_collapsed = use_signal(|| false); + let sidebar_active = use_signal(|| "analytics".to_string()); let slider_value_signal = slider_value.clone(); let slider_value_setter = slider_value.clone(); let contact_method_signal = contact_method.clone(); @@ -60,6 +65,8 @@ fn UiShowcase() -> Element { let dialog_signal = dialog_open.clone(); let sheet_signal = sheet_open.clone(); let toast_signal = toast_open.clone(); + let sidebar_collapsed_setter = sidebar_collapsed.clone(); + let sidebar_active_setter = sidebar_active.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![ @@ -150,6 +157,34 @@ fn UiShowcase() -> Element { .unwrap_or_else(|| "System".to_string()) }; let theme_summary = format!("Active theme: {theme_display}"); + let collapsed_state = sidebar_collapsed(); + let current_sidebar_value = sidebar_active(); + let is_analytics_active = current_sidebar_value.as_str() == "analytics"; + let is_crm_active = current_sidebar_value.as_str() == "crm"; + let is_billing_active = current_sidebar_value.as_str() == "billing"; + let is_settings_active = current_sidebar_value.as_str() == "settings"; + let (sidebar_title, sidebar_body) = match current_sidebar_value.as_str() { + "analytics" => ( + "Analytics overview".to_string(), + "Monitor KPI trends, conversion funnels, and health metrics in real time.".to_string(), + ), + "crm" => ( + "Customer relationship management".to_string(), + "Surface leads, segment accounts, and coordinate follow-ups in one place.".to_string(), + ), + "billing" => ( + "Billing & usage".to_string(), + "Review invoices, adjust subscription tiers, and reconcile metered usage.".to_string(), + ), + "settings" => ( + "Workspace settings".to_string(), + "Manage authentication, API tokens, and notification preferences.".to_string(), + ), + _ => ( + "Select a section".to_string(), + "Pick a destination from the sidebar to preview the content area.".to_string(), + ), + }; rsx! { section { @@ -396,6 +431,114 @@ fn UiShowcase() -> Element { } } + Card { + CardHeader { + CardTitle { "Structural navigation" } + CardDescription { "Collapsible sidebar layout with grouped menus." } + } + CardContent { + SidebarLayout { + Sidebar { + collapsed: collapsed_state, + SidebarHeader { + div { class: "ui-sidebar-button-body", + span { class: "ui-sidebar-icon", "⚡" } + span { class: "ui-sidebar-text", + span { class: "ui-sidebar-label", "Acme HQ" } + span { class: "ui-sidebar-description", "Operations console" } + } + } + } + SidebarContent { + SidebarGroup { + SidebarGroupLabel { "Workspace" } + SidebarGroupContent { + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { + label: "Analytics", + description: Some("Track KPIs and trends".into()), + icon: Some("📊".into()), + active: is_analytics_active, + on_click: move |_| { + let mut signal = sidebar_active_setter.clone(); + signal.set("analytics".to_string()); + }, + } + } + SidebarMenuItem { + SidebarMenuButton { + label: "CRM", + description: Some("Manage customer pipeline".into()), + icon: Some("👥".into()), + active: is_crm_active, + on_click: move |_| { + let mut signal = sidebar_active_setter.clone(); + signal.set("crm".to_string()); + }, + } + } + } + } + } + SidebarSeparator {} + SidebarGroup { + SidebarGroupLabel { "Reporting" } + SidebarGroupContent { + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { + label: "Billing", + description: Some("Invoices, usage, balances".into()), + icon: Some("💳".into()), + badge: Some("8".into()), + active: is_billing_active, + on_click: move |_| { + let mut signal = sidebar_active_setter.clone(); + signal.set("billing".to_string()); + }, + } + } + SidebarMenuItem { + SidebarMenuButton { + label: "Settings", + description: Some("Themes, tokens, notifications".into()), + icon: Some("⚙️".into()), + active: is_settings_active, + on_click: move |_| { + let mut signal = sidebar_active_setter.clone(); + signal.set("settings".to_string()); + }, + } + } + } + } + } + } + SidebarFooter { + SidebarTrigger { + collapsed: collapsed_state, + label: Some("Toggle sidebar".to_string()), + on_toggle: move |next| { + let mut signal = sidebar_collapsed_setter.clone(); + signal.set(next); + }, + } + } + } + SidebarInset { + class: "ui-stack", + h3 { style: "font-size: 1.2rem; font-weight: 600;", "{sidebar_title}" } + p { + style: "color: hsl(var(--muted-foreground)); max-width: 460px;", + "{sidebar_body}" + } + SpanHelper { "Use the sidebar to swap the focused surface." } + } + } + } + } + Card { CardHeader { CardTitle { "Selection controls" }