From 3575d553577de28667586ca878d915548ebf6b58 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 25 Jul 2026 23:58:22 +1000 Subject: [PATCH 1/2] refactor(sidenav): move shortcut handling into Rust --- app/src/shell.rs | 1 - app_crates/registry/src/ui/sidenav.rs | 23 +++++++++ e2e/tests/components/sidenav.spec.ts | 60 +++++++++++++++++++++++ public/app_components/sidenav.js | 34 ------------- public/registry/styles/default/sidenav.md | 23 +++++++++ 5 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 e2e/tests/components/sidenav.spec.ts delete mode 100644 public/app_components/sidenav.js 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..cf452ef 100644 --- a/app_crates/registry/src/ui/sidenav.rs +++ b/app_crates/registry/src/ui/sidenav.rs @@ -1,6 +1,8 @@ +use leptos::ev; use leptos::prelude::*; use leptos_router::hooks::use_location; use leptos_ui::{clx, variants, void}; +use wasm_bindgen::JsCast; mod components { use super::*; @@ -68,6 +70,27 @@ pub fn SidenavWrapper( let open = RwSignal::new(default_open); provide_context(SidenavContext { open }); + let listener = window_event_listener(ev::keydown, move |event| { + if !((event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("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(web_sys::HtmlElement::is_content_editable); + + if is_text_input || is_content_editable { + return; + } + } + + event.prevent_default(); + SidenavContext { open }.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); view! { diff --git a/e2e/tests/components/sidenav.spec.ts b/e2e/tests/components/sidenav.spec.ts new file mode 100644 index 0000000..0cb2a78 --- /dev/null +++ b/e2e/tests/components/sidenav.spec.ts @@ -0,0 +1,60 @@ +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 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 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/registry/styles/default/sidenav.md b/public/registry/styles/default/sidenav.md index 99b3d33..537946f 100644 --- a/public/registry/styles/default/sidenav.md +++ b/public/registry/styles/default/sidenav.md @@ -23,9 +23,11 @@ ui add sidenav ## Component Code ```rust +use leptos::ev; use leptos::prelude::*; use leptos_router::hooks::use_location; use leptos_ui::{clx, variants, void}; +use wasm_bindgen::JsCast; mod components { use super::*; @@ -93,6 +95,27 @@ pub fn SidenavWrapper( let open = RwSignal::new(default_open); provide_context(SidenavContext { open }); + let listener = window_event_listener(ev::keydown, move |event| { + if !((event.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("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(web_sys::HtmlElement::is_content_editable); + + if is_text_input || is_content_editable { + return; + } + } + + event.prevent_default(); + SidenavContext { open }.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); view! { From 78984df3eb2954d327ee35a2ea1d9f0c2cfce47b Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 26 Jul 2026 01:16:10 +1000 Subject: [PATCH 2/2] fix(sidenav): harden Rust shortcut lifecycle --- app_crates/registry/src/ui/sidenav.rs | 24 +++++-- crates/ui-cli/src/command_add/tree_parser.rs | 9 +++ e2e/tests/components/sidenav.spec.ts | 76 ++++++++++++++++++++ public/docs/changelog.md | 2 +- public/registry/styles/default/sidenav.md | 26 +++++-- public/registry/tree.md | 4 +- 6 files changed, 126 insertions(+), 15 deletions(-) diff --git a/app_crates/registry/src/ui/sidenav.rs b/app_crates/registry/src/ui/sidenav.rs index cf452ef..5b5425d 100644 --- a/app_crates/registry/src/ui/sidenav.rs +++ b/app_crates/registry/src/ui/sidenav.rs @@ -1,8 +1,9 @@ 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}; -use wasm_bindgen::JsCast; mod components { use super::*; @@ -60,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( @@ -68,18 +72,22 @@ 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.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("b")) { + 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(web_sys::HtmlElement::is_content_editable); + let is_content_editable = element.dyn_ref::().is_some_and(HtmlElement::is_content_editable); if is_text_input || is_content_editable { return; @@ -87,7 +95,7 @@ pub fn SidenavWrapper( } event.prevent_default(); - SidenavContext { open }.toggle(); + context.toggle(); }); on_cleanup(move || listener.remove()); @@ -270,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 index 0cb2a78..2a06f70 100644 --- a/e2e/tests/components/sidenav.spec.ts +++ b/e2e/tests/components/sidenav.spec.ts @@ -27,6 +27,69 @@ test.describe("Sidenav keyboard shortcut", () => { 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 @@ -38,6 +101,19 @@ test.describe("Sidenav keyboard shortcut", () => { 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, }) => { 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 537946f..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" @@ -25,9 +25,10 @@ ui add sidenav ```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}; -use wasm_bindgen::JsCast; mod components { use super::*; @@ -85,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( @@ -93,18 +97,22 @@ 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.ctrl_key() || event.meta_key()) && event.key().eq_ignore_ascii_case("b")) { + 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(web_sys::HtmlElement::is_content_editable); + let is_content_editable = element.dyn_ref::().is_some_and(HtmlElement::is_content_editable); if is_text_input || is_content_editable { return; @@ -112,7 +120,7 @@ pub fn SidenavWrapper( } event.prevent_default(); - SidenavContext { open }.toggle(); + context.toggle(); }); on_cleanup(move || listener.remove()); @@ -295,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 +```