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 +```