use crate::components::button::{Button, ButtonVariant}; use crate::components::separator::Separator; use crate::components::sheet::{ Sheet, SheetContentClose, SheetDescription, SheetHeader, SheetSide, SheetTitle, }; use crate::components::skeleton::Skeleton; use crate::components::tooltip::{Tooltip, TooltipContent, TooltipTrigger}; use dioxus::core::use_drop; use dioxus::prelude::*; use dioxus_icons::lucide::PanelLeft; use dioxus_primitives::dioxus_attributes::attributes; use dioxus_primitives::merge_attributes; use dioxus_primitives::use_controlled; #[css_module("/src/components/sidebar/style.css")] struct Styles; // constants const SIDEBAR_WIDTH: &str = "16rem"; const SIDEBAR_WIDTH_MOBILE: &str = "18rem"; const SIDEBAR_WIDTH_ICON: &str = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT: &str = "b"; const MOBILE_BREAKPOINT: u32 = 768; #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum SidebarState { #[default] Expanded, Collapsed, } impl SidebarState { pub fn as_str(&self) -> &'static str { match self { SidebarState::Expanded => "expanded", SidebarState::Collapsed => "collapsed", } } } // Variants/methods beyond what this app currently uses are part of the // component library's public API, not unused code — CI runs clippy with // `-D warnings`, which would otherwise turn this dead_code lint into a // build failure. #[allow(dead_code)] #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum SidebarSide { #[default] Left, Right, } impl SidebarSide { pub fn as_str(&self) -> &'static str { match self { SidebarSide::Left => "left", SidebarSide::Right => "right", } } } #[allow(dead_code)] #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum SidebarVariant { #[default] Sidebar, Floating, Inset, } impl SidebarVariant { pub fn as_str(&self) -> &'static str { match self { SidebarVariant::Sidebar => "sidebar", SidebarVariant::Floating => "floating", SidebarVariant::Inset => "inset", } } } #[allow(dead_code)] #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum SidebarCollapsible { #[default] Offcanvas, Icon, None, } impl SidebarCollapsible { pub fn as_str(&self) -> &'static str { match self { SidebarCollapsible::Offcanvas => "offcanvas", SidebarCollapsible::Icon => "icon", SidebarCollapsible::None => "none", } } } #[derive(Clone, Copy)] #[allow(dead_code)] pub struct SidebarCtx { pub state: Memo, pub side: Signal, pub is_mobile: Signal, // From use_controlled: open: Memo, set_open: Callback, // Mobile state: open_mobile: Signal, } impl SidebarCtx { /// Toggle the sidebar open/closed state pub fn toggle(&self) { if (self.is_mobile)() { let current = (self.open_mobile)(); let mut open_mobile = self.open_mobile; open_mobile.set(!current); } else { self.set_open.call(!self.open()); } } /// Set the mobile sidebar open state pub fn set_open_mobile(&self, value: bool) { let mut open_mobile = self.open_mobile; open_mobile.set(value); } /// Get the current open state (desktop) pub fn open(&self) -> bool { self.open.cloned() } } pub fn use_sidebar() -> SidebarCtx { use_context::() } pub fn use_is_mobile() -> Signal { let mut is_mobile = use_signal(|| false); use_effect(move || { spawn(async move { let js_code = format!( r#" function checkMobile() {{ return window.innerWidth < {MOBILE_BREAKPOINT}; }} function handleResize() {{ dioxus.send(checkMobile()); }} window.__sidebarResizeHandler = handleResize; window.addEventListener('resize', window.__sidebarResizeHandler); dioxus.send(checkMobile()); "# ); let mut eval = document::eval(&js_code); while let Ok(result) = eval.recv::().await { is_mobile.set(result); } }); }); use_drop(|| { _ = document::eval( r#" window.removeEventListener('resize', window.__sidebarResizeHandler); delete window.__sidebarResizeHandler; "#, ); }); is_mobile } #[component] pub fn SidebarProvider( #[props(default = true)] default_open: bool, #[props(default)] open: ReadSignal>, #[props(default)] on_open_change: Callback, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let is_mobile = use_is_mobile(); let side = use_signal(|| SidebarSide::Left); let open_mobile = use_signal(|| false); let (open, set_open) = use_controlled(open, default_open, on_open_change); let state = use_memo(move || { if open() { SidebarState::Expanded } else { SidebarState::Collapsed } }); let ctx = SidebarCtx { state, side, is_mobile, open, set_open, open_mobile, }; use_context_provider(|| ctx); use_effect(move || { spawn(async move { let js_code = format!( r#" function sidebarKeyHandler(event) {{ if (event.key === '{SIDEBAR_KEYBOARD_SHORTCUT}' && (event.metaKey || event.ctrlKey)) {{ event.preventDefault(); dioxus.send(true); }} }} window.__sidebarKeyHandler = sidebarKeyHandler; window.addEventListener('keydown', window.__sidebarKeyHandler); "# ); let mut eval = document::eval(&js_code); loop { if eval.recv::().await.is_ok() { ctx.toggle(); } } }); }); use_drop(|| { _ = document::eval( r#" window.removeEventListener('keydown', window.__sidebarKeyHandler); delete window.__sidebarKeyHandler; "#, ); }); let sidebar_style = format!( r#" --dx-sidebar-width: {SIDEBAR_WIDTH}; --dx-sidebar-width-mobile: {SIDEBAR_WIDTH_MOBILE}; --dx-sidebar-width-icon: {SIDEBAR_WIDTH_ICON} "# ); let base = attributes!(div { class: Styles::dx_sidebar_wrapper, "data-slot": "sidebar-wrapper", style: sidebar_style, }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn Sidebar( #[props(default)] side: SidebarSide, #[props(default)] variant: SidebarVariant, #[props(default)] collapsible: SidebarCollapsible, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let ctx = use_sidebar(); let mut ctx_side = ctx.side; if *ctx_side.peek() != side { ctx_side.set(side); } let is_mobile = ctx.is_mobile; let state = ctx.state; let open_mobile = ctx.open_mobile; if collapsible == SidebarCollapsible::None { let base = attributes!(div { class: Styles::dx_sidebar_static, "data-slot": "sidebar", }); let merged = merge_attributes(vec![base, attributes]); return rsx! { div { ..merged, {children} } }; } if is_mobile() { let sheet_side = match side { SidebarSide::Left => SheetSide::Left, SidebarSide::Right => SheetSide::Right, }; return rsx! { Sheet { open: open_mobile(), on_open_change: move |v| ctx.set_open_mobile(v), "data-side": sheet_side.as_str(), class: Styles::dx_sidebar_sheet.to_string(), "data-sidebar": "sidebar", "data-slot": "sidebar", "data-mobile": "true", SheetContentClose { class: Styles::dx_sidebar_sheet_close } SheetHeader { class: Styles::dx_sr_only, SheetTitle { "Sidebar" } SheetDescription { "Displays the mobile sidebar." } } div { class: Styles::dx_sidebar_mobile_inner, {children} } } }; } let collapsible_str = if state() == SidebarState::Collapsed { collapsible.as_str() } else { "" }; let container_base = attributes!(div { class: Styles::dx_sidebar_container, "data-slot": "sidebar-container", }); let container_attrs = merge_attributes(vec![container_base, attributes]); rsx! { div { class: Styles::dx_sidebar_desktop, "data-state": state().as_str(), "data-collapsible": collapsible_str, "data-variant": variant.as_str(), "data-side": side.as_str(), "data-slot": "sidebar", div { class: Styles::dx_sidebar_gap, "data-slot": "sidebar-gap" } div { ..container_attrs, div { class: Styles::dx_sidebar_inner, "data-sidebar": "sidebar", "data-slot": "sidebar-inner", {children} } } } } } #[component] pub fn SidebarTrigger( #[props(default)] onclick: Option>, #[props(extends = GlobalAttributes)] #[props(extends = button)] attributes: Vec, ) -> Element { let ctx = use_sidebar(); let base = attributes!(button { class: Styles::dx_sidebar_trigger, "data-sidebar": "trigger", "data-slot": "sidebar-trigger", }); let merged = merge_attributes(vec![base, attributes]); rsx! { Button { variant: ButtonVariant::Ghost, onclick: move |e| { if let Some(handler) = &onclick { handler.call(e); } ctx.toggle(); }, attributes: merged, PanelLeft { class: Styles::dx_sidebar_trigger_icon, size: "1rem", } span { class: Styles::dx_sr_only, "Toggle Sidebar" } } } } #[component] pub fn SidebarRail(#[props(extends = GlobalAttributes)] attributes: Vec) -> Element { let ctx = use_sidebar(); let base = attributes!(button { class: Styles::dx_sidebar_rail, "data-sidebar": "rail", "data-slot": "sidebar-rail", }); let merged = merge_attributes(vec![base, attributes]); rsx! { button { aria_label: "Toggle Sidebar", tabindex: -1, onclick: move |_| ctx.toggle(), title: "Toggle Sidebar", ..merged, } } } #[component] pub fn SidebarInset( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(main { class: Styles::dx_sidebar_inset, "data-slot": "sidebar-inset", }); let merged = merge_attributes(vec![base, attributes]); rsx! { main { ..merged, {children} } } } #[component] pub fn SidebarHeader( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_header, "data-slot": "sidebar-header", "data-sidebar": "header", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarContent( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_content, "data-slot": "sidebar-content", "data-sidebar": "content", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarFooter( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_footer, "data-slot": "sidebar-footer", "data-sidebar": "footer", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarSeparator( #[props(default = true)] horizontal: bool, #[props(default = true)] decorative: bool, #[props(extends = GlobalAttributes)] attributes: Vec, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_separator, "data-slot": "sidebar-separator", "data-sidebar": "separator", }); let merged = merge_attributes(vec![base, attributes]); rsx! { Separator { horizontal, decorative, attributes: merged } } } #[component] pub fn SidebarGroup( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_group, "data-slot": "sidebar-group", "data-sidebar": "group", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarGroupLabel( r#as: Option, Element>>, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_group_label, "data-slot": "sidebar-group-label", "data-sidebar": "group-label", }); let merged = merge_attributes(vec![base, attributes]); if let Some(dynamic) = r#as { dynamic.call(merged) } else { rsx! { div { ..merged,{children} } } } } #[component] pub fn SidebarGroupAction( r#as: Option, Element>>, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(button { class: Styles::dx_sidebar_group_action, "data-slot": "sidebar-group-action", "data-sidebar": "group-action", }); let merged = merge_attributes(vec![base, attributes]); if let Some(dynamic) = r#as { dynamic.call(merged) } else { rsx! { button { ..merged,{children} } } } } #[component] pub fn SidebarGroupContent( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_group_content, "data-slot": "sidebar-group-content", "data-sidebar": "group-content", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarMenu( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(ul { class: Styles::dx_sidebar_menu, "data-slot": "sidebar-menu", "data-sidebar": "menu", }); let merged = merge_attributes(vec![base, attributes]); rsx! { ul { ..merged, {children} } } } #[component] pub fn SidebarMenuItem( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(li { class: Styles::dx_sidebar_menu_item, "data-slot": "sidebar-menu-item", "data-sidebar": "menu-item", }); let merged = merge_attributes(vec![base, attributes]); rsx! { li { ..merged, {children} } } } #[derive(Debug, Clone, Copy, Default, PartialEq)] #[allow(dead_code)] pub enum SidebarMenuButtonVariant { #[default] Default, Outline, } impl SidebarMenuButtonVariant { pub fn as_str(&self) -> &'static str { match self { SidebarMenuButtonVariant::Default => "default", SidebarMenuButtonVariant::Outline => "outline", } } } #[derive(Debug, Clone, Copy, Default, PartialEq)] #[allow(dead_code)] pub enum SidebarMenuButtonSize { #[default] Default, Sm, Lg, } impl SidebarMenuButtonSize { pub fn as_str(&self) -> &'static str { match self { SidebarMenuButtonSize::Default => "default", SidebarMenuButtonSize::Sm => "sm", SidebarMenuButtonSize::Lg => "lg", } } } #[component] pub fn SidebarMenuButton( #[props(default = false)] is_active: bool, #[props(default)] variant: SidebarMenuButtonVariant, #[props(default)] size: SidebarMenuButtonSize, #[props(extends = GlobalAttributes)] attributes: Vec, #[props(default)] tooltip: Option, r#as: Option, Element>>, children: Element, ) -> Element { let ctx = use_sidebar(); let is_mobile = ctx.is_mobile; let state = ctx.state; let base = attributes!(button { class: Styles::dx_sidebar_menu_button, "data-slot": "sidebar-menu-button", "data-sidebar": "menu-button", "data-size": size.as_str(), "data-variant": variant.as_str(), "data-active": if is_active { "true" } else { "false" }, }); let merged = merge_attributes(vec![base, attributes]); let Some(tooltip_content) = tooltip else { return if let Some(dynamic) = r#as { dynamic.call(merged) } else { rsx! { button { ..merged, {children} } } }; }; let hidden = state() != SidebarState::Collapsed || is_mobile(); let sidebar_side = ctx.side; rsx! { Tooltip { class: Styles::dx_sidebar_tooltip, disabled: hidden, TooltipTrigger { as: move |tooltip_attrs: Vec| { let final_attrs = merge_attributes(vec![tooltip_attrs, merged.clone()]); let children = children.clone(); if let Some(dynamic) = &r#as { dynamic.call(final_attrs) } else { rsx! { button { ..final_attrs, {children} } } } }, } TooltipContent { side: match sidebar_side() { SidebarSide::Left => dioxus_primitives::ContentSide::Right, SidebarSide::Right => dioxus_primitives::ContentSide::Left, }, {tooltip_content} } } } } #[component] pub fn SidebarMenuAction( #[props(default = false)] show_on_hover: bool, r#as: Option, Element>>, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(button { class: Styles::dx_sidebar_menu_action, "data-slot": "sidebar-menu-action", "data-sidebar": "menu-action", "data-show-on-hover": if show_on_hover { "true" } else { "false" }, }); let merged = merge_attributes(vec![base, attributes]); if let Some(dynamic) = r#as { dynamic.call(merged) } else { rsx! { button { ..merged,{children} } } } } #[component] pub fn SidebarMenuBadge( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_menu_badge, "data-slot": "sidebar-menu-badge", "data-sidebar": "menu-badge", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, {children} } } } #[component] pub fn SidebarMenuSkeleton( #[props(default = false)] show_icon: bool, #[props(extends = GlobalAttributes)] attributes: Vec, ) -> Element { let base = attributes!(div { class: Styles::dx_sidebar_menu_skeleton, "data-slot": "sidebar-menu-skeleton", "data-sidebar": "menu-skeleton", }); let merged = merge_attributes(vec![base, attributes]); rsx! { div { ..merged, if show_icon { Skeleton { class: Styles::dx_sidebar_menu_skeleton_icon } } Skeleton { class: Styles::dx_sidebar_menu_skeleton_text, width: "70%" } } } } #[component] pub fn SidebarMenuSub( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(ul { class: Styles::dx_sidebar_menu_sub, "data-slot": "sidebar-menu-sub", "data-sidebar": "menu-sub", }); let merged = merge_attributes(vec![base, attributes]); rsx! { ul { ..merged, {children} } } } #[component] pub fn SidebarMenuSubItem( #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(li { class: Styles::dx_sidebar_menu_sub_item, "data-slot": "sidebar-menu-sub-item", "data-sidebar": "menu-sub-item", }); let merged = merge_attributes(vec![base, attributes]); rsx! { li { ..merged, {children} } } } #[derive(Debug, Clone, Copy, Default, PartialEq)] #[allow(dead_code)] pub enum SidebarMenuSubButtonSize { Sm, #[default] Md, } impl SidebarMenuSubButtonSize { #[allow(dead_code)] pub fn as_str(&self) -> &'static str { match self { SidebarMenuSubButtonSize::Sm => "sm", SidebarMenuSubButtonSize::Md => "md", } } } #[component] pub fn SidebarMenuSubButton( #[props(default = false)] is_active: bool, #[props(default)] size: SidebarMenuSubButtonSize, r#as: Option, Element>>, #[props(extends = GlobalAttributes)] attributes: Vec, children: Element, ) -> Element { let base = attributes!(a { class: Styles::dx_sidebar_menu_sub_button, "data-slot": "sidebar-menu-sub-button", "data-sidebar": "menu-sub-button", "data-size": size.as_str(), "data-active": if is_active { "true" } else { "false" }, }); let merged = merge_attributes(vec![base, attributes]); if let Some(dynamic) = r#as { dynamic.call(merged) } else { rsx! { a { ..merged, {children} } } } }