From adddccaaecaae96be9e1ec80f4b0f1b841c21c41 Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:27:35 +0800 Subject: [PATCH 01/12] add merge attribute method --- primitives/src/lib.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index b58fa05f..ea35c1e5 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -2,12 +2,14 @@ #![warn(missing_docs)] use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; use dioxus::core::{current_scope_id, use_drop}; use dioxus::prelude::*; use dioxus::prelude::{asset, manganis, Asset}; +use dioxus_core::AttributeValue::Text; pub mod accordion; pub mod alert_dialog; @@ -261,3 +263,52 @@ impl ContentAlign { } } } + +/// Merge multiple attribute vectors. +/// +/// Rules: +/// - Later lists win for the same (name, namespace) pair. +/// - `class` is concatenated with a single space separator (trimmed); last wins for volatility flag. +/// - Other attributes are overwritten by the last occurrence. +/// TODO: event handler attributes are not merged/combined yet. +pub fn merge_attributes(lists: Vec>) -> Vec { + let mut merged = Vec::new(); + let mut index = HashMap::new(); + + for attr in lists.into_iter().flatten() { + let key = (attr.name, attr.namespace); + match index.get(&key) { + None => { + index.insert(key, merged.len()); + merged.push(attr); + } + Some(&i) if attr.name != "class" => { + merged[i] = attr; + } + Some(&i) => { + let was_volatile = merged[i].volatile; + merged[i] = match (&merged[i].value, &attr.value) { + (Text(a), Text(b)) => Attribute { + name: attr.name, + namespace: attr.namespace, + volatile: was_volatile || attr.volatile, + value: Text(join_class(a, b)), + }, + _ => attr, + }; + } + } + } + + merged +} + +fn join_class(a: &str, b: &str) -> String { + let (a, b) = (a.trim(), b.trim()); + match (a.is_empty(), b.is_empty()) { + (true, true) => String::new(), + (true, false) => b.to_string(), + (false, true) => a.to_string(), + (false, false) => format!("{a} {b}"), + } +} From 9e5948815509a6b5b1901af8fc74ec88a15c5359 Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:29:19 +0800 Subject: [PATCH 02/12] Add `r#as` support for tooltip --- preview/src/components/tooltip/component.rs | 1 + primitives/src/tooltip.rs | 43 +++++++++++++-------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/preview/src/components/tooltip/component.rs b/preview/src/components/tooltip/component.rs index a6486d2f..d09c2e21 100644 --- a/preview/src/components/tooltip/component.rs +++ b/preview/src/components/tooltip/component.rs @@ -23,6 +23,7 @@ pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element { tooltip::TooltipTrigger { class: "tooltip-trigger", id: props.id, + r#as: props.r#as, attributes: props.attributes, {props.children} } diff --git a/primitives/src/tooltip.rs b/primitives/src/tooltip.rs index a9c1a8d4..9fb32efa 100644 --- a/primitives/src/tooltip.rs +++ b/primitives/src/tooltip.rs @@ -1,7 +1,8 @@ //! Defines the [`Tooltip`] component and its sub-components, which provide contextual information when hovering or focusing on elements. use crate::{ - use_animated_open, use_controlled, use_id_or, use_unique_id, ContentAlign, ContentSide, + merge_attributes, use_animated_open, use_controlled, use_id_or, use_unique_id, ContentAlign, + ContentSide, }; use dioxus::prelude::*; @@ -106,6 +107,10 @@ pub struct TooltipTriggerProps { #[props(default)] pub id: Option, + /// Render the trigger element as a custom component/element. + #[props(default)] + pub r#as: Option, Element>>, + /// Additional attributes for the trigger element #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -181,22 +186,26 @@ pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element { } }; - rsx! { - div { - id: props.id.clone(), - tabindex: "0", - // Mouse events - onmouseenter: handle_mouse_enter, - onmouseleave: handle_mouse_leave, - // Focus events - onfocus: handle_focus, - onblur: handle_blur, - // Keyboard events - onkeydown: handle_keydown, - // ARIA attributes - aria_describedby: ctx.tooltip_id.cloned(), - ..props.attributes, - {props.children} + let base: Vec = vec![ + Attribute::new("id", props.id.clone(), None, false), + Attribute::new("tabindex", "0", None, false), + onmouseenter(handle_mouse_enter), + onmouseleave(handle_mouse_leave), + onfocus(handle_focus), + onblur(handle_blur), + onkeydown(handle_keydown), + Attribute::new("aria-describedby", ctx.tooltip_id.cloned(), None, false), + ]; + let merged = merge_attributes(vec![base, props.attributes]); + + if let Some(dynamic) = props.r#as { + dynamic.call(merged) + } else { + rsx! { + div { + ..merged, + {props.children} + } } } } From 9c7aaf96c9fa53612aca40e52ba073b5329e8f34 Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:31:04 +0800 Subject: [PATCH 03/12] Add `r#as` support for collapsible --- .../src/components/collapsible/component.rs | 42 +++++++++--- primitives/src/collapsible.rs | 68 ++++++++++++------- 2 files changed, 75 insertions(+), 35 deletions(-) diff --git a/preview/src/components/collapsible/component.rs b/preview/src/components/collapsible/component.rs index 5e98d2c9..8a0c2656 100644 --- a/preview/src/components/collapsible/component.rs +++ b/preview/src/components/collapsible/component.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use dioxus_primitives::collapsible::{ self, CollapsibleContentProps, CollapsibleProps, CollapsibleTriggerProps, }; +use dioxus_primitives::merge_attributes; #[component] pub fn Collapsible(props: CollapsibleProps) -> Element { @@ -13,6 +14,7 @@ pub fn Collapsible(props: CollapsibleProps) -> Element { disabled: props.disabled, open: props.open, on_open_change: props.on_open_change, + r#as: props.r#as, attributes: props.attributes, class: "collapsible", {props.children} @@ -22,17 +24,35 @@ pub fn Collapsible(props: CollapsibleProps) -> Element { #[component] pub fn CollapsibleTrigger(props: CollapsibleTriggerProps) -> Element { - rsx! { - collapsible::CollapsibleTrigger { class: "collapsible-trigger", attributes: props.attributes, - {props.children} - svg { - class: "collapsible-expand-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - // shifted up by 6 polyline { points: "6 9 12 15 18 9" } - polyline { points: "6 15 12 21 18 15" } - // shifted down by 6 polyline { points: "6 15 12 9 18 15" } - polyline { points: "6 9 12 3 18 9" } + let base = vec![Attribute::new( + "class", + "collapsible-trigger", + None, + false, + )]; + let merged = merge_attributes(vec![base, props.attributes]); + + if props.r#as.is_some() { + rsx! { + collapsible::CollapsibleTrigger { + r#as: props.r#as, + attributes: merged, + {props.children} + } + } + } else { + rsx! { + collapsible::CollapsibleTrigger { attributes: merged, + {props.children} + svg { + class: "collapsible-expand-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + // shifted up by 6 polyline { points: "6 9 12 15 18 9" } + polyline { points: "6 15 12 21 18 15" } + // shifted down by 6 polyline { points: "6 15 12 9 18 15" } + polyline { points: "6 9 12 3 18 9" } + } } } } diff --git a/primitives/src/collapsible.rs b/primitives/src/collapsible.rs index a1471d0a..803710f5 100644 --- a/primitives/src/collapsible.rs +++ b/primitives/src/collapsible.rs @@ -1,6 +1,6 @@ //! Defines the [`Collapsible`] component and its sub-components. -use crate::{use_controlled, use_id_or, use_unique_id}; +use crate::{merge_attributes, use_controlled, use_id_or, use_unique_id}; use dioxus::prelude::*; // TODO: more docs @@ -44,6 +44,10 @@ pub struct CollapsibleProps { #[props(default)] pub on_open_change: Callback, + /// Render the root element as a custom component/element. + #[props(default)] + pub r#as: Option, Element>>, + /// Additional attributes for the collapsible element. #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -97,13 +101,20 @@ pub fn Collapsible(props: CollapsibleProps) -> Element { aria_controls_id, }); - rsx! { - div { - "data-open": open, - "data-disabled": props.disabled, - ..props.attributes, + let base: Vec = vec![ + Attribute::new("data-open", open, None, false), + Attribute::new("data-disabled", props.disabled, None, false), + ]; + let merged = merge_attributes(vec![base, props.attributes]); - {props.children} + if let Some(dynamic) = props.r#as { + dynamic.call(merged) + } else { + rsx! { + div { + ..merged, + {props.children} + } } } } @@ -180,9 +191,14 @@ pub fn CollapsibleContent(props: CollapsibleContentProps) -> Element { /// The props for the [`CollapsibleTrigger`] component. #[derive(Props, Clone, PartialEq)] pub struct CollapsibleTriggerProps { + /// Render the trigger element as a custom component/element. + #[props(default)] + pub r#as: Option, Element>>, + /// Additional attributes for the collapsible trigger element. #[props(extends = GlobalAttributes)] pub attributes: Vec, + /// The children of the collapsible trigger. pub children: Element, } @@ -227,24 +243,28 @@ pub fn CollapsibleTrigger(props: CollapsibleTriggerProps) -> Element { let open = ctx.open; - rsx! { - - button { - type: "button", - "data-open": open, - "data-disabled": ctx.disabled, - disabled: ctx.disabled, - - aria_controls: ctx.aria_controls_id, - aria_expanded: open, - - onclick: move |_| { - let new_open = !open(); - ctx.set_open.call(new_open); - }, + let base: Vec = vec![ + Attribute::new("type", "button", None, false), + Attribute::new("data-open", open, None, false), + Attribute::new("data-disabled", ctx.disabled, None, false), + Attribute::new("disabled", ctx.disabled, None, false), + Attribute::new("aria-controls", ctx.aria_controls_id, None, false), + Attribute::new("aria-expanded", open, None, false), + onclick(move |_| { + let new_open = !open(); + ctx.set_open.call(new_open); + }), + ]; + let merged = merge_attributes(vec![base, props.attributes]); - ..props.attributes, - {props.children} + if let Some(dynamic) = props.r#as { + dynamic.call(merged) + } else { + rsx! { + button { + ..merged, + {props.children} + } } } } From 1853ca4ac7aaef621e3b66d7fdc2ef22de7fd1bb Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:32:33 +0800 Subject: [PATCH 04/12] Add merge attribute support for button --- preview/src/components/button/component.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/preview/src/components/button/component.rs b/preview/src/components/button/component.rs index 6e6a9678..8219397c 100644 --- a/preview/src/components/button/component.rs +++ b/preview/src/components/button/component.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use dioxus_primitives::merge_attributes; #[derive(Copy, Clone, PartialEq, Default)] #[non_exhaustive] @@ -34,12 +35,16 @@ pub fn Button( onmouseup: Option>, children: Element, ) -> Element { + let base: Vec = vec![ + Attribute::new("class", "button", None, false), + Attribute::new("data-style", variant.class(), None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + rsx! { document::Link { rel: "stylesheet", href: asset!("./style.css") } button { - class: "button", - "data-style": variant.class(), onclick: move |event| { if let Some(f) = &onclick { f.call(event); @@ -55,7 +60,7 @@ pub fn Button( f.call(event); } }, - ..attributes, + ..merged, {children} } } From f572a83da8ae0a0f3b550bf90283adb95c9f4621 Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:34:49 +0800 Subject: [PATCH 05/12] Add `r#as` support for dropdown menu --- .../src/components/dropdown_menu/component.rs | 38 +++++--- .../src/components/dropdown_menu/style.css | 1 + primitives/src/dropdown_menu.rs | 86 ++++++++++++------- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/preview/src/components/dropdown_menu/component.rs b/preview/src/components/dropdown_menu/component.rs index b56b9b6d..56ab4adb 100644 --- a/preview/src/components/dropdown_menu/component.rs +++ b/preview/src/components/dropdown_menu/component.rs @@ -3,19 +3,22 @@ use dioxus_primitives::dropdown_menu::{ self, DropdownMenuContentProps, DropdownMenuItemProps, DropdownMenuProps, DropdownMenuTriggerProps, }; +use dioxus_primitives::merge_attributes; #[component] pub fn DropdownMenu(props: DropdownMenuProps) -> Element { + let base = vec![Attribute::new("class", "dropdown-menu", None, false)]; + let merged = merge_attributes(vec![base, props.attributes.clone()]); + rsx! { document::Link { rel: "stylesheet", href: asset!("./style.css") } dropdown_menu::DropdownMenu { - class: "dropdown-menu", open: props.open, default_open: props.default_open, on_open_change: props.on_open_change, disabled: props.disabled, roving_loop: props.roving_loop, - attributes: props.attributes, + attributes: merged, {props.children} } } @@ -23,20 +26,31 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { #[component] pub fn DropdownMenuTrigger(props: DropdownMenuTriggerProps) -> Element { + let base = vec![Attribute::new( + "class", + "dropdown-menu-trigger", + None, + false, + )]; + let merged = merge_attributes(vec![base, props.attributes]); + rsx! { - dropdown_menu::DropdownMenuTrigger { class: "dropdown-menu-trigger", attributes: props.attributes, {props.children} } + dropdown_menu::DropdownMenuTrigger { r#as: props.r#as, attributes: merged, {props.children} } } } #[component] pub fn DropdownMenuContent(props: DropdownMenuContentProps) -> Element { + let base = vec![Attribute::new( + "class", + "dropdown-menu-content", + None, + false, + )]; + let merged = merge_attributes(vec![base, props.attributes.clone()]); + rsx! { - dropdown_menu::DropdownMenuContent { - class: "dropdown-menu-content", - id: props.id, - attributes: props.attributes, - {props.children} - } + dropdown_menu::DropdownMenuContent { id: props.id, attributes: merged, {props.children} } } } @@ -44,14 +58,16 @@ pub fn DropdownMenuContent(props: DropdownMenuContentProps) -> Element { pub fn DropdownMenuItem( props: DropdownMenuItemProps, ) -> Element { + let base = vec![Attribute::new("class", "dropdown-menu-item", None, false)]; + let merged = merge_attributes(vec![base, props.attributes.clone()]); + rsx! { dropdown_menu::DropdownMenuItem { - class: "dropdown-menu-item", disabled: props.disabled, value: props.value, index: props.index, on_select: props.on_select, - attributes: props.attributes, + attributes: merged, {props.children} } } diff --git a/preview/src/components/dropdown_menu/style.css b/preview/src/components/dropdown_menu/style.css index 98befa7a..0372d612 100644 --- a/preview/src/components/dropdown_menu/style.css +++ b/preview/src/components/dropdown_menu/style.css @@ -83,6 +83,7 @@ .dropdown-menu-item { display: flex; align-items: center; + gap: 0.5rem; padding: 8px 12px; border-radius: calc(0.5rem - 0.25rem); color: var(--secondary-color-4); diff --git a/primitives/src/dropdown_menu.rs b/primitives/src/dropdown_menu.rs index fd7f740f..d2816beb 100644 --- a/primitives/src/dropdown_menu.rs +++ b/primitives/src/dropdown_menu.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use crate::{ focus::{use_focus_controlled_item, use_focus_provider, FocusState}, - use_animated_open, use_controlled, use_id_or, use_unique_id, + merge_attributes, use_animated_open, use_controlled, use_id_or, use_unique_id, }; use dioxus::prelude::*; @@ -158,10 +158,14 @@ pub fn DropdownMenu(props: DropdownMenuProps) -> Element { /// The props for the [`DropdownMenuTrigger`] component #[derive(Props, Clone, PartialEq)] pub struct DropdownMenuTriggerProps { - /// Additional attributes to apply to the trigger button element. + /// Render the trigger element as a custom component/element. + #[props(default)] + pub r#as: Option, Element>>, + + /// Additional attributes to apply to the trigger element. #[props(extends = GlobalAttributes)] pub attributes: Vec, - /// The children of the trigger button + /// The children of the trigger pub children: Element, } @@ -216,37 +220,53 @@ pub fn DropdownMenuTrigger(props: DropdownMenuTriggerProps) -> Element { let mut ctx: DropdownMenuContext = use_context(); let mut element = use_signal(|| None::>); - rsx! { - button { - id: "{ctx.trigger_id}", - type: "button", - "data-state": if (ctx.open)() { "open" } else { "closed" }, - "data-disabled": (ctx.disabled)(), - disabled: (ctx.disabled)(), - aria_expanded: ctx.open, - aria_haspopup: "listbox", - - onmounted: move |e: MountedEvent| { - element.set(Some(e.data())); - }, - onclick: move |_| { - let new_open = !(ctx.open)(); - ctx.set_open.call(new_open); - // Focus the element on click. Safari does not do this automatically. https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus - if let Some(data) = element() { - spawn(async move { - _ = data.set_focus(true).await; - }); - } - }, - onblur: move |_| { - if !ctx.focus.any_focused() { - ctx.focus.blur(); - } - }, + let open = ctx.open; + let disabled = ctx.disabled; + let data_state = use_memo(move || if open() { "open" } else { "closed" }); + + let base: Vec = vec![ + Attribute::new("id", ctx.trigger_id, None, false), + Attribute::new("type", "button", None, false), + Attribute::new("data-state", data_state, None, false), + Attribute::new("data-disabled", disabled, None, false), + Attribute::new("disabled", disabled, None, false), + Attribute::new("aria-expanded", open, None, false), + Attribute::new("aria-haspopup", "listbox", None, false), + onmounted(move |e: MountedEvent| { + element.set(Some(e.data())); + }), + onclick(move |_| { + if disabled() { + return; + } - ..props.attributes, - {props.children} + let new_open = !open(); + ctx.set_open.call(new_open); + + // Focus the element on click. Safari does not do this automatically. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus + if let Some(data) = element() { + spawn(async move { + _ = data.set_focus(true).await; + }); + } + }), + onblur(move |_| { + if !ctx.focus.any_focused() { + ctx.focus.blur(); + } + }), + ]; + let merged = merge_attributes(vec![base, props.attributes]); + + if let Some(dynamic) = props.r#as { + dynamic.call(merged) + } else { + rsx! { + button { + ..merged, + {props.children} + } } } } From de66814a473cb670e39786d7992061b48c50a2ee Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Tue, 23 Dec 2025 23:39:15 +0800 Subject: [PATCH 06/12] Optimize theme style for separator --- preview/src/components/separator/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/preview/src/components/separator/style.css b/preview/src/components/separator/style.css index afbfd608..cfff431d 100644 --- a/preview/src/components/separator/style.css +++ b/preview/src/components/separator/style.css @@ -1,5 +1,5 @@ .separator { - background-color: var(--primary-color-6); + background-color: var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); } .separator[data-orientation="horizontal"] { @@ -10,4 +10,4 @@ .separator[data-orientation="vertical"] { width: 1px; height: 100%; -} \ No newline at end of file +} From 653d6e6d352872b57eaea6a02c05489830c3e74d Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:18:07 +0800 Subject: [PATCH 07/12] Add sidebar component --- preview/src/components/sidebar/component.json | 21 + preview/src/components/sidebar/component.rs | 860 ++++++++++++++++++ preview/src/components/sidebar/mod.rs | 2 + preview/src/components/sidebar/style.css | 769 ++++++++++++++++ 4 files changed, 1652 insertions(+) create mode 100644 preview/src/components/sidebar/component.json create mode 100644 preview/src/components/sidebar/component.rs create mode 100644 preview/src/components/sidebar/mod.rs create mode 100644 preview/src/components/sidebar/style.css diff --git a/preview/src/components/sidebar/component.json b/preview/src/components/sidebar/component.json new file mode 100644 index 00000000..c4fd8482 --- /dev/null +++ b/preview/src/components/sidebar/component.json @@ -0,0 +1,21 @@ +{ + "name": "sidebar", + "description": "A sidebar component as a vertical interface panel fixed to the screen edge, enable quick access to different sections of an application", + "authors": [ + "zhiyanzhaijie" + ], + "exclude": [ + "variants", + "docs.md", + "component.json" + ], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + } + ], + "globalAssets": [ + "../../../assets/dx-components-theme.css" + ] +} diff --git a/preview/src/components/sidebar/component.rs b/preview/src/components/sidebar/component.rs new file mode 100644 index 00000000..2471f2bb --- /dev/null +++ b/preview/src/components/sidebar/component.rs @@ -0,0 +1,860 @@ +use crate::components::button::{Button, ButtonVariant}; +use crate::components::separator::Separator; +use crate::components::sheet::{ + Sheet, SheetContent, SheetDescription, SheetHeader, SheetSide, SheetTitle, +}; +use crate::components::tooltip::{Tooltip, TooltipContent, TooltipTrigger}; +use dioxus::core::use_drop; +use dioxus::prelude::*; +use dioxus_primitives::merge_attributes; + +// 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", + } + } +} + +#[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", + } + } +} + +#[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", + } + } +} + +#[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)] +pub struct SidebarCtx { + pub state: Memo, + pub side: Signal, + pub open: Signal, + pub set_open: Callback, + pub open_mobile: Signal, + pub set_open_mobile: Callback, + pub is_mobile: Signal, + pub toggle_sidebar: Callback<()>, +} + +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 < {}; + }} + function handleResize() {{ + dioxus.send(checkMobile()); + }} + window.__sidebarResizeHandler = handleResize; + window.addEventListener('resize', window.__sidebarResizeHandler); + dioxus.send(checkMobile()); + "#, + MOBILE_BREAKPOINT + ); + 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: Option>, + #[props(default)] on_open_change: Option>, + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let is_mobile = use_is_mobile(); + let side = use_signal(|| SidebarSide::Left); + let mut open_mobile = use_signal(|| false); + let mut internal_open = use_signal(move || default_open); + + let open_signal = open.unwrap_or(internal_open); + + let set_open = use_callback(move |value: bool| { + if let Some(callback) = on_open_change { + callback.call(value); + } + if open.is_none() { + internal_open.set(value); + } + }); + + let set_open_mobile = use_callback(move |value: bool| { + open_mobile.set(value); + }); + + let toggle_sidebar = use_callback(move |_: ()| { + if is_mobile() { + open_mobile.set(!open_mobile()); + } else { + let new_value = !open_signal(); + set_open.call(new_value); + } + }); + + let state = use_memo(move || { + if open_signal() { + SidebarState::Expanded + } else { + SidebarState::Collapsed + } + }); + + use_effect(move || { + spawn(async move { + let js_code = format!( + r#" + function sidebarKeyHandler(event) {{ + if (event.key === '{}' && (event.metaKey || event.ctrlKey)) {{ + event.preventDefault(); + dioxus.send(true); + }} + }} + window.__sidebarKeyHandler = sidebarKeyHandler; + window.addEventListener('keydown', window.__sidebarKeyHandler); + "#, + SIDEBAR_KEYBOARD_SHORTCUT + ); + let mut eval = document::eval(&js_code); + + loop { + if eval.recv::().await.is_ok() { + toggle_sidebar.call(()); + } + } + }); + }); + + use_drop(|| { + _ = document::eval( + r#" + window.removeEventListener('keydown', window.__sidebarKeyHandler); + delete window.__sidebarKeyHandler; + "#, + ); + }); + + let ctx = SidebarCtx { + state, + side, + open: open_signal, + set_open, + open_mobile, + set_open_mobile, + is_mobile, + toggle_sidebar, + }; + + use_context_provider(|| ctx); + + let sidebar_style = format!( + r#" + --sidebar-width: {}; + --sidebar-width-mobile: {}; + --sidebar-width-icon: {} + "#, + SIDEBAR_WIDTH, SIDEBAR_WIDTH_MOBILE, SIDEBAR_WIDTH_ICON + ); + + let base: Vec = vec![ + Attribute::new("class", "sidebar-wrapper", None, false), + Attribute::new("data-slot", "sidebar-wrapper", None, false), + Attribute::new("style", sidebar_style, None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + 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: Vec = vec![ + Attribute::new("class", "sidebar sidebar-static", None, false), + Attribute::new("data-slot", "sidebar", None, false), + ]; + 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.call(v), + SheetContent { + side: sheet_side, + class: "sidebar-sheet", + "data-sidebar": "sidebar", + "data-slot": "sidebar", + "data-mobile": "true", + SheetHeader { class: "sr-only", + SheetTitle { "Sidebar" } + SheetDescription { "Displays the mobile sidebar." } + } + div { class: "sidebar-mobile-inner", {children} } + } + } + }; + } + + let collapsible_str = if state() == SidebarState::Collapsed { + collapsible.as_str() + } else { + "" + }; + + let container_base: Vec = vec![ + Attribute::new("class", "sidebar-container", None, false), + Attribute::new("data-slot", "sidebar-container", None, false), + ]; + let container_attrs = merge_attributes(vec![container_base, attributes]); + + rsx! { + div { + class: "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: "sidebar-gap", "data-slot": "sidebar-gap" } + div { + ..container_attrs, + div { + class: "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: Vec = vec![ + Attribute::new("class", "sidebar-trigger", None, false), + Attribute::new("data-sidebar", "trigger", None, false), + Attribute::new("data-slot", "sidebar-trigger", None, false), + ]; + 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_sidebar.call(()); + }, + attributes: merged, + svg { + class: "sidebar-trigger-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + rect { + x: "3", + y: "3", + width: "18", + height: "18", + rx: "2", + } + path { d: "M9 3v18" } + } + span { class: "sr-only", "Toggle Sidebar" } + } + } +} + +#[component] +pub fn SidebarRail(#[props(extends = GlobalAttributes)] attributes: Vec) -> Element { + let ctx = use_sidebar(); + + let base: Vec = vec![ + Attribute::new("class", "sidebar-rail", None, false), + Attribute::new("data-sidebar", "rail", None, false), + Attribute::new("data-slot", "sidebar-rail", None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + button { + aria_label: "Toggle Sidebar", + tabindex: -1, + onclick: move |_| ctx.toggle_sidebar.call(()), + title: "Toggle Sidebar", + ..merged, + } + } +} + +#[component] +pub fn SidebarInset( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let base: Vec = vec![ + Attribute::new("class", "sidebar-inset", None, false), + Attribute::new("data-slot", "sidebar-inset", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-header", None, false), + Attribute::new("data-slot", "sidebar-header", None, false), + Attribute::new("data-sidebar", "header", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-content", None, false), + Attribute::new("data-slot", "sidebar-content", None, false), + Attribute::new("data-sidebar", "content", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-footer", None, false), + Attribute::new("data-slot", "sidebar-footer", None, false), + Attribute::new("data-sidebar", "footer", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-separator", None, false), + Attribute::new("data-slot", "sidebar-separator", None, false), + Attribute::new("data-sidebar", "separator", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-group", None, false), + Attribute::new("data-slot", "sidebar-group", None, false), + Attribute::new("data-sidebar", "group", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-group-label", None, false), + Attribute::new("data-slot", "sidebar-group-label", None, false), + Attribute::new("data-sidebar", "group-label", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-group-action", None, false), + Attribute::new("data-slot", "sidebar-group-action", None, false), + Attribute::new("data-sidebar", "group-action", None, 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 SidebarGroupContent( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let base: Vec = vec![ + Attribute::new("class", "sidebar-group-content", None, false), + Attribute::new("data-slot", "sidebar-group-content", None, false), + Attribute::new("data-sidebar", "group-content", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu", None, false), + Attribute::new("data-slot", "sidebar-menu", None, false), + Attribute::new("data-sidebar", "menu", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu-item", None, false), + Attribute::new("data-slot", "sidebar-menu-item", None, false), + Attribute::new("data-sidebar", "menu-item", None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + li { ..merged, {children} } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +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)] +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: Vec = vec![ + Attribute::new("class", "sidebar-menu-button", None, false), + Attribute::new("data-slot", "sidebar-menu-button", None, false), + Attribute::new("data-sidebar", "menu-button", None, false), + Attribute::new("data-size", size.as_str(), None, false), + Attribute::new("data-variant", variant.as_str(), None, false), + Attribute::new( + "data-active", + if is_active { "true" } else { "false" }, + None, + 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 { + disabled: hidden, + TooltipTrigger { + r#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: Vec = vec![ + Attribute::new("class", "sidebar-menu-action", None, false), + Attribute::new("data-slot", "sidebar-menu-action", None, false), + Attribute::new("data-sidebar", "menu-action", None, false), + Attribute::new( + "data-show-on-hover", + if show_on_hover { "true" } else { "false" }, + None, + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu-badge", None, false), + Attribute::new("data-slot", "sidebar-menu-badge", None, false), + Attribute::new("data-sidebar", "menu-badge", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu-skeleton", None, false), + Attribute::new("data-slot", "sidebar-menu-skeleton", None, false), + Attribute::new("data-sidebar", "menu-skeleton", None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + div { + ..merged, + if show_icon { + div { class: "skeleton sidebar-menu-skeleton-icon" } + } + div { class: "skeleton sidebar-menu-skeleton-text", width: "70%" } + } + } +} + +#[component] +pub fn SidebarMenuSub( + #[props(extends = GlobalAttributes)] attributes: Vec, + children: Element, +) -> Element { + let base: Vec = vec![ + Attribute::new("class", "sidebar-menu-sub", None, false), + Attribute::new("data-slot", "sidebar-menu-sub", None, false), + Attribute::new("data-sidebar", "menu-sub", None, false), + ]; + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu-sub-item", None, false), + Attribute::new("data-slot", "sidebar-menu-sub-item", None, false), + Attribute::new("data-sidebar", "menu-sub-item", None, false), + ]; + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + li { ..merged, {children} } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum SidebarMenuSubButtonSize { + Sm, + #[default] + Md, +} + +impl SidebarMenuSubButtonSize { + 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: Vec = vec![ + Attribute::new("class", "sidebar-menu-sub-button", None, false), + Attribute::new("data-slot", "sidebar-menu-sub-button", None, false), + Attribute::new("data-sidebar", "menu-sub-button", None, false), + Attribute::new("data-size", size.as_str(), None, false), + Attribute::new( + "data-active", + if is_active { "true" } else { "false" }, + None, + false, + ), + ]; + let merged = merge_attributes(vec![base, attributes]); + + if let Some(dynamic) = r#as { + dynamic.call(merged) + } else { + rsx! { + a { ..merged, {children} } + } + } +} diff --git a/preview/src/components/sidebar/mod.rs b/preview/src/components/sidebar/mod.rs new file mode 100644 index 00000000..2590c013 --- /dev/null +++ b/preview/src/components/sidebar/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/preview/src/components/sidebar/style.css b/preview/src/components/sidebar/style.css new file mode 100644 index 00000000..c3b44a6a --- /dev/null +++ b/preview/src/components/sidebar/style.css @@ -0,0 +1,769 @@ +/* TODO: abstract as Utilitiy class */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:root { + --sidebar-background: var(--primary-color-2); + --sidebar-foreground: var(--secondary-color-4); + --sidebar-border: var(--primary-color-6); + --sidebar-accent: var(--primary-color-4); + --sidebar-accent-foreground: var(--secondary-color-4); + --sidebar-ring: var(--primary-color-7); +} + +.sidebar-wrapper { + display: flex; + width: 100%; + height: 100svh; + min-height: 100svh; + overflow: hidden; +} + +@media (min-width: 768px) { + .sidebar-wrapper:has(.sidebar-desktop[data-side="right"]) { + flex-direction: row-reverse; + } + + .sidebar-wrapper:has(.sidebar-desktop[data-variant="inset"]) { + background: var(--sidebar-background); + } +} + +.sidebar-desktop { + display: none; + color: var(--sidebar-foreground); +} + +@media (min-width: 768px) { + .sidebar-desktop { + display: block; + } +} + +.sidebar-gap { + position: relative; + width: var(--sidebar-width); + background: transparent; + transition: width 200ms ease-out; +} + +[data-collapsible="icon"] .sidebar-gap { + width: var(--sidebar-width-icon); +} + +[data-variant="floating"] .sidebar-gap, +[data-variant="inset"] .sidebar-gap { + width: var(--sidebar-width); +} + +[data-variant="floating"][data-collapsible="icon"] .sidebar-gap, +[data-variant="inset"][data-collapsible="icon"] .sidebar-gap { + width: calc(var(--sidebar-width-icon) + 1rem); +} + +[data-collapsible="offcanvas"] .sidebar-gap { + width: 0; +} + +.sidebar-container { + position: fixed; + top: 0; + bottom: 0; + z-index: 10; + display: none; + width: var(--sidebar-width); + height: 100svh; + box-sizing: border-box; + transition: left 200ms ease-out, right 200ms ease-out, width 200ms ease-out; +} + +@media (min-width: 768px) { + .sidebar-container { + display: flex; + } +} + +[data-side="left"] .sidebar-container { + left: 0; +} + +[data-side="left"][data-collapsible="offcanvas"] .sidebar-container { + left: calc(var(--sidebar-width) * -1); +} + +[data-side="right"] .sidebar-container { + right: 0; +} + +[data-side="right"][data-collapsible="offcanvas"] .sidebar-container { + right: calc(var(--sidebar-width) * -1); +} + +[data-collapsible="icon"] .sidebar-container { + width: var(--sidebar-width-icon); +} + +[data-variant="sidebar"][data-side="left"] .sidebar-container { + border-right: 1px solid var(--sidebar-border); +} + +[data-variant="sidebar"][data-side="right"] .sidebar-container { + border-left: 1px solid var(--sidebar-border); +} + +[data-variant="floating"] .sidebar-container, +[data-variant="inset"] .sidebar-container { + padding: 0.5rem; +} + +[data-variant="floating"][data-collapsible="icon"] .sidebar-container, +[data-variant="inset"][data-collapsible="icon"] .sidebar-container { + width: calc(var(--sidebar-width-icon) + 1rem + 2px); +} + +.sidebar-inner { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + box-sizing: border-box; + background: var(--sidebar-background); +} + +[data-variant="floating"] .sidebar-inner { + border: 1px solid var(--sidebar-border); + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgb(0 0 0 / 10%); +} + +.sidebar-static { + display: flex; + flex-direction: column; + width: var(--sidebar-width); + height: 100%; + background: var(--sidebar-background); + color: var(--sidebar-foreground); +} + +.sidebar-sheet { + width: var(--sidebar-width-mobile) !important; + padding: 0 !important; + background: var(--sidebar-background); +} + +.sidebar-sheet > .sheet-close { + display: none; +} + +.sidebar-mobile-inner { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.sidebar-trigger { + width: 1.75rem; + height: 1.75rem; + padding: 0 !important; + + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} + +.sidebar-trigger-icon { + width: 1rem; + height: 1rem; +} + +.sidebar-rail { + position: absolute; + top: 0; + bottom: 0; + z-index: 20; + display: none; + width: 1rem; + padding: 0; + border: none; + background: transparent; + cursor: ew-resize; + transform: translateX(-50%); + transition: all 200ms ease-out; +} + +@media (min-width: 640px) { + .sidebar-rail { + display: flex; + } +} + +.sidebar-rail::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 2px; +} + +.sidebar-rail:hover::after { + background: var(--sidebar-border); +} + +[data-side="left"] .sidebar-rail { + right: -1rem; +} + +[data-side="left"] .sidebar-rail { + cursor: w-resize; +} + +[data-side="right"] .sidebar-rail { + left: 0; +} + +[data-side="right"] .sidebar-rail { + cursor: e-resize; +} + +[data-side="left"][data-state="collapsed"] .sidebar-rail { + cursor: e-resize; +} + +[data-side="right"][data-state="collapsed"] .sidebar-rail { + cursor: w-resize; +} + +[data-collapsible="offcanvas"] .sidebar-rail { + transform: translateX(0); +} + +[data-collapsible="offcanvas"] .sidebar-rail::after { + left: 100%; +} + +[data-collapsible="offcanvas"] .sidebar-rail:hover { + background: var(--sidebar-background); +} + +[data-side="left"][data-collapsible="offcanvas"] .sidebar-rail { + right: -0.5rem; +} + +[data-side="right"][data-collapsible="offcanvas"] .sidebar-rail { + left: -0.5rem; +} + +.sidebar-inset { + position: relative; + display: flex; + flex: 1 1 0%; + flex-direction: column; + width: 100%; + background: var(--primary-color-1); +} + +[data-variant="inset"]~.sidebar-inset { + margin: 0.5rem; + margin-left: 0; + border-radius: 0.75rem; + box-shadow: 0 1px 3px rgb(0 0 0 / 10%); +} + +[data-variant="inset"][data-state="collapsed"]~.sidebar-inset { + margin-left: 0.5rem; +} + +[data-variant="inset"][data-side="right"]~.sidebar-inset { + margin-left: 0.5rem; + margin-right: 0; +} + +[data-variant="inset"][data-side="right"][data-state="collapsed"]~.sidebar-inset { + margin-right: 0.5rem; +} + +.sidebar-header { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; +} + +.sidebar-content { + display: flex; + flex: 1 1 0%; + flex-direction: column; + gap: 0.5rem; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; +} + +[data-collapsible="icon"] .sidebar-content { + overflow: hidden; +} + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; +} + +.sidebar-separator { + width: auto; + margin: 0 0.5rem; + background: var(--sidebar-border); +} + +.sidebar-group { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.5rem; +} + +.sidebar-group-label { + display: flex; + align-items: center; + height: 2rem; + padding: 0 0.5rem; + border-radius: 0.375rem; + color: var(--sidebar-foreground); + font-size: 0.75rem; + font-weight: 500; + opacity: 0.7; + outline: none; + transition: margin 200ms ease-out, opacity 200ms ease-out; +} + +.sidebar-group-label svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +[data-collapsible="icon"] .sidebar-group-label { + margin-top: -2rem; + opacity: 0; +} + +.sidebar-group-action { + position: absolute; + top: 0.875rem; + right: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--sidebar-foreground); + cursor: pointer; + outline: none; + transition: transform 150ms ease-out; +} + +.sidebar-group-action:hover { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-group-action svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +/* Increase hit area on mobile */ +.sidebar-group-action::after { + content: ""; + position: absolute; + inset: -0.5rem; +} + +@media (min-width: 768px) { + .sidebar-group-action::after { + display: none; + } +} + +[data-collapsible="icon"] .sidebar-group-action { + display: none; +} + +.sidebar-group-content { + width: 100%; + font-size: 0.875rem; +} + +.sidebar-menu { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.sidebar-menu-item { + position: relative; +} + +.sidebar-menu-button[data-sidebar="menu-button"] { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + box-sizing: border-box; + padding: 0.5rem; + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--sidebar-foreground); + cursor: pointer; + font-size: 0.875rem; + outline: none; + overflow: hidden; + text-align: left; + text-decoration: none; + transition: width 200ms ease-out, height 200ms ease-out, padding 200ms ease-out; +} + +.sidebar-menu-button[data-sidebar="menu-button"]:hover { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-button[data-sidebar="menu-button"]:focus-visible { + box-shadow: 0 0 0 2px var(--sidebar-ring); +} + +.sidebar-menu-button[data-sidebar="menu-button"]:active { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-button[data-sidebar="menu-button"]:disabled, +.sidebar-menu-button[data-sidebar="menu-button"][aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-active="true"] { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); + font-weight: 500; +} + +.sidebar-menu-button[data-sidebar="menu-button"] svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sidebar-menu-button[data-sidebar="menu-button"]>span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Size variants */ +.sidebar-menu-button[data-sidebar="menu-button"][data-size="default"] { + height: 2rem; + font-size: 0.875rem; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"] { + height: 1.75rem; + font-size: 0.75rem; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] { + height: 3rem; + font-size: 0.875rem; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"] { + background: var(--primary-color-1); + box-shadow: 0 0 0 1px var(--sidebar-border); +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"]:hover { + background: var(--sidebar-accent); + box-shadow: 0 0 0 1px var(--sidebar-accent); +} + +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"] { + width: 2rem; + height: 2rem; + padding: 0.5rem; +} + +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] { + padding: 0; +} + +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img)), +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img)) { + justify-content: center; + gap: 0; +} + +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img))> :not(:first-child), +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img))> :not(:first-child) { + display: none; +} + +.sidebar-desktop[data-collapsible="icon"] .sidebar-menu-button[data-sidebar="menu-button"] svg { + display: block; +} + +.sidebar-menu-item:has(.sidebar-menu-action[data-sidebar="menu-action"]) .sidebar-menu-button[data-sidebar="menu-button"] { + padding-right: 2rem; +} + +.sidebar-menu-action[data-sidebar="menu-action"] { + position: absolute; + top: 0.375rem; + right: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--sidebar-foreground); + cursor: pointer; + outline: none; + transition: transform 150ms ease-out; +} + +.sidebar-menu-action[data-sidebar="menu-action"]:hover { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-action[data-sidebar="menu-action"] svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sidebar-menu-action[data-sidebar="menu-action"]::after { + content: ""; + position: absolute; + inset: -0.5rem; +} + +@media (min-width: 768px) { + .sidebar-menu-action[data-sidebar="menu-action"]::after { + display: none; + } +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"]~.sidebar-menu-action[data-sidebar="menu-action"] { + top: 0.25rem; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-size="default"]~.sidebar-menu-action[data-sidebar="menu-action"] { + top: 0.375rem; +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"]~.sidebar-menu-action[data-sidebar="menu-action"] { + top: 0.625rem; +} + +[data-collapsible="icon"] .sidebar-menu-action[data-sidebar="menu-action"] { + display: none; +} + +.sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] { + opacity: 0; +} + +@media (min-width: 768px) { + + .sidebar-menu-item:hover .sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"], + .sidebar-menu-item:focus-within .sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"], + .sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"][data-state="open"] { + opacity: 1; + } +} + +.sidebar-menu-button[data-sidebar="menu-button"][data-active="true"]~.sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] { + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-badge { + position: absolute; + right: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + border-radius: 0.375rem; + color: var(--sidebar-foreground); + font-size: 0.75rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + pointer-events: none; + user-select: none; +} + +.sidebar-menu-button:hover~.sidebar-menu-badge { + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-button[data-active="true"]~.sidebar-menu-badge { + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-button[data-size="sm"]~.sidebar-menu-badge { + top: 0.25rem; +} + +.sidebar-menu-button[data-size="default"]~.sidebar-menu-badge { + top: 0.375rem; +} + +.sidebar-menu-button[data-size="lg"]~.sidebar-menu-badge { + top: 0.625rem; +} + +[data-collapsible="icon"] .sidebar-menu-badge { + display: none; +} + +.sidebar-menu-skeleton { + display: flex; + align-items: center; + gap: 0.5rem; + height: 2rem; + padding: 0 0.5rem; + border-radius: 0.375rem; +} + +.sidebar-menu-skeleton-icon { + width: 1rem; + height: 1rem; + border-radius: 0.375rem; +} + +.sidebar-menu-skeleton-text { + flex: 1; + height: 1rem; +} + +.sidebar-menu-sub { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0 0.875rem; + padding: 0.125rem 0.625rem; + border-left: 1px solid var(--sidebar-border); + list-style: none; + transform: translateX(1px); +} + +[data-collapsible="icon"] .sidebar-menu-sub { + display: none; +} + +.sidebar-menu-sub-item { + position: relative; +} + +.sidebar-menu-sub-button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + box-sizing: border-box; + min-width: 0; + height: 1.75rem; + padding: 0 0.5rem; + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--sidebar-foreground); + cursor: pointer; + font-size: 0.875rem; + outline: none; + overflow: hidden; + text-decoration: none; + transform: translateX(-1px); +} + +.sidebar-menu-sub-button:hover { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-sub-button:focus-visible { + box-shadow: 0 0 0 2px var(--sidebar-ring); +} + +.sidebar-menu-sub-button:active { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-sub-button:disabled, +.sidebar-menu-sub-button[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +.sidebar-menu-sub-button[data-active="true"] { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-sub-button svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--sidebar-accent-foreground); +} + +.sidebar-menu-sub-button>span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-menu-sub-button[data-size="sm"] { + font-size: 0.75rem; +} + +.sidebar-menu-sub-button[data-size="md"] { + font-size: 0.875rem; +} + +[data-collapsible="icon"] .sidebar-menu-sub-button { + display: none; +} From 2d09b11f328cdc6f24188403d15657d3a95b18da Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:18:50 +0800 Subject: [PATCH 08/12] Add doc for sidebar --- preview/src/components/sidebar/docs.md | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 preview/src/components/sidebar/docs.md diff --git a/preview/src/components/sidebar/docs.md b/preview/src/components/sidebar/docs.md new file mode 100644 index 00000000..41b56920 --- /dev/null +++ b/preview/src/components/sidebar/docs.md @@ -0,0 +1,68 @@ +The Sidebar component is a vertical interface panel fixed to the screen edge, displaying navigation menus or filters that enable quick access to different sections of an application. + +## Component Structure + +```rust +// Provider: supplies open/side/collapsible signals and ⌘/Ctrl+B toggle +SidebarProvider { + Sidebar { + side: SidebarSide::Left, // left/right placement + variant: SidebarVariant::Sidebar, // chrome: Sidebar | Floating | Inset + collapsible: SidebarCollapsible::Offcanvas, // behavior: Offcanvas | Icon | None + + // Layout - Header + SidebarHeader { + SidebarTrigger {} // toggle button (r#as) + } + + // Layout - Scrollable content area + SidebarContent { + SidebarGroup { + SidebarGroupLabel { "..." } // optional label (r#as) + SidebarGroupAction { "..." } // optional action (r#as) + SidebarGroupContent { // wraps menus + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { // primary item (r#as) + is_active: true, // highlight state + tooltip: rsx!("..."),// Option; wraps tooltip only when Some + Icon {} // icon node + span { "..." } // text node + } + SidebarMenuAction { show_on_hover: true, Icon {} } // trailing action (r#as) + SidebarMenuBadge { "+..." } // optional badge + } + SidebarMenuItem { // nested submenu + SidebarMenuSub { + SidebarMenuSubItem { + SidebarMenuSubButton { "..." } // submenu button/link (r#as) + } + } + } + } + } + } + } + + // Layout - Footer + SidebarFooter { + SidebarMenu { /* ... */ } + } + } + + // Optional desktop rail controller placed between rail and content + SidebarRail {} // draggable resize handle + + // Layout - Main content area beside the rail + SidebarInset { /* ... */ } +} +``` + +## Behaviors +- Layout: `variant` adjusts chrome (`Floating/Inset` adds padding/radius); `side` selects left/right. +- Collapse: `Offcanvas` hides the rail; `Icon` keeps a thin icon strip and hides labels/actions/badges; `None` is static. +- Keyboard: ⌘/Ctrl+B toggles via provider. Focus rings are defined in `sidebar/style.css`; keep or replace with `:focus-visible` styles. +- Tooltips: `tooltip: Option` on `SidebarMenuButton`; `None` skips wrapping in Tooltip. + +## Custom Rendering with `r#as` +Supported components: `SidebarTrigger`, `SidebarGroupLabel`, `SidebarGroupAction`, `SidebarMenuButton`, `SidebarMenuAction`, `SidebarMenuSubButton`. Use `r#as: |attrs| rsx! { ... }` and spread `..attrs` to retain merged attributes, state data, and handlers. From f13fe2f62bdbe927b9a6c72aa6e623f9a82ad37c Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:20:58 +0800 Subject: [PATCH 09/12] Add block type component and its support of theme and preview --- preview/assets/dx-components-theme.css | 19 +- preview/assets/main.css | 312 ++++++++++++------------- preview/src/components/mod.rs | 132 ++++++++--- preview/src/main.rs | 228 ++++++++++++++++-- preview/src/theme.rs | 90 +++++++ 5 files changed, 553 insertions(+), 228 deletions(-) create mode 100644 preview/src/theme.rs diff --git a/preview/assets/dx-components-theme.css b/preview/assets/dx-components-theme.css index 6d51a267..e619f9f0 100644 --- a/preview/assets/dx-components-theme.css +++ b/preview/assets/dx-components-theme.css @@ -14,6 +14,16 @@ body { font-weight: 400; } +html[data-theme="dark"] { + --dark: initial; + --light: ; +} + +html[data-theme="light"] { + --dark: ; + --light: initial; +} + @media (prefers-color-scheme: dark) { :root { --dark: initial; @@ -56,12 +66,9 @@ body { --secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b); --primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626); --secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444); - --contrast-error-color: var(--dark, var(--secondary-color-3)) - var(--light, var(--primary-color)); - --primary-info-color: var(--dark, var(--primary-color-5)) - var(--light, var(--primary-color)); - --secondary-info-color: var(--dark, var(--primary-color-7)) - var(--light, var(--secondary-color-3)); + --contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color)); + --primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color)); + --secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3)); } /* Modern browsers with `scrollbar-*` support */ diff --git a/preview/assets/main.css b/preview/assets/main.css index f6c15a89..c85b863b 100644 --- a/preview/assets/main.css +++ b/preview/assets/main.css @@ -1,261 +1,249 @@ /* Theme style variables */ :root { - --highlight-color-main: #dd7230; - --highlight-color-secondary: #eb5160; - --highlight-color-tertiary: #2b7fff; -} - -html[data-theme="dark"] { - --dark: initial; - --light: ; -} - -html[data-theme="light"] { - --dark: ; - --light: initial; + --highlight-color-main: #dd7230; + --highlight-color-secondary: #eb5160; + --highlight-color-tertiary: #2b7fff; } html, body { - padding: 0; - margin: 0; - overflow-x: hidden; + padding: 0; + margin: 0; + overflow-x: hidden; } .dark-mode-only { - display: var(--dark, initial) var(--light, none); + display: var(--dark, initial) var(--light, none); } .light-mode-only { - display: var(--dark, none) var(--light, initial); + display: var(--dark, none) var(--light, initial); } .preview-navbar { - position: sticky; - z-index: 1000; - top: 0; - display: flex; - width: 100%; - box-sizing: border-box; - align-items: center; - justify-content: space-between; - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--primary-color-6); - background-color: var(--primary-color); + position: sticky; + z-index: 1000; + top: 0; + display: flex; + width: 100%; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--primary-color-6); + background-color: var(--primary-color); } .navbar-link { - padding: 0.5em 0; - color: var(--secondary-color-4); - font-size: 1.2em; - font-weight: bold; - text-decoration: none; + padding: 0.5em 0; + color: var(--secondary-color-4); + font-size: 1.2em; + font-weight: bold; + text-decoration: none; } .navbar-link:hover { - color: var(--secondary-color-3); + color: var(--secondary-color-3); } .navbar-links { - display: flex; - gap: 1rem; + display: flex; + gap: 1rem; } .navbar-brand { - display: flex; - align-items: center; - color: var(--secondary-color-4); - font-size: 1.5em; - font-weight: bold; - text-decoration: none; + display: flex; + align-items: center; + color: var(--secondary-color-4); + font-size: 1.5em; + font-weight: bold; + text-decoration: none; } /* Code block styles */ .code-block { - overflow: scroll; - width: 100%; - max-width: calc(100vw - 2rem); - height: 100%; - max-height: 80vh; - box-sizing: border-box; - padding: 0.5rem; - border-radius: 0.5rem; - margin: 0; - font-family: "Courier New", Courier, monospace; - white-space: pre; + overflow: scroll; + width: 100%; + max-width: calc(100vw - 2rem); + height: 100%; + max-height: 80vh; + box-sizing: border-box; + padding: 0.5rem; + border-radius: 0.5rem; + margin: 0; + font-family: "Courier New", Courier, monospace; + white-space: pre; } .dark-code-block { - display: var(--dark, block) var(--light, none); + display: var(--dark, block) var(--light, none); } .light-code-block { - display: var(--dark, none) var(--light, block); + display: var(--dark, none) var(--light, block); } .code-block[data-collapsed="true"] { - height: 20em; - backdrop-filter: blur(1px); - mask: linear-gradient( - to bottom, - rgb(0 0 0 / 100%) 0%, - rgb(0 0 0 / 100%) 60%, - rgb(0 0 0 / 0%) 100% - ); - user-select: none; + height: 20em; + backdrop-filter: blur(1px); + mask: linear-gradient(to bottom, + rgb(0 0 0 / 100%) 0%, + rgb(0 0 0 / 100%) 60%, + rgb(0 0 0 / 0%) 100%); + user-select: none; } .dark-mode-toggle { - border: none; - background: none; - color: var(--secondary-color-4); - cursor: pointer; + border: none; + background: none; + color: var(--secondary-color-4); + cursor: pointer; } .copy-button { - display: flex; - align-items: center; - justify-content: center; - padding: 0; - border: none; - border-radius: 0.5rem; - margin: 0; - background: none; - color: var(--secondary-color-4); - cursor: pointer; - gap: 0.5em; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 0.5rem; + margin: 0; + background: none; + color: var(--secondary-color-4); + cursor: pointer; + gap: 0.5em; } .copy-button:hover { - color: var(--highlight-color-tertiary); + color: var(--highlight-color-tertiary); } .copy-button[data-copied="true"] { - color: var(--secondary-success-color); + color: var(--secondary-success-color); } /* Demo frame styles */ .component-preview { - display: flex; - width: 100vw; - flex-direction: column; - align-items: center; - justify-content: center; + display: flex; + width: 100vw; + flex-direction: column; + align-items: center; + justify-content: center; } .component-preview-contents { - display: flex; - width: 75vw; - flex-direction: column; - align-items: center; - justify-content: center; - margin: 20px 0; + display: flex; + width: 75vw; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 20px 0; } .component-preview-frame { - display: flex; - width: 100%; - min-height: 20rem; - max-height: 100%; - box-sizing: border-box; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 20px; - border: 1px solid var(--primary-color-6); - border-radius: 0.5em; - background-color: var(--primary-color-2); + display: flex; + width: 100%; + min-height: 20rem; + max-height: 100%; + box-sizing: border-box; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + border: 1px solid var(--primary-color-6); + border-radius: 0.5em; + background-color: var(--primary-color-2); } /* component info styles */ .component-title { - width: 100%; - margin-top: 20px; - margin-bottom: 20px; - font-size: 2em; - text-align: center; - text-transform: capitalize; + width: 100%; + margin-top: 20px; + margin-bottom: 20px; + font-size: 2em; + text-align: center; + text-transform: capitalize; } .component-variants-title { - width: 100%; - margin-top: 20px; - margin-bottom: 20px; - font-size: 1.5em; - text-align: center; - text-transform: capitalize; + width: 100%; + margin-top: 20px; + margin-bottom: 20px; + font-size: 1.5em; + text-align: center; + text-transform: capitalize; } .component-description { - max-width: 90vw; - margin-right: 5vw; - margin-left: 5vw; - font-size: 1em; - overflow-x: scroll; + max-width: 90vw; + margin-right: 5vw; + margin-left: 5vw; + font-size: 1em; + overflow-x: scroll; } .component-installation { - width: 100%; - padding: 1rem; + width: 100%; + padding: 1rem; } .component-installation-list { - padding-left: 2rem; - list-style-type: decimal; + padding-left: 2rem; + list-style-type: decimal; } .component-installation-list li { - margin-bottom: 0.5rem; - font-size: 1em; + margin-bottom: 0.5rem; + font-size: 1em; } .component-code { - display: flex; - width: 100%; - min-height: 20rem; - max-height: 100%; - box-sizing: border-box; - flex-direction: column; - align-items: center; - justify-content: center; - padding-top: 2rem; - border-top-left-radius: 0.5em; - border-top-right-radius: 0.5em; + display: flex; + width: 100%; + min-height: 20rem; + max-height: 100%; + box-sizing: border-box; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 2rem; + border-top-left-radius: 0.5em; + border-top-right-radius: 0.5em; } /* Masonry styles */ .masonry-with-columns { - display: flex; - flex-wrap: wrap; - padding: 0 0 0 1rem; + display: flex; + flex-wrap: wrap; + padding: 0 0 0 1rem; } .masonry-with-columns .masonry-preview-frame { - display: flex; - max-width: calc(100vw - 2rem); - min-height: 10rem; - box-sizing: border-box; - flex: 1 0 auto; - flex-direction: column; - align-items: center; - padding: 3rem; - border: 1px solid var(--primary-color-6); - border-radius: 0.5rem; - margin: 0 1rem 1rem 0; - background-color: var(--primary-color-2); + display: flex; + max-width: calc(100vw - 2rem); + min-height: 10rem; + box-sizing: border-box; + flex: 1 0 auto; + flex-direction: column; + align-items: center; + padding: 3rem; + border: 1px solid var(--primary-color-6); + border-radius: 0.5rem; + margin: 0 1rem 1rem 0; + background-color: var(--primary-color-2); } .masonry-with-columns .masonry-component-frame { - display: flex; - width: 100%; - height: 100%; - flex-direction: column; - align-items: center; - justify-content: center; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; } /* Disable animations while the page is loading */ .preload * { - animation-duration: 0.001s !important; - transition: none !important; + animation-duration: 0.001s !important; + transition: none !important; } diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 8fb5d326..5c973fa8 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -1,6 +1,7 @@ -use super::{ComponentDemoData, ComponentVariantDemoData, HighlightedCode}; +use super::{ComponentDemoData, ComponentType, ComponentVariantDemoData, HighlightedCode}; + macro_rules! examples { - ($($name:ident $([$($variant:ident),*])?),*) => { + ($($name:ident $(($kind:ident))? $([$($variant:ident),*])?),* $(,)?) => { $( pub(crate) mod $name { pub(crate) mod component; @@ -16,46 +17,106 @@ macro_rules! examples { } } )* - pub (crate) static DEMOS: &[ComponentDemoData] = &[ + pub(crate) static DEMOS: &[ComponentDemoData] = &[ $( - ComponentDemoData { - name: stringify!($name), - docs: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/docs.html")), - component: HighlightedCode { - light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.light.html")), - dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.dark.html")), - }, - style: HighlightedCode { - light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.light.html")), - dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.dark.html")), + examples!(@demo $name $( $kind )? $([$($variant),*])?), + )* + ]; + }; + + (@kind) => { ComponentType::Normal }; + (@kind normal) => { ComponentType::Normal }; + (@kind block) => { ComponentType::Block }; + + // Normal components: no variant-level css_highlighted + (@demo $name:ident $([$($variant:ident),*])?) => { + ComponentDemoData { + name: stringify!($name), + r#type: ComponentType::Normal, + docs: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/docs.html")), + component: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.dark.html")), + }, + style: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.dark.html")), + }, + variants: &[ + ComponentVariantDemoData { + name: "main", + rs_highlighted: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.dark.html")), }, - variants: &[ + css_highlighted: None, + component: $name::variants::main::Demo, + }, + $( + $( ComponentVariantDemoData { - name: "main", + name: stringify!($variant), rs_highlighted: HighlightedCode { - light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.light.html")), - dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.dark.html")), + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.dark.html")), }, - component: $name::variants::main::Demo, + css_highlighted: None, + component: $name::variants::$variant::Demo, }, - $( - $( - ComponentVariantDemoData { - name: stringify!($variant), - rs_highlighted: HighlightedCode { - light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.light.html")), - dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.dark.html")), - }, - component: $name::variants::$variant::Demo, - }, - )* - )? - ] + )* + )? + ], + } + }; + + // Block components: require variants//style.css and expose it as css_highlighted + (@demo $name:ident block $([$($variant:ident),*])?) => { + ComponentDemoData { + name: stringify!($name), + r#type: ComponentType::Block, + docs: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/docs.html")), + component: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/component.rs.base16-ocean.dark.html")), + }, + style: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/style.css.base16-ocean.dark.html")), + }, + variants: &[ + ComponentVariantDemoData { + name: "main", + rs_highlighted: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/mod.rs.base16-ocean.dark.html")), + }, + css_highlighted: Some(HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/style.css.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/main/style.css.base16-ocean.dark.html")), + }), + component: $name::variants::main::Demo, }, - )* - ]; + $( + $( + ComponentVariantDemoData { + name: stringify!($variant), + rs_highlighted: HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/mod.rs.base16-ocean.dark.html")), + }, + css_highlighted: Some(HighlightedCode { + light: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/style.css.base16-ocean.light.html")), + dark: include_str!(concat!(env!("OUT_DIR"), "/", stringify!($name), "/variants/", stringify!($variant), "/style.css.base16-ocean.dark.html")), + }), + component: $name::variants::$variant::Demo, + }, + )* + )? + ], + } }; } + examples!( accordion, alert_dialog, @@ -81,8 +142,9 @@ examples!( scroll_area, select, separator, - skeleton, sheet, + sidebar(block), + skeleton, slider, switch, tabs, @@ -91,5 +153,5 @@ examples!( toggle_group, toggle, toolbar, - tooltip + tooltip, ); diff --git a/preview/src/main.rs b/preview/src/main.rs index d5f7c641..5c7ff7a3 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -10,10 +10,20 @@ use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; use unic_langid::{langid, LanguageIdentifier}; mod components; +mod theme; + +#[derive(Copy, Clone, PartialEq)] +enum ComponentType { + /// Normal componet as default. + Normal, + /// Component that render the preview inside an iframe for isolation. + Block, +} #[derive(Clone, PartialEq)] struct ComponentDemoData { name: &'static str, + r#type: ComponentType, docs: &'static str, component: HighlightedCode, style: HighlightedCode, @@ -25,6 +35,7 @@ struct ComponentDemoData { struct ComponentVariantDemoData { name: &'static str, rs_highlighted: HighlightedCode, + css_highlighted: Option, component: fn() -> Element, } @@ -70,6 +81,7 @@ fn App() -> Element { #[derive(Routable, Clone, PartialEq)] pub(crate) enum Route { + #[layout(AppLayout)] #[layout(NavigationLayout)] #[route("/?:iframe&:dark_mode")] Home { @@ -82,6 +94,13 @@ pub(crate) enum Route { iframe: Option, dark_mode: Option, }, + #[end_layout] + #[route("/component/block/?:name&:variant&:dark_mode")] + ComponentBlockDemo { + name: String, + variant: Option, + dark_mode: Option, + }, } impl Route { @@ -89,6 +108,7 @@ impl Route { match self { Route::Home { iframe, .. } => *iframe, Route::ComponentDemo { iframe, .. } => *iframe, + Route::ComponentBlockDemo { .. } => None, } } @@ -101,6 +121,7 @@ impl Route { match self { Route::Home { dark_mode, .. } => *dark_mode, Route::ComponentDemo { dark_mode, .. } => *dark_mode, + Route::ComponentBlockDemo { dark_mode, .. } => *dark_mode, } } @@ -137,13 +158,21 @@ async fn static_routes() -> Result, ServerFnError> { } #[component] -fn NavigationLayout() -> Element { +fn AppLayout() -> Element { use_effect(move || { + theme::theme_seed(); if let Some(dark_mode) = Route::in_dark_mode() { - set_theme(dark_mode); + theme::set_theme(dark_mode); } }); + rsx! { + Outlet:: {} + } +} + +#[component] +fn NavigationLayout() -> Element { // Send the route to the parent window if in an iframe let mut initial_route = use_hook(|| CopyValue::new(true)); use_effect(move || { @@ -337,20 +366,13 @@ fn CheckIcon() -> Element { } } -fn set_theme(dark_mode: bool) { - let theme = if dark_mode { "dark" } else { "light" }; - _ = document::eval(&format!( - "document.documentElement.setAttribute('data-theme', '{theme}');", - )); -} - #[component] fn DarkModeToggle() -> Element { rsx! { button { class: "dark-mode-toggle dark-mode-only", onclick: move |_| { - set_theme(false); + theme::set_theme(false); }, r#type: "button", aria_label: "Enable light mode", @@ -359,7 +381,7 @@ fn DarkModeToggle() -> Element { button { class: "dark-mode-toggle light-mode-only", onclick: move |_| { - set_theme(true); + theme::set_theme(true); }, r#type: "button", aria_label: "Enable dark mode", @@ -602,7 +624,11 @@ fn LanguageSelect() -> Element { } #[component] -fn ComponentCode(rs_highlighted: HighlightedCode, css_highlighted: HighlightedCode) -> Element { +fn ComponentCode( + rs_highlighted: HighlightedCode, + css_highlighted: HighlightedCode, + #[props(default = ComponentType::Normal)] component_type: ComponentType, +) -> Element { let mut collapsed = use_signal(|| true); let expand = rsx! { @@ -659,7 +685,9 @@ fn ComponentCode(rs_highlighted: HighlightedCode, css_highlighted: HighlightedCo TabList { TabTrigger { value: "main.rs", index: 0usize, "main.rs" } TabTrigger { value: "style.css", index: 1usize, "style.css" } - TabTrigger { value: "dx-components-theme.css", index: 2usize, "dx-components-theme.css" } + if component_type != ComponentType::Block { + TabTrigger { value: "dx-components-theme.css", index: 2usize, "dx-components-theme.css" } + } } div { width: "100%", @@ -684,13 +712,15 @@ fn ComponentCode(rs_highlighted: HighlightedCode, css_highlighted: HighlightedCo CodeBlock { source: css_highlighted, collapsed: collapsed() } {expand.clone()} } - TabContent { - index: 2usize, - value: "dx-components-theme.css", - width: "100%", - position: "relative", - CodeBlock { source: THEME_CSS, collapsed: collapsed() } - {expand.clone()} + if component_type != ComponentType::Block { + TabContent { + index: 2usize, + value: "dx-components-theme.css", + width: "100%", + position: "relative", + CodeBlock { source: THEME_CSS, collapsed: collapsed() } + {expand.clone()} + } } } } @@ -789,6 +819,7 @@ fn ComponentDemo(iframe: Option, dark_mode: Option, name: String) -> fn ComponentHighlight(demo: ComponentDemoData) -> Element { let ComponentDemoData { name: raw_name, + r#type, docs, variants, component, @@ -804,7 +835,14 @@ fn ComponentHighlight(demo: ComponentDemoData) -> Element { h1 { class: "component-title", "{name}" } div { class: "component-preview", div { class: "component-preview-contents", - ComponentVariantHighlight { variant: main.clone(), main_variant: true } + match r#type { + ComponentType::Normal => rsx! { + ComponentVariantHighlight { variant: main.clone(), main_variant: true } + }, + ComponentType::Block => rsx! { + BlockComponentVariantHighlight { variant: main.clone(), main_variant: true, component_name: raw_name } + }, + } div { class: "component-installation", h2 { "Installation" } Tabs { @@ -848,7 +886,14 @@ fn ComponentHighlight(demo: ComponentDemoData) -> Element { if !variants.is_empty() { h2 { class: "component-variants-title", "Variants" } for variant in variants { - ComponentVariantHighlight { variant: variant.clone(), main_variant: false } + match r#type { + ComponentType::Normal => rsx! { + ComponentVariantHighlight { variant: variant.clone(), main_variant: false } + }, + ComponentType::Block => rsx! { + BlockComponentVariantHighlight { variant: variant.clone(), main_variant: false, component_name: raw_name } + }, + } } } } @@ -868,7 +913,11 @@ fn ManualComponentInstallation(component: HighlightedCode, style: HighlightedCod li { "Create a component based on the main.rs below." } li { "Modify your components and styles as needed." } } - ComponentCode { rs_highlighted: component, css_highlighted: style } + ComponentCode { + rs_highlighted: component, + css_highlighted: style, + component_type: ComponentType::Normal, + } } } @@ -915,6 +964,7 @@ fn ComponentVariantHighlight(variant: ComponentVariantDemoData, main_variant: bo let ComponentVariantDemoData { name, rs_highlighted: highlighted, + css_highlighted: _, component: Comp, } = variant; rsx! { @@ -961,6 +1011,116 @@ fn ComponentVariantHighlight(variant: ComponentVariantDemoData, main_variant: bo } } +#[component] +fn BlockComponentVariantHighlight( + component_name: &'static str, + variant: ComponentVariantDemoData, + main_variant: bool, +) -> Element { + let ComponentVariantDemoData { + name, + rs_highlighted: highlighted, + css_highlighted, + component: _, + } = variant; + + let href = Route::ComponentBlockDemo { + name: component_name.to_string(), + variant: Some(name.to_string()), + dark_mode: Route::in_dark_mode(), + } + .to_string(); + + rsx! { + if !main_variant { + h3 { "{name}" } + } + Tabs { + default_value: "Preview", + border_bottom_left_radius: "0.5rem", + border_bottom_right_radius: "0.5rem", + horizontal: true, + width: "100%", + variant: TabsVariant::Ghost, + TabList { + TabTrigger { value: "Preview", index: 0usize, "PREVIEW" } + TabTrigger { value: "Code", index: 1usize, "CODE" } + } + div { + width: "100%", + height: "100%", + display: "flex", + flex_direction: "column", + justify_content: "center", + align_items: "center", + TabContent { + index: 0usize, + id: "component-preview-frame", + value: "Preview", + width: "100%", + position: "relative", + iframe { + src: "{href}", + width: "100%", + height: "600px", + border: "1px solid var(--primary-color-6)", + border_radius: "0.5em", + } + } + TabContent { + index: 1usize, + value: "Code", + width: "100%", + position: "relative", + if let Some(css) = css_highlighted { + ComponentCode { + rs_highlighted: highlighted, + css_highlighted: css, + component_type: ComponentType::Block, + } + } else { + ColapsibleCodeBlock { highlighted } + } + } + } + } + } +} + +#[component] +fn ComponentBlockDemo(name: String, variant: Option, dark_mode: Option) -> Element { + let Some(demo) = components::DEMOS.iter().find(|d| d.name == name).cloned() else { + return rsx! { + div { "Block component not found" } + }; + }; + + let variant = match variant.as_deref() { + Some(wanted) => match demo.variants.iter().find(|v| v.name == wanted) { + Some(v) => v, + None => { + return rsx! { + div { + style: "min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem;", + "Variant content not found: {wanted}" + } + }; + } + }, + None => &demo.variants[0], + }; + + let Comp = variant.component; + + rsx! { + document::Link { + rel: "stylesheet", + href: asset!("/assets/dx-components-theme.css"), + } + div { style: "min-height: 100vh;", Comp {} } + } +} + #[component] fn Home(iframe: Option, dark_mode: Option) -> Element { let mut search = use_signal(String::new); @@ -1044,9 +1204,27 @@ fn ComponentGallery(search: String) -> Element { #[component] fn ComponentGalleryPreview(component: ComponentDemoData) -> Element { - let ComponentDemoData { name, variants, .. } = component; + let ComponentDemoData { + name, + r#type, + variants, + .. + } = component; + let first_variant = &variants[0]; let Comp = first_variant.component; + + let preview = match r#type { + ComponentType::Normal => rsx! { + Comp {} + }, + ComponentType::Block => rsx! { + div { style: "display: flex; align-items: center; justify-content: center; height: 150px; color: var(--secondary-color-4);", + "Click to view full preview" + } + }, + }; + rsx! { div { class: "masonry-preview-frame", position: "relative", h3 { class: "component-title", {name.replace("_", " ")} } @@ -1059,7 +1237,7 @@ fn ComponentGalleryPreview(component: ComponentDemoData) -> Element { aria_label: "{name} details", to: Route::component(name), } - div { class: "masonry-component-frame", Comp {} } + div { class: "masonry-component-frame", {preview} } } } } diff --git a/preview/src/theme.rs b/preview/src/theme.rs new file mode 100644 index 00000000..3e0a043a --- /dev/null +++ b/preview/src/theme.rs @@ -0,0 +1,90 @@ +use dioxus::prelude::*; + +const COOKIE_NAME: &str = "dx_theme"; +const CHANNEL_NAME: &str = "dx-theme"; + +pub fn theme_seed() { + _ = document::eval(&format!( + r#" + (function () {{ + if (window.__dx_theme_seeded) return; + window.__dx_theme_seeded = true; + + const COOKIE_NAME = '{cookie_name}'; + const CHANNEL_NAME = '{channel_name}'; + + function getCookie(name) {{ + const prefix = name + '='; + const parts = document.cookie.split(';'); + for (let p of parts) {{ + p = p.trim(); + if (p.startsWith(prefix)) return decodeURIComponent(p.slice(prefix.length)); + }} + return null; + }} + + function apply(theme) {{ + if (theme === 'dark' || theme === 'light') {{ + document.documentElement.setAttribute('data-theme', theme); + }} else {{ + document.documentElement.removeAttribute('data-theme'); + }} + }} + + apply(getCookie(COOKIE_NAME)); + + try {{ + const ch = new BroadcastChannel(CHANNEL_NAME); + ch.addEventListener('message', (event) => {{ + const data = event.data; + apply(data && data.theme); + }}); + window.__dx_theme_channel = ch; + }} catch (_) {{}} + }})(); + "#, + cookie_name = COOKIE_NAME, + channel_name = CHANNEL_NAME + )); +} + +pub fn set_theme(dark_mode: bool) { + let theme = if dark_mode { "dark" } else { "light" }; + + _ = document::eval(&format!( + r#" + (function () {{ + const COOKIE_NAME = '{cookie_name}'; + const CHANNEL_NAME = '{channel_name}'; + + function getCookie(name) {{ + const prefix = name + '='; + const parts = document.cookie.split(';'); + for (let p of parts) {{ + p = p.trim(); + if (p.startsWith(prefix)) return decodeURIComponent(p.slice(prefix.length)); + }} + return null; + }} + + document.documentElement.setAttribute('data-theme', '{theme}'); + if (getCookie(COOKIE_NAME) === '{theme}') return; + + document.cookie = '{cookie_name}={theme}; path=/; max-age=31536000; samesite=lax'; + + try {{ + const ch = window.__dx_theme_channel; + if (ch && typeof ch.postMessage === 'function') {{ + ch.postMessage({{ theme: '{theme}' }}); + }} else {{ + const tmp = new BroadcastChannel(CHANNEL_NAME); + tmp.postMessage({{ theme: '{theme}' }}); + tmp.close(); + }} + }} catch (_) {{}} + }})(); + "#, + cookie_name = COOKIE_NAME, + channel_name = CHANNEL_NAME + )); +} From 50c7afbbb4a00e181a6a65c488593f704fab0f2c Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:32:33 +0800 Subject: [PATCH 10/12] Add preview for sidebar --- component.json | 3 +- preview/src/components/mod.rs | 2 +- .../sidebar/variants/floating/mod.rs | 538 ++++++++++++++++++ .../sidebar/variants/floating/style.css | 118 ++++ .../components/sidebar/variants/inset/mod.rs | 538 ++++++++++++++++++ .../sidebar/variants/inset/style.css | 118 ++++ .../components/sidebar/variants/main/mod.rs | 538 ++++++++++++++++++ .../sidebar/variants/main/style.css | 118 ++++ 8 files changed, 1971 insertions(+), 2 deletions(-) create mode 100644 preview/src/components/sidebar/variants/floating/mod.rs create mode 100644 preview/src/components/sidebar/variants/floating/style.css create mode 100644 preview/src/components/sidebar/variants/inset/mod.rs create mode 100644 preview/src/components/sidebar/variants/inset/style.css create mode 100644 preview/src/components/sidebar/variants/main/mod.rs create mode 100644 preview/src/components/sidebar/variants/main/style.css diff --git a/component.json b/component.json index c84d50e3..c25c4bea 100644 --- a/component.json +++ b/component.json @@ -37,6 +37,7 @@ "preview/src/components/textarea", "preview/src/components/skeleton", "preview/src/components/card", - "preview/src/components/sheet" + "preview/src/components/sheet", + "preview/src/components/sidebar" ] } diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 5c973fa8..cc9c186e 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -143,7 +143,7 @@ examples!( select, separator, sheet, - sidebar(block), + sidebar(block)[floating, inset], skeleton, slider, switch, diff --git a/preview/src/components/sidebar/variants/floating/mod.rs b/preview/src/components/sidebar/variants/floating/mod.rs new file mode 100644 index 00000000..88628f40 --- /dev/null +++ b/preview/src/components/sidebar/variants/floating/mod.rs @@ -0,0 +1,538 @@ +use crate::components::avatar::{Avatar, AvatarFallback, AvatarImage, AvatarImageSize}; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; +use crate::components::dropdown_menu::{ + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +}; +use crate::components::separator::Separator; +use crate::components::sidebar::{ + Sidebar, SidebarCollapsible, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, + SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, + SidebarMenuButton, SidebarMenuButtonSize, SidebarMenuItem, SidebarMenuSub, + SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSide, + SidebarTrigger, SidebarVariant, +}; +use crate::components::skeleton::Skeleton; +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +struct Team { + name: &'static str, + plan: &'static str, +} + +#[derive(Clone, PartialEq)] +struct NavMainItem { + title: &'static str, + url: &'static str, + is_active: bool, + items: &'static [SubItem], +} + +#[derive(Clone, PartialEq)] +struct SubItem { + title: &'static str, + url: &'static str, +} + +#[derive(Clone, PartialEq)] +struct Project { + name: &'static str, + url: &'static str, +} + +const TEAMS: &[Team] = &[ + Team { + name: "Acme Inc", + plan: "Enterprise", + }, + Team { + name: "Acme Corp.", + plan: "Startup", + }, + Team { + name: "Evil Corp.", + plan: "Free", + }, +]; + +const NAV_MAIN: &[NavMainItem] = &[ + NavMainItem { + title: "Playground", + url: "#", + is_active: true, + items: &[ + SubItem { + title: "History", + url: "#", + }, + SubItem { + title: "Starred", + url: "#", + }, + SubItem { + title: "Settings", + url: "#", + }, + ], + }, + NavMainItem { + title: "Models", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Genesis", + url: "#", + }, + SubItem { + title: "Explorer", + url: "#", + }, + SubItem { + title: "Quantum", + url: "#", + }, + ], + }, + NavMainItem { + title: "Documentation", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Introduction", + url: "#", + }, + SubItem { + title: "Get Started", + url: "#", + }, + SubItem { + title: "Tutorials", + url: "#", + }, + SubItem { + title: "Changelog", + url: "#", + }, + ], + }, + NavMainItem { + title: "Settings", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "General", + url: "#", + }, + SubItem { + title: "Team", + url: "#", + }, + SubItem { + title: "Billing", + url: "#", + }, + SubItem { + title: "Limits", + url: "#", + }, + ], + }, +]; + +const PROJECTS: &[Project] = &[ + Project { + name: "Design Engineering", + url: "#", + }, + Project { + name: "Sales & Marketing", + url: "#", + }, + Project { + name: "Travel", + url: "#", + }, +]; + +#[component] +pub fn Demo() -> Element { + let side = use_signal(|| SidebarSide::Left); + let collapsible = use_signal(|| SidebarCollapsible::Offcanvas); + + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + SidebarProvider { + Sidebar { + variant: SidebarVariant::Floating, + collapsible: collapsible(), + side: side(), + SidebarHeader { + TeamSwitcher { teams: TEAMS } + } + SidebarContent { + NavMain { items: NAV_MAIN } + NavProjects { projects: PROJECTS } + } + SidebarFooter { NavUser {} } + SidebarRail {} + } + SidebarInset { + header { style: "display:flex; align-items:center; justify-content:space-between; height:3.5rem; flex-shrink:0; padding:0 1rem; border-bottom:1px solid var(--sidebar-border); background:var(--primary-color-1);", + div { style: "display: flex; align-items: center; gap: 0.75rem;", + SidebarTrigger {} + Separator { height: "1rem", horizontal: false } + span { "Sidebar Setting" } + } + } + div { style: "display:flex; flex:1; flex-direction:column; gap:1.5rem; padding:1.5rem; min-height:0; overflow-y:auto; overflow-x:hidden;", + DemoSettingControls { side, collapsible } + Skeleton { style: "height: 10rem; width: 100%; flex-shrink:0;" } + Skeleton { style: "height: 20rem; width: 100%; flex-shrink:0;" } + } + } + } + } +} + +#[component] +fn TeamSwitcher(teams: &'static [Team]) -> Element { + let mut active_team = use_signal(|| 0usize); + + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + div { style: "display:flex; flex-shrink:0; align-items:center; justify-content:center; width:2rem; height:2rem; aspect-ratio:1; border-radius:0.5rem; background:var(--sidebar-accent); color:var(--sidebar-accent-foreground);", + Icon {} + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", {teams[active_team()].name} } + span { class: "sidebar-info-subtitle", {teams[active_team()].plan} } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "padding:0.5rem; font-size:0.75rem; opacity:0.7;", + "Teams" + } + for (idx , team) in teams.iter().enumerate() { + DropdownMenuItem { + index: idx, + value: idx, + on_select: move |v: usize| active_team.set(v), + Icon {} + {team.name} + span { style: "margin-left:auto; font-size:0.75rem; opacity:0.7;", + "⌘{idx + 1}" + } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: teams.len(), + value: 999usize, + on_select: move |_: usize| {}, + Icon {} + div { style: "opacity:0.7; font-weight:500;", "Add team" } + } + } + } + } + } + } +} + +#[component] +fn NavMain(items: &'static [NavMainItem]) -> Element { + rsx! { + SidebarGroup { + SidebarGroupLabel { "Platform" } + SidebarMenu { + for item in items.iter() { + Collapsible { + default_open: item.is_active, + r#as: move |attributes: Vec| rsx! { + SidebarMenuItem { key: "{item.title}", attributes, + CollapsibleTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { + tooltip: rsx! { + {item.title} + }, + attributes, + Icon {} + span { {item.title} } + ChevronIcon {} + } + }, + } + CollapsibleContent { + SidebarMenuSub { + for sub_item in item.items { + SidebarMenuSubItem { key: "{sub_item.title}", + SidebarMenuSubButton { + r#as: move |attributes: Vec| rsx! { + a { href: sub_item.url, ..attributes, + span { {sub_item.title} } + } + }, + } + } + } + } + } + } + }, + } + } + } + } + } +} + +#[component] +fn NavProjects(projects: &'static [Project]) -> Element { + rsx! { + SidebarGroup { class: "sidebar-hide-on-collapse", + SidebarGroupLabel { "Projects" } + SidebarMenu { + for project in projects.iter() { + SidebarMenuItem { key: "{project.name}", + SidebarMenuButton { + r#as: move |attributes: Vec| rsx! { + a { href: project.url, ..attributes, + Icon {} + span { {project.name} } + } + }, + } + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuAction { show_on_hover: true, attributes, + Icon {} + span { class: "sr-only", "More" } + } + }, + } + DropdownMenuContent { + DropdownMenuItem { + index: 0usize, + value: "view".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "View Project" } + } + DropdownMenuItem { + index: 1usize, + value: "share".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Share Project" } + } + Separator { decorative: true } + DropdownMenuItem { + index: 2usize, + value: "delete".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Delete Project" } + } + } + } + } + } + SidebarMenuItem { + SidebarMenuButton { style: "opacity:0.7; font-weight:500;", + Icon {} + span { "More" } + } + SidebarMenuBadge { "+99" } + } + } + } + } +} + +#[component] +fn NavUser() -> Element { + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + Avatar { size: AvatarImageSize::Small, style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "display:flex; align-items:center; gap:0.5rem; padding:0.375rem 0.25rem; text-align:left; font-size:0.875rem;", + Avatar { + size: AvatarImageSize::Small, + style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: 0usize, + value: "upgrade".to_string(), + on_select: move |_: String| {}, + Icon {} + "Upgrade to Pro" + } + Separator { decorative: true } + DropdownMenuItem { + index: 1usize, + value: "account".to_string(), + on_select: move |_: String| {}, + Icon {} + "Account" + } + DropdownMenuItem { + index: 2usize, + value: "billing".to_string(), + on_select: move |_: String| {}, + Icon {} + "Billing" + } + DropdownMenuItem { + index: 3usize, + value: "notifications".to_string(), + on_select: move |_: String| {}, + Icon {} + "Notifications" + } + Separator { decorative: true } + DropdownMenuItem { + index: 4usize, + value: "logout".to_string(), + on_select: move |_: String| {}, + Icon {} + "Log out" + } + } + } + } + } + } +} + +#[component] +fn DemoSettingControls( + side: Signal, + collapsible: Signal, +) -> Element { + rsx! { + div { style: "display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--sidebar-border); border-radius: 0.75rem; background: var(--primary-color-2);", + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Side" + } + div { style: "display: inline-flex; gap: 0.5rem;", + Button { + variant: if side() == SidebarSide::Left { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Left), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Left" + } + Button { + variant: if side() == SidebarSide::Right { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Right), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Right" + } + } + } + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Collapse" + } + div { style: "display: inline-flex; gap: 0.5rem; flex-wrap: wrap;", + Button { + variant: if collapsible() == SidebarCollapsible::Offcanvas { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Offcanvas), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Offcanvas" + } + Button { + variant: if collapsible() == SidebarCollapsible::Icon { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Icon), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Icon" + } + Button { + variant: if collapsible() == SidebarCollapsible::None { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::None), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "None" + } + } + } + } + } +} + +#[component] +fn Icon(#[props(default = "sidebar-icon")] class: &'static str) -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class, + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "10" } + } + } +} + +#[component] +fn ChevronIcon() -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class: "sidebar-icon sidebar-chevron", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "m9 18 6-6-6-6" } + } + } +} diff --git a/preview/src/components/sidebar/variants/floating/style.css b/preview/src/components/sidebar/variants/floating/style.css new file mode 100644 index 00000000..b250e85d --- /dev/null +++ b/preview/src/components/sidebar/variants/floating/style.css @@ -0,0 +1,118 @@ +.sidebar-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sidebar-info-block { + display: grid; + flex: 1; + min-width: 0; + text-align: left; + font-size: 0.875rem; + line-height: 1.25; +} + +.sidebar-info-title, +.sidebar-info-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-info-title { + font-weight: 600; +} + +.sidebar-info-subtitle { + font-size: 0.75rem; + opacity: 0.7; +} + +.sidebar-chevron { + margin-left: auto; + transition: transform 200ms ease-out; +} + +.sidebar-menu-button[data-open="true"] .sidebar-chevron { + transform: rotate(90deg); +} + +[data-collapsible="icon"] .sidebar-hide-on-collapse { + display: none; +} + +.sidebar-menu :is(.dropdown-menu, .tooltip) { + display: block; + width: 100%; +} + +.dropdown-menu-content .separator[data-orientation="horizontal"] { + margin: 0.25rem 0; +} + +.sidebar-menu-item>.dropdown-menu:has(.sidebar-menu-action) { + position: static; +} + +.sidebar-header .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible), +.sidebar-footer .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible) { + box-shadow: none; +} + +.sidebar-sheet .sidebar-header .dropdown-menu-content { + top: 100%; + bottom: auto; + left: 0; + right: auto; + margin-top: 4px; + margin-bottom: 0; +} + +.sidebar-sheet .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 100%; + left: 0; + right: auto; + margin-top: 0; + margin-bottom: 4px; +} + +.sidebar-menu-button[data-sidebar="menu-button"].collapsible-trigger:hover { + text-decoration: none; + text-decoration-line: none; +} + +@media (min-width: 768px) { + .sidebar-desktop[data-side="left"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + left: 100%; + margin-top: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="left"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + left: 100%; + margin-bottom: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="right"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: 0.5rem; + } + + .sidebar-desktop[data-side="right"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + right: 100%; + left: auto; + margin-bottom: 0; + margin-right: 0.5rem; + } +} diff --git a/preview/src/components/sidebar/variants/inset/mod.rs b/preview/src/components/sidebar/variants/inset/mod.rs new file mode 100644 index 00000000..9e3a8c04 --- /dev/null +++ b/preview/src/components/sidebar/variants/inset/mod.rs @@ -0,0 +1,538 @@ +use crate::components::avatar::{Avatar, AvatarFallback, AvatarImage, AvatarImageSize}; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; +use crate::components::dropdown_menu::{ + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +}; +use crate::components::separator::Separator; +use crate::components::sidebar::{ + Sidebar, SidebarCollapsible, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, + SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, + SidebarMenuButton, SidebarMenuButtonSize, SidebarMenuItem, SidebarMenuSub, + SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSide, + SidebarTrigger, SidebarVariant, +}; +use crate::components::skeleton::Skeleton; +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +struct Team { + name: &'static str, + plan: &'static str, +} + +#[derive(Clone, PartialEq)] +struct NavMainItem { + title: &'static str, + url: &'static str, + is_active: bool, + items: &'static [SubItem], +} + +#[derive(Clone, PartialEq)] +struct SubItem { + title: &'static str, + url: &'static str, +} + +#[derive(Clone, PartialEq)] +struct Project { + name: &'static str, + url: &'static str, +} + +const TEAMS: &[Team] = &[ + Team { + name: "Acme Inc", + plan: "Enterprise", + }, + Team { + name: "Acme Corp.", + plan: "Startup", + }, + Team { + name: "Evil Corp.", + plan: "Free", + }, +]; + +const NAV_MAIN: &[NavMainItem] = &[ + NavMainItem { + title: "Playground", + url: "#", + is_active: true, + items: &[ + SubItem { + title: "History", + url: "#", + }, + SubItem { + title: "Starred", + url: "#", + }, + SubItem { + title: "Settings", + url: "#", + }, + ], + }, + NavMainItem { + title: "Models", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Genesis", + url: "#", + }, + SubItem { + title: "Explorer", + url: "#", + }, + SubItem { + title: "Quantum", + url: "#", + }, + ], + }, + NavMainItem { + title: "Documentation", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Introduction", + url: "#", + }, + SubItem { + title: "Get Started", + url: "#", + }, + SubItem { + title: "Tutorials", + url: "#", + }, + SubItem { + title: "Changelog", + url: "#", + }, + ], + }, + NavMainItem { + title: "Settings", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "General", + url: "#", + }, + SubItem { + title: "Team", + url: "#", + }, + SubItem { + title: "Billing", + url: "#", + }, + SubItem { + title: "Limits", + url: "#", + }, + ], + }, +]; + +const PROJECTS: &[Project] = &[ + Project { + name: "Design Engineering", + url: "#", + }, + Project { + name: "Sales & Marketing", + url: "#", + }, + Project { + name: "Travel", + url: "#", + }, +]; + +#[component] +pub fn Demo() -> Element { + let side = use_signal(|| SidebarSide::Left); + let collapsible = use_signal(|| SidebarCollapsible::Offcanvas); + + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + SidebarProvider { + Sidebar { + variant: SidebarVariant::Inset, + collapsible: collapsible(), + side: side(), + SidebarHeader { + TeamSwitcher { teams: TEAMS } + } + SidebarContent { + NavMain { items: NAV_MAIN } + NavProjects { projects: PROJECTS } + } + SidebarFooter { NavUser {} } + SidebarRail {} + } + SidebarInset { + header { style: "display:flex; align-items:center; justify-content:space-between; height:3.5rem; flex-shrink:0; padding:0 1rem; border-bottom:1px solid var(--sidebar-border); background:var(--primary-color-1);", + div { style: "display: flex; align-items: center; gap: 0.75rem;", + SidebarTrigger {} + Separator { height: "1rem", horizontal: false } + span { "Sidebar Setting" } + } + } + div { style: "display:flex; flex:1; flex-direction:column; gap:1.5rem; padding:1.5rem; min-height:0; overflow-y:auto; overflow-x:hidden;", + DemoSettingControls { side, collapsible } + Skeleton { style: "height: 10rem; width: 100%; flex-shrink:0;" } + Skeleton { style: "height: 20rem; width: 100%; flex-shrink:0;" } + } + } + } + } +} + +#[component] +fn TeamSwitcher(teams: &'static [Team]) -> Element { + let mut active_team = use_signal(|| 0usize); + + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + div { style: "display:flex; flex-shrink:0; align-items:center; justify-content:center; width:2rem; height:2rem; aspect-ratio:1; border-radius:0.5rem; background:var(--sidebar-accent); color:var(--sidebar-accent-foreground);", + Icon {} + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", {teams[active_team()].name} } + span { class: "sidebar-info-subtitle", {teams[active_team()].plan} } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "padding:0.5rem; font-size:0.75rem; opacity:0.7;", + "Teams" + } + for (idx , team) in teams.iter().enumerate() { + DropdownMenuItem { + index: idx, + value: idx, + on_select: move |v: usize| active_team.set(v), + Icon {} + {team.name} + span { style: "margin-left:auto; font-size:0.75rem; opacity:0.7;", + "⌘{idx + 1}" + } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: teams.len(), + value: 999usize, + on_select: move |_: usize| {}, + Icon {} + div { style: "opacity:0.7; font-weight:500;", "Add team" } + } + } + } + } + } + } +} + +#[component] +fn NavMain(items: &'static [NavMainItem]) -> Element { + rsx! { + SidebarGroup { + SidebarGroupLabel { "Platform" } + SidebarMenu { + for item in items.iter() { + Collapsible { + default_open: item.is_active, + r#as: move |attributes: Vec| rsx! { + SidebarMenuItem { key: "{item.title}", attributes, + CollapsibleTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { + tooltip: rsx! { + {item.title} + }, + attributes, + Icon {} + span { {item.title} } + ChevronIcon {} + } + }, + } + CollapsibleContent { + SidebarMenuSub { + for sub_item in item.items { + SidebarMenuSubItem { key: "{sub_item.title}", + SidebarMenuSubButton { + r#as: move |attributes: Vec| rsx! { + a { href: sub_item.url, ..attributes, + span { {sub_item.title} } + } + }, + } + } + } + } + } + } + }, + } + } + } + } + } +} + +#[component] +fn NavProjects(projects: &'static [Project]) -> Element { + rsx! { + SidebarGroup { class: "sidebar-hide-on-collapse", + SidebarGroupLabel { "Projects" } + SidebarMenu { + for project in projects.iter() { + SidebarMenuItem { key: "{project.name}", + SidebarMenuButton { + r#as: move |attributes: Vec| rsx! { + a { href: project.url, ..attributes, + Icon {} + span { {project.name} } + } + }, + } + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuAction { show_on_hover: true, attributes, + Icon {} + span { class: "sr-only", "More" } + } + }, + } + DropdownMenuContent { + DropdownMenuItem { + index: 0usize, + value: "view".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "View Project" } + } + DropdownMenuItem { + index: 1usize, + value: "share".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Share Project" } + } + Separator { decorative: true } + DropdownMenuItem { + index: 2usize, + value: "delete".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Delete Project" } + } + } + } + } + } + SidebarMenuItem { + SidebarMenuButton { style: "opacity:0.7; font-weight:500;", + Icon {} + span { "More" } + } + SidebarMenuBadge { "+99" } + } + } + } + } +} + +#[component] +fn NavUser() -> Element { + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + Avatar { size: AvatarImageSize::Small, style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "display:flex; align-items:center; gap:0.5rem; padding:0.375rem 0.25rem; text-align:left; font-size:0.875rem;", + Avatar { + size: AvatarImageSize::Small, + style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: 0usize, + value: "upgrade".to_string(), + on_select: move |_: String| {}, + Icon {} + "Upgrade to Pro" + } + Separator { decorative: true } + DropdownMenuItem { + index: 1usize, + value: "account".to_string(), + on_select: move |_: String| {}, + Icon {} + "Account" + } + DropdownMenuItem { + index: 2usize, + value: "billing".to_string(), + on_select: move |_: String| {}, + Icon {} + "Billing" + } + DropdownMenuItem { + index: 3usize, + value: "notifications".to_string(), + on_select: move |_: String| {}, + Icon {} + "Notifications" + } + Separator { decorative: true } + DropdownMenuItem { + index: 4usize, + value: "logout".to_string(), + on_select: move |_: String| {}, + Icon {} + "Log out" + } + } + } + } + } + } +} + +#[component] +fn DemoSettingControls( + side: Signal, + collapsible: Signal, +) -> Element { + rsx! { + div { style: "display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--sidebar-border); border-radius: 0.75rem; background: var(--primary-color-2);", + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Side" + } + div { style: "display: inline-flex; gap: 0.5rem;", + Button { + variant: if side() == SidebarSide::Left { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Left), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Left" + } + Button { + variant: if side() == SidebarSide::Right { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Right), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Right" + } + } + } + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Collapse" + } + div { style: "display: inline-flex; gap: 0.5rem; flex-wrap: wrap;", + Button { + variant: if collapsible() == SidebarCollapsible::Offcanvas { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Offcanvas), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Offcanvas" + } + Button { + variant: if collapsible() == SidebarCollapsible::Icon { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Icon), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Icon" + } + Button { + variant: if collapsible() == SidebarCollapsible::None { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::None), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "None" + } + } + } + } + } +} + +#[component] +fn Icon(#[props(default = "sidebar-icon")] class: &'static str) -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class, + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "10" } + } + } +} + +#[component] +fn ChevronIcon() -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class: "sidebar-icon sidebar-chevron", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "m9 18 6-6-6-6" } + } + } +} diff --git a/preview/src/components/sidebar/variants/inset/style.css b/preview/src/components/sidebar/variants/inset/style.css new file mode 100644 index 00000000..b250e85d --- /dev/null +++ b/preview/src/components/sidebar/variants/inset/style.css @@ -0,0 +1,118 @@ +.sidebar-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sidebar-info-block { + display: grid; + flex: 1; + min-width: 0; + text-align: left; + font-size: 0.875rem; + line-height: 1.25; +} + +.sidebar-info-title, +.sidebar-info-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-info-title { + font-weight: 600; +} + +.sidebar-info-subtitle { + font-size: 0.75rem; + opacity: 0.7; +} + +.sidebar-chevron { + margin-left: auto; + transition: transform 200ms ease-out; +} + +.sidebar-menu-button[data-open="true"] .sidebar-chevron { + transform: rotate(90deg); +} + +[data-collapsible="icon"] .sidebar-hide-on-collapse { + display: none; +} + +.sidebar-menu :is(.dropdown-menu, .tooltip) { + display: block; + width: 100%; +} + +.dropdown-menu-content .separator[data-orientation="horizontal"] { + margin: 0.25rem 0; +} + +.sidebar-menu-item>.dropdown-menu:has(.sidebar-menu-action) { + position: static; +} + +.sidebar-header .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible), +.sidebar-footer .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible) { + box-shadow: none; +} + +.sidebar-sheet .sidebar-header .dropdown-menu-content { + top: 100%; + bottom: auto; + left: 0; + right: auto; + margin-top: 4px; + margin-bottom: 0; +} + +.sidebar-sheet .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 100%; + left: 0; + right: auto; + margin-top: 0; + margin-bottom: 4px; +} + +.sidebar-menu-button[data-sidebar="menu-button"].collapsible-trigger:hover { + text-decoration: none; + text-decoration-line: none; +} + +@media (min-width: 768px) { + .sidebar-desktop[data-side="left"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + left: 100%; + margin-top: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="left"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + left: 100%; + margin-bottom: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="right"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: 0.5rem; + } + + .sidebar-desktop[data-side="right"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + right: 100%; + left: auto; + margin-bottom: 0; + margin-right: 0.5rem; + } +} diff --git a/preview/src/components/sidebar/variants/main/mod.rs b/preview/src/components/sidebar/variants/main/mod.rs new file mode 100644 index 00000000..3c470868 --- /dev/null +++ b/preview/src/components/sidebar/variants/main/mod.rs @@ -0,0 +1,538 @@ +use crate::components::avatar::{Avatar, AvatarFallback, AvatarImage, AvatarImageSize}; +use crate::components::button::{Button, ButtonVariant}; +use crate::components::collapsible::{Collapsible, CollapsibleContent, CollapsibleTrigger}; +use crate::components::dropdown_menu::{ + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +}; +use crate::components::separator::Separator; +use crate::components::sidebar::{ + Sidebar, SidebarCollapsible, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, + SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, + SidebarMenuButton, SidebarMenuButtonSize, SidebarMenuItem, SidebarMenuSub, + SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSide, + SidebarTrigger, SidebarVariant, +}; +use crate::components::skeleton::Skeleton; +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +struct Team { + name: &'static str, + plan: &'static str, +} + +#[derive(Clone, PartialEq)] +struct NavMainItem { + title: &'static str, + url: &'static str, + is_active: bool, + items: &'static [SubItem], +} + +#[derive(Clone, PartialEq)] +struct SubItem { + title: &'static str, + url: &'static str, +} + +#[derive(Clone, PartialEq)] +struct Project { + name: &'static str, + url: &'static str, +} + +const TEAMS: &[Team] = &[ + Team { + name: "Acme Inc", + plan: "Enterprise", + }, + Team { + name: "Acme Corp.", + plan: "Startup", + }, + Team { + name: "Evil Corp.", + plan: "Free", + }, +]; + +const NAV_MAIN: &[NavMainItem] = &[ + NavMainItem { + title: "Playground", + url: "#", + is_active: true, + items: &[ + SubItem { + title: "History", + url: "#", + }, + SubItem { + title: "Starred", + url: "#", + }, + SubItem { + title: "Settings", + url: "#", + }, + ], + }, + NavMainItem { + title: "Models", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Genesis", + url: "#", + }, + SubItem { + title: "Explorer", + url: "#", + }, + SubItem { + title: "Quantum", + url: "#", + }, + ], + }, + NavMainItem { + title: "Documentation", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "Introduction", + url: "#", + }, + SubItem { + title: "Get Started", + url: "#", + }, + SubItem { + title: "Tutorials", + url: "#", + }, + SubItem { + title: "Changelog", + url: "#", + }, + ], + }, + NavMainItem { + title: "Settings", + url: "#", + is_active: false, + items: &[ + SubItem { + title: "General", + url: "#", + }, + SubItem { + title: "Team", + url: "#", + }, + SubItem { + title: "Billing", + url: "#", + }, + SubItem { + title: "Limits", + url: "#", + }, + ], + }, +]; + +const PROJECTS: &[Project] = &[ + Project { + name: "Design Engineering", + url: "#", + }, + Project { + name: "Sales & Marketing", + url: "#", + }, + Project { + name: "Travel", + url: "#", + }, +]; + +#[component] +pub fn Demo() -> Element { + let side = use_signal(|| SidebarSide::Left); + let collapsible = use_signal(|| SidebarCollapsible::Offcanvas); + + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + SidebarProvider { + Sidebar { + variant: SidebarVariant::Sidebar, + collapsible: collapsible(), + side: side(), + SidebarHeader { + TeamSwitcher { teams: TEAMS } + } + SidebarContent { + NavMain { items: NAV_MAIN } + NavProjects { projects: PROJECTS } + } + SidebarFooter { NavUser {} } + SidebarRail {} + } + SidebarInset { + header { style: "display:flex; align-items:center; justify-content:space-between; height:3.5rem; flex-shrink:0; padding:0 1rem; border-bottom:1px solid var(--sidebar-border); background:var(--primary-color-1);", + div { style: "display: flex; align-items: center; gap: 0.75rem;", + SidebarTrigger {} + Separator { height: "1rem", horizontal: false } + span { "Sidebar Setting" } + } + } + div { style: "display:flex; flex:1; flex-direction:column; gap:1.5rem; padding:1.5rem; min-height:0; overflow-y:auto; overflow-x:hidden;", + DemoSettingControls { side, collapsible } + Skeleton { style: "height: 10rem; width: 100%; flex-shrink:0;" } + Skeleton { style: "height: 20rem; width: 100%; flex-shrink:0;" } + } + } + } + } +} + +#[component] +fn TeamSwitcher(teams: &'static [Team]) -> Element { + let mut active_team = use_signal(|| 0usize); + + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + div { style: "display:flex; flex-shrink:0; align-items:center; justify-content:center; width:2rem; height:2rem; aspect-ratio:1; border-radius:0.5rem; background:var(--sidebar-accent); color:var(--sidebar-accent-foreground);", + Icon {} + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", {teams[active_team()].name} } + span { class: "sidebar-info-subtitle", {teams[active_team()].plan} } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "padding:0.5rem; font-size:0.75rem; opacity:0.7;", + "Teams" + } + for (idx , team) in teams.iter().enumerate() { + DropdownMenuItem { + index: idx, + value: idx, + on_select: move |v: usize| active_team.set(v), + Icon {} + {team.name} + span { style: "margin-left:auto; font-size:0.75rem; opacity:0.7;", + "⌘{idx + 1}" + } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: teams.len(), + value: 999usize, + on_select: move |_: usize| {}, + Icon {} + div { style: "opacity:0.7; font-weight:500;", "Add team" } + } + } + } + } + } + } +} + +#[component] +fn NavMain(items: &'static [NavMainItem]) -> Element { + rsx! { + SidebarGroup { + SidebarGroupLabel { "Platform" } + SidebarMenu { + for item in items.iter() { + Collapsible { + default_open: item.is_active, + r#as: move |attributes: Vec| rsx! { + SidebarMenuItem { key: "{item.title}", attributes, + CollapsibleTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { + tooltip: rsx! { + {item.title} + }, + attributes, + Icon {} + span { {item.title} } + ChevronIcon {} + } + }, + } + CollapsibleContent { + SidebarMenuSub { + for sub_item in item.items { + SidebarMenuSubItem { key: "{sub_item.title}", + SidebarMenuSubButton { + r#as: move |attributes: Vec| rsx! { + a { href: sub_item.url, ..attributes, + span { {sub_item.title} } + } + }, + } + } + } + } + } + } + }, + } + } + } + } + } +} + +#[component] +fn NavProjects(projects: &'static [Project]) -> Element { + rsx! { + SidebarGroup { class: "sidebar-hide-on-collapse", + SidebarGroupLabel { "Projects" } + SidebarMenu { + for project in projects.iter() { + SidebarMenuItem { key: "{project.name}", + SidebarMenuButton { + r#as: move |attributes: Vec| rsx! { + a { href: project.url, ..attributes, + Icon {} + span { {project.name} } + } + }, + } + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuAction { show_on_hover: true, attributes, + Icon {} + span { class: "sr-only", "More" } + } + }, + } + DropdownMenuContent { + DropdownMenuItem { + index: 0usize, + value: "view".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "View Project" } + } + DropdownMenuItem { + index: 1usize, + value: "share".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Share Project" } + } + Separator { decorative: true } + DropdownMenuItem { + index: 2usize, + value: "delete".to_string(), + on_select: move |_: String| {}, + Icon {} + span { "Delete Project" } + } + } + } + } + } + SidebarMenuItem { + SidebarMenuButton { style: "opacity:0.7; font-weight:500;", + Icon {} + span { "More" } + } + SidebarMenuBadge { "+99" } + } + } + } + } +} + +#[component] +fn NavUser() -> Element { + rsx! { + SidebarMenu { + SidebarMenuItem { + DropdownMenu { + DropdownMenuTrigger { + r#as: move |attributes: Vec| rsx! { + SidebarMenuButton { size: SidebarMenuButtonSize::Lg, attributes, + Avatar { size: AvatarImageSize::Small, style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + ChevronIcon {} + } + }, + } + DropdownMenuContent { + div { style: "display:flex; align-items:center; gap:0.5rem; padding:0.375rem 0.25rem; text-align:left; font-size:0.875rem;", + Avatar { + size: AvatarImageSize::Small, + style: "border-radius:0.5rem;", + AvatarImage { + src: asset!("/assets/dioxus-logo.png", ImageAssetOptions::new().with_avif()), + alt: "dioxus avatar", + } + AvatarFallback { "DX" } + } + div { class: "sidebar-info-block", + span { class: "sidebar-info-title", "Dioxus" } + span { class: "sidebar-info-subtitle", "m@example.com" } + } + } + Separator { decorative: true } + DropdownMenuItem { + index: 0usize, + value: "upgrade".to_string(), + on_select: move |_: String| {}, + Icon {} + "Upgrade to Pro" + } + Separator { decorative: true } + DropdownMenuItem { + index: 1usize, + value: "account".to_string(), + on_select: move |_: String| {}, + Icon {} + "Account" + } + DropdownMenuItem { + index: 2usize, + value: "billing".to_string(), + on_select: move |_: String| {}, + Icon {} + "Billing" + } + DropdownMenuItem { + index: 3usize, + value: "notifications".to_string(), + on_select: move |_: String| {}, + Icon {} + "Notifications" + } + Separator { decorative: true } + DropdownMenuItem { + index: 4usize, + value: "logout".to_string(), + on_select: move |_: String| {}, + Icon {} + "Log out" + } + } + } + } + } + } +} + +#[component] +fn DemoSettingControls( + side: Signal, + collapsible: Signal, +) -> Element { + rsx! { + div { style: "display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--sidebar-border); border-radius: 0.75rem; background: var(--primary-color-2);", + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Side" + } + div { style: "display: inline-flex; gap: 0.5rem;", + Button { + variant: if side() == SidebarSide::Left { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Left), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Left" + } + Button { + variant: if side() == SidebarSide::Right { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| side.set(SidebarSide::Right), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Right" + } + } + } + div { style: "display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;", + span { style: "font-size: 0.75rem; font-weight: 600; color: var(--secondary-color-4);", + "Collapse" + } + div { style: "display: inline-flex; gap: 0.5rem; flex-wrap: wrap;", + Button { + variant: if collapsible() == SidebarCollapsible::Offcanvas { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Offcanvas), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Offcanvas" + } + Button { + variant: if collapsible() == SidebarCollapsible::Icon { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::Icon), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "Icon" + } + Button { + variant: if collapsible() == SidebarCollapsible::None { ButtonVariant::Primary } else { ButtonVariant::Outline }, + onclick: move |_| collapsible.set(SidebarCollapsible::None), + style: "padding: 0.4rem 0.6rem; font-size: 0.75rem;", + "None" + } + } + } + } + } +} + +#[component] +fn Icon(#[props(default = "sidebar-icon")] class: &'static str) -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class, + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + circle { cx: "12", cy: "12", r: "10" } + } + } +} + +#[component] +fn ChevronIcon() -> Element { + rsx! { + svg { + xmlns: "http://www.w3.org/2000/svg", + class: "sidebar-icon sidebar-chevron", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "m9 18 6-6-6-6" } + } + } +} diff --git a/preview/src/components/sidebar/variants/main/style.css b/preview/src/components/sidebar/variants/main/style.css new file mode 100644 index 00000000..b250e85d --- /dev/null +++ b/preview/src/components/sidebar/variants/main/style.css @@ -0,0 +1,118 @@ +.sidebar-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sidebar-info-block { + display: grid; + flex: 1; + min-width: 0; + text-align: left; + font-size: 0.875rem; + line-height: 1.25; +} + +.sidebar-info-title, +.sidebar-info-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-info-title { + font-weight: 600; +} + +.sidebar-info-subtitle { + font-size: 0.75rem; + opacity: 0.7; +} + +.sidebar-chevron { + margin-left: auto; + transition: transform 200ms ease-out; +} + +.sidebar-menu-button[data-open="true"] .sidebar-chevron { + transform: rotate(90deg); +} + +[data-collapsible="icon"] .sidebar-hide-on-collapse { + display: none; +} + +.sidebar-menu :is(.dropdown-menu, .tooltip) { + display: block; + width: 100%; +} + +.dropdown-menu-content .separator[data-orientation="horizontal"] { + margin: 0.25rem 0; +} + +.sidebar-menu-item>.dropdown-menu:has(.sidebar-menu-action) { + position: static; +} + +.sidebar-header .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible), +.sidebar-footer .sidebar-menu-button[data-sidebar="menu-button"].dropdown-menu-trigger:not(:focus-visible) { + box-shadow: none; +} + +.sidebar-sheet .sidebar-header .dropdown-menu-content { + top: 100%; + bottom: auto; + left: 0; + right: auto; + margin-top: 4px; + margin-bottom: 0; +} + +.sidebar-sheet .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 100%; + left: 0; + right: auto; + margin-top: 0; + margin-bottom: 4px; +} + +.sidebar-menu-button[data-sidebar="menu-button"].collapsible-trigger:hover { + text-decoration: none; + text-decoration-line: none; +} + +@media (min-width: 768px) { + .sidebar-desktop[data-side="left"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + left: 100%; + margin-top: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="left"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + left: 100%; + margin-bottom: 0; + margin-left: 0.5rem; + } + + .sidebar-desktop[data-side="right"] :is(.sidebar-header, .sidebar-menu-item:has(.sidebar-menu-action)) .dropdown-menu-content { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: 0.5rem; + } + + .sidebar-desktop[data-side="right"] .sidebar-footer .dropdown-menu-content { + top: auto; + bottom: 0; + right: 100%; + left: auto; + margin-bottom: 0; + margin-right: 0.5rem; + } +} From a2d02af890793fe2248e12704efef9c4f745ae6e Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:38:21 +0800 Subject: [PATCH 11/12] Optimize the code style of r#as in sheet component --- preview/src/components/sheet/component.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/preview/src/components/sheet/component.rs b/preview/src/components/sheet/component.rs index 2ec0930b..9c6bec60 100644 --- a/preview/src/components/sheet/component.rs +++ b/preview/src/components/sheet/component.rs @@ -143,16 +143,16 @@ pub fn SheetClose( ) -> Element { let ctx: DialogCtx = use_context(); - let mut merged_attributes: Vec = vec![onclick(move |_| { + let mut merged: Vec = vec![onclick(move |_| { ctx.set_open(false); })]; - merged_attributes.extend(attributes); + merged.extend(attributes); if let Some(dynamic) = r#as { - dynamic.call(merged_attributes) + dynamic.call(merged) } else { rsx! { - button { ..merged_attributes, {children} } + button { ..merged, {children} } } } } From e0c3613879fcdae0895824c14c06089ceea64116 Mon Sep 17 00:00:00 2001 From: zhiyanzhaijie Date: Wed, 24 Dec 2025 11:50:27 +0800 Subject: [PATCH 12/12] style by `cargo fmt` and `Clippy` --- preview/src/components/collapsible/component.rs | 7 +------ primitives/src/lib.rs | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/preview/src/components/collapsible/component.rs b/preview/src/components/collapsible/component.rs index 8a0c2656..5fac6f41 100644 --- a/preview/src/components/collapsible/component.rs +++ b/preview/src/components/collapsible/component.rs @@ -24,12 +24,7 @@ pub fn Collapsible(props: CollapsibleProps) -> Element { #[component] pub fn CollapsibleTrigger(props: CollapsibleTriggerProps) -> Element { - let base = vec![Attribute::new( - "class", - "collapsible-trigger", - None, - false, - )]; + let base = vec![Attribute::new("class", "collapsible-trigger", None, false)]; let merged = merge_attributes(vec![base, props.attributes]); if props.r#as.is_some() { diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index ea35c1e5..36e51e85 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -270,6 +270,7 @@ impl ContentAlign { /// - Later lists win for the same (name, namespace) pair. /// - `class` is concatenated with a single space separator (trimmed); last wins for volatility flag. /// - Other attributes are overwritten by the last occurrence. +/// /// TODO: event handler attributes are not merged/combined yet. pub fn merge_attributes(lists: Vec>) -> Vec { let mut merged = Vec::new();