Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
<script async src="/app_components/resizable.bundle.js"></script>
<script async src="/app_components/shimmer_init.js?v=3"></script>
<script async src="/app_components/lazy_load_sonner.js"></script>
<script async src="/app_components/sidenav.js?v=1"></script>

// JSON-LD Structured Data for SEO (inlined at compile time — readable by AI crawlers)
<script type="application/ld+json" inner_html=include_str!("../../public/schema.json")></script>
Expand Down
37 changes: 36 additions & 1 deletion app_crates/registry/src/ui/sidenav.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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(
Expand All @@ -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::<HtmlElement>().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);

Expand Down Expand Up @@ -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::<SidenavContext>();
Expand Down
9 changes: 9 additions & 0 deletions crates/ui-cli/src/command_add/tree_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
136 changes: 136 additions & 0 deletions e2e/tests/components/sidenav.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
34 changes: 0 additions & 34 deletions public/app_components/sidenav.js

This file was deleted.

2 changes: 1 addition & 1 deletion public/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -458,4 +459,3 @@ use icons::LucideIcon;
- Registry flattening for cleaner directory structure
- Slot component simplified to support only `A` variant


39 changes: 37 additions & 2 deletions public/registry/styles/default/sidenav.md
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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};

Expand Down Expand Up @@ -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(
Expand All @@ -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::<HtmlElement>().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);

Expand Down Expand Up @@ -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::<SidenavContext>();
Expand Down
4 changes: 3 additions & 1 deletion public/registry/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2088,4 +2090,4 @@ Each dependency level is shown with progressive bullet points: * for components,
* use_theme_mode (hooks)
* use_virtual_scroll (hooks)

```
```