diff --git a/app/src/shell.rs b/app/src/shell.rs
index 8b09e8b..d894421 100644
--- a/app/src/shell.rs
+++ b/app/src/shell.rs
@@ -142,7 +142,6 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
-
// JSON-LD Structured Data for SEO (inlined at compile time — readable by AI crawlers)
diff --git a/app_crates/registry/src/ui/sidenav.rs b/app_crates/registry/src/ui/sidenav.rs
index 9b79a86..5b5425d 100644
--- a/app_crates/registry/src/ui/sidenav.rs
+++ b/app_crates/registry/src/ui/sidenav.rs
@@ -1,4 +1,7 @@
+use leptos::ev;
use leptos::prelude::*;
+use leptos::wasm_bindgen::JsCast;
+use leptos::web_sys::HtmlElement;
use leptos_router::hooks::use_location;
use leptos_ui::{clx, variants, void};
@@ -58,6 +61,9 @@ impl SidenavContext {
/// Root layout wrapper for the Sidenav system.
/// Provides `SidenavContext` to all descendants automatically.
+/// Owns the `Ctrl+B` / `Cmd+B` shortcut while mounted. If multiple wrappers
+/// share a window, the first one to handle the event prevents later handlers
+/// from toggling additional Sidenavs.
/// Use `default_open` to set the initial state (default: `true` = expanded).
#[component]
pub fn SidenavWrapper(
@@ -66,7 +72,32 @@ pub fn SidenavWrapper(
children: Children,
) -> impl IntoView {
let open = RwSignal::new(default_open);
- provide_context(SidenavContext { open });
+ let context = SidenavContext { open };
+ provide_context(context);
+
+ let listener = window_event_listener(ev::keydown, move |event| {
+ if event.default_prevented()
+ || event.shift_key()
+ || event.alt_key()
+ || !((event.ctrl_key() || event.meta_key()) && event.key() == "b")
+ {
+ return;
+ }
+
+ if let Some(element) = document().active_element() {
+ let tag_name = element.tag_name();
+ let is_text_input = tag_name.eq_ignore_ascii_case("input") || tag_name.eq_ignore_ascii_case("textarea");
+ let is_content_editable = element.dyn_ref::().is_some_and(HtmlElement::is_content_editable);
+
+ if is_text_input || is_content_editable {
+ return;
+ }
+ }
+
+ event.prevent_default();
+ context.toggle();
+ });
+ on_cleanup(move || listener.remove());
let merged_class = tw_merge!("group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full", class);
@@ -247,6 +278,10 @@ pub enum SidenavState {
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
+/// Toggles the nearest context-owned Sidenav when clicked.
+///
+/// Keyboard shortcuts require an ancestor [`SidenavWrapper`]. The inline
+/// fallback preserves click behavior for standalone Sidenavs without context.
#[component]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
let ctx = use_context::();
diff --git a/crates/ui-cli/src/command_add/tree_parser.rs b/crates/ui-cli/src/command_add/tree_parser.rs
index 2e19657..931e835 100644
--- a/crates/ui-cli/src/command_add/tree_parser.rs
+++ b/crates/ui-cli/src/command_add/tree_parser.rs
@@ -363,4 +363,13 @@ mod tests {
sorted.sort();
assert_eq!(keys, sorted);
}
+
+ #[test]
+ fn sidenav_resolves_its_compile_time_cargo_dependencies() {
+ let parser = TreeParser::parse_tree_md(include_str!("../../../../public/registry/tree.md")).unwrap();
+ let resolved = parser.resolve_dependencies(&["sidenav".to_string()]).unwrap();
+
+ assert!(resolved.cargo_deps.contains("leptos_router"));
+ assert!(resolved.cargo_deps.contains("strum/derive"));
+ }
}
diff --git a/e2e/tests/components/sidenav.spec.ts b/e2e/tests/components/sidenav.spec.ts
new file mode 100644
index 0000000..2a06f70
--- /dev/null
+++ b/e2e/tests/components/sidenav.spec.ts
@@ -0,0 +1,136 @@
+import { expect, test } from "@playwright/test";
+
+const SIDENAV_ROUTE = "/view/sidenav01/docs/components/accordion";
+
+test.describe("Sidenav keyboard shortcut", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route("**/app_components/sidenav.js*", (route) => route.abort());
+ await page.goto(SIDENAV_ROUTE);
+ await page.waitForLoadState("networkidle");
+ });
+
+ test("toggles with Control+B when the legacy JavaScript is unavailable", async ({
+ page,
+ }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ await page.keyboard.press("Control+b");
+ await expect(sidenav).toHaveAttribute("data-state", "Collapsed");
+ });
+
+ test("toggles with Meta+B", async ({ page }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ await page.keyboard.press("Meta+b");
+ await expect(sidenav).toHaveAttribute("data-state", "Collapsed");
+ });
+
+ test("does not handle a shortcut that another owner already handled", async ({
+ page,
+ }) => {
+ await page.addInitScript(() => {
+ window.addEventListener("keydown", (event) => {
+ if (
+ (event.ctrlKey || event.metaKey) &&
+ event.key.toLowerCase() === "b"
+ ) {
+ event.preventDefault();
+ }
+ });
+ });
+
+ await page.goto(SIDENAV_ROUTE);
+ await page.waitForLoadState("networkidle");
+
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+ await page.keyboard.press("Control+b");
+
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+
+ test("does not toggle with Control+Shift+B", async ({ page }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await page.keyboard.press("Control+Shift+B");
+
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+
+ test("does not toggle with Control+Alt+B", async ({ page }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await page.keyboard.press("Control+Alt+B");
+
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+
+ test("removes the shortcut listener when its wrapper unmounts", async ({
+ page,
+ }) => {
+ await page.evaluate(() => {
+ history.pushState(
+ {},
+ "",
+ "/view/sidenav02/docs/components/accordion",
+ );
+ dispatchEvent(new PopStateEvent("popstate"));
+ });
+
+ await expect(page).toHaveURL(
+ /\/view\/sidenav02\/docs\/components\/accordion$/,
+ );
+
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+
+ await page.keyboard.press("Control+b");
+
+ await expect(sidenav).toHaveAttribute("data-state", "Collapsed");
+ });
+
+ test("does not toggle while an input is focused", async ({ page }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+ const search = page
+ .locator('[data-name="SidenavWrapper"] input#search')
+ .first();
+
+ await search.focus();
+ await page.keyboard.press("Control+b");
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+
+ test("does not toggle while a textarea is focused", async ({ page }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await page.evaluate(() => {
+ const textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textarea.focus();
+ });
+
+ await page.keyboard.press("Control+b");
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+
+ test("does not toggle for a focused descendant of a contenteditable region", async ({
+ page,
+ }) => {
+ const sidenav = page.locator('[data-name="Sidenav"]').first();
+
+ await page.evaluate(() => {
+ const editor = document.createElement("div");
+ editor.contentEditable = "true";
+ const child = document.createElement("span");
+ child.tabIndex = 0;
+ child.textContent = "Editable child";
+ editor.appendChild(child);
+ document.body.appendChild(editor);
+ child.focus();
+ });
+
+ await page.keyboard.press("Control+b");
+ await expect(sidenav).toHaveAttribute("data-state", "Expanded");
+ });
+});
diff --git a/public/app_components/sidenav.js b/public/app_components/sidenav.js
deleted file mode 100644
index b4408c8..0000000
--- a/public/app_components/sidenav.js
+++ /dev/null
@@ -1,34 +0,0 @@
-(function () {
- document.addEventListener("keydown", function (e) {
- // Ctrl+B (Windows/Linux) or Cmd+B (Mac)
- if (!(e.key === "b" && (e.ctrlKey || e.metaKey))) return;
-
- // Skip if an editable element is focused (INPUT, TEXTAREA, contenteditable)
- const el = document.activeElement;
- if (
- el &&
- (el.tagName === "INPUT" ||
- el.tagName === "TEXTAREA" ||
- el.isContentEditable)
- )
- return;
-
- // Prefer clicking the trigger button — routes through Leptos signal when SidenavWrapper context is present
- const trigger = document.querySelector('[data-name="SidenavTrigger"]');
- if (trigger) {
- e.preventDefault();
- trigger.click();
- return;
- }
-
- // Fallback: direct DOM toggle (no SidenavTrigger in DOM)
- const sidenav = document.querySelector('[data-name="Sidenav"]');
- if (!sidenav || !sidenav.hasAttribute("data-sidenav")) return;
- e.preventDefault();
- const current = sidenav.getAttribute("data-state");
- sidenav.setAttribute(
- "data-state",
- current === "Collapsed" ? "Expanded" : "Collapsed",
- );
- });
-})();
diff --git a/public/docs/changelog.md b/public/docs/changelog.md
index 2dc79b4..ee2e260 100644
--- a/public/docs/changelog.md
+++ b/public/docs/changelog.md
@@ -21,6 +21,7 @@ image_dark = "/images/thumbnails/_placeholder-dark.webp"
### Improvements
+- **[Sidenav](/docs/components/sidenav)**: Moved the `Ctrl+B` / `Cmd+B` shortcut into the lifecycle-owned Leptos component, including editable-focus guards, multi-wrapper event arbitration, and explicit listener cleanup.
- **[Button](/docs/components/button)**: Added `ButtonSize::IconSm` (`size-8`) and `ButtonSize::IconXs` (`size-6`) variants.
---
@@ -458,4 +459,3 @@ use icons::LucideIcon;
- Registry flattening for cleaner directory structure
- Slot component simplified to support only `A` variant
-
diff --git a/public/registry/styles/default/sidenav.md b/public/registry/styles/default/sidenav.md
index 99b3d33..654d9da 100644
--- a/public/registry/styles/default/sidenav.md
+++ b/public/registry/styles/default/sidenav.md
@@ -1,7 +1,7 @@
---
title: "Sidenav"
name: "sidenav"
-cargo_dependencies: []
+cargo_dependencies: ["leptos_router", "strum/derive"]
registry_dependencies: []
type: "components:ui"
path: "ui/sidenav.rs"
@@ -23,7 +23,10 @@ ui add sidenav
## Component Code
```rust
+use leptos::ev;
use leptos::prelude::*;
+use leptos::wasm_bindgen::JsCast;
+use leptos::web_sys::HtmlElement;
use leptos_router::hooks::use_location;
use leptos_ui::{clx, variants, void};
@@ -83,6 +86,9 @@ impl SidenavContext {
/// Root layout wrapper for the Sidenav system.
/// Provides `SidenavContext` to all descendants automatically.
+/// Owns the `Ctrl+B` / `Cmd+B` shortcut while mounted. If multiple wrappers
+/// share a window, the first one to handle the event prevents later handlers
+/// from toggling additional Sidenavs.
/// Use `default_open` to set the initial state (default: `true` = expanded).
#[component]
pub fn SidenavWrapper(
@@ -91,7 +97,32 @@ pub fn SidenavWrapper(
children: Children,
) -> impl IntoView {
let open = RwSignal::new(default_open);
- provide_context(SidenavContext { open });
+ let context = SidenavContext { open };
+ provide_context(context);
+
+ let listener = window_event_listener(ev::keydown, move |event| {
+ if event.default_prevented()
+ || event.shift_key()
+ || event.alt_key()
+ || !((event.ctrl_key() || event.meta_key()) && event.key() == "b")
+ {
+ return;
+ }
+
+ if let Some(element) = document().active_element() {
+ let tag_name = element.tag_name();
+ let is_text_input = tag_name.eq_ignore_ascii_case("input") || tag_name.eq_ignore_ascii_case("textarea");
+ let is_content_editable = element.dyn_ref::().is_some_and(HtmlElement::is_content_editable);
+
+ if is_text_input || is_content_editable {
+ return;
+ }
+ }
+
+ event.prevent_default();
+ context.toggle();
+ });
+ on_cleanup(move || listener.remove());
let merged_class = tw_merge!("group/sidenav-wrapper has-data-[variant=Inset]:bg-sidenav flex h-full w-full", class);
@@ -272,6 +303,10 @@ pub enum SidenavState {
const ONCLICK_TRIGGER: &str = "document.querySelector('[data-name=\"Sidenav\"]').setAttribute('data-state', document.querySelector('[data-name=\"Sidenav\"]').getAttribute('data-state') === 'Collapsed' ? 'Expanded' : 'Collapsed')";
+/// Toggles the nearest context-owned Sidenav when clicked.
+///
+/// Keyboard shortcuts require an ancestor [`SidenavWrapper`]. The inline
+/// fallback preserves click behavior for standalone Sidenavs without context.
#[component]
pub fn SidenavTrigger(children: Children) -> impl IntoView {
let ctx = use_context::();
diff --git a/public/registry/tree.md b/public/registry/tree.md
index 6085712..1fa5075 100644
--- a/public/registry/tree.md
+++ b/public/registry/tree.md
@@ -1958,6 +1958,8 @@ Each dependency level is shown with progressive bullet points: * for components,
* shimmer (ui)
** use_random (hooks)
* sidenav (ui)
+ ** cargo: leptos_router
+ ** cargo: strum/derive
* skeleton (ui)
* slider (ui)
* sonner (ui)
@@ -2088,4 +2090,4 @@ Each dependency level is shown with progressive bullet points: * for components,
* use_theme_mode (hooks)
* use_virtual_scroll (hooks)
-```
\ No newline at end of file
+```