sidebar组件

This commit is contained in:
tommy
2025-11-03 13:52:15 +08:00
parent 7817029fa9
commit ae5969319c
4 changed files with 707 additions and 2 deletions

View File

@@ -1183,3 +1183,238 @@
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.75rem; 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%;
}
}

View File

@@ -26,6 +26,7 @@ mod radio_group;
mod select; mod select;
mod separator; mod separator;
mod sheet; mod sheet;
mod sidebar;
mod slider; mod slider;
mod steps; mod steps;
mod switch; mod switch;
@@ -58,6 +59,7 @@ pub use radio_group::*;
pub use select::*; pub use select::*;
pub use separator::*; pub use separator::*;
pub use sheet::*; pub use sheet::*;
pub use sidebar::*;
pub use slider::*; pub use slider::*;
pub use steps::*; pub use steps::*;
pub use switch::*; pub use switch::*;

View File

@@ -0,0 +1,325 @@
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<String>,
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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>) -> Element {
let classes = merge_class("ui-sidebar-separator", class);
rsx! {
div {
class: classes,
role: "separator",
}
}
}
#[component]
pub fn SidebarGroup(#[props(into, default)] class: Option<String>, 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<String>,
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<String>,
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<String>, 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<String>,
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<String>,
#[props(into, default)] badge: Option<String>,
#[props(into, default)] icon: Option<String>,
#[props(into, default)] href: Option<String>,
#[props(default)] active: bool,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_click: Option<EventHandler<MouseEvent>>,
) -> 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<String>,
) -> 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<String>,
#[props(into, default)] label: Option<String>,
#[props(optional)] on_toggle: Option<EventHandler<bool>>,
) -> 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}" }
}
}
}

View File

@@ -6,8 +6,11 @@ use crate::components::{
ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input, ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input,
Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination, Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination,
Popover, Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator, Popover, Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator,
SeparatorOrientation, Sheet, SheetSide, Slider, StepItem, Steps, Switch, Tabs, TabsContent, SeparatorOrientation, Sheet, SheetSide, Sidebar, SidebarContent, SidebarFooter,
TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Tooltip, 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, Echo, Hero,
}; };
@@ -41,6 +44,8 @@ fn UiShowcase() -> Element {
let dialog_open = use_signal(|| false); let dialog_open = use_signal(|| false);
let sheet_open = use_signal(|| false); let sheet_open = use_signal(|| false);
let toast_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_signal = slider_value.clone();
let slider_value_setter = slider_value.clone(); let slider_value_setter = slider_value.clone();
let contact_method_signal = contact_method.clone(); let contact_method_signal = contact_method.clone();
@@ -60,6 +65,8 @@ fn UiShowcase() -> Element {
let dialog_signal = dialog_open.clone(); let dialog_signal = dialog_open.clone();
let sheet_signal = sheet_open.clone(); let sheet_signal = sheet_open.clone();
let toast_signal = toast_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 intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal());
let contact_text = move || format!("Preferred contact: {}", contact_method_signal()); let contact_text = move || format!("Preferred contact: {}", contact_method_signal());
let select_options = vec![ let select_options = vec![
@@ -150,6 +157,34 @@ fn UiShowcase() -> Element {
.unwrap_or_else(|| "System".to_string()) .unwrap_or_else(|| "System".to_string())
}; };
let theme_summary = format!("Active theme: {theme_display}"); 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! { rsx! {
section { 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 { Card {
CardHeader { CardHeader {
CardTitle { "Selection controls" } CardTitle { "Selection controls" }