Context
public/components/sidenav.js (~35 lines) is loaded globally in shell.rs on every page. It adds a Ctrl/Cmd+B keyboard shortcut to toggle the sidenav. It works by querying [data-name="SidenavTrigger"] and calling .click(), or falling back to direct DOM data-state manipulation.
This is unnecessary because SidenavWrapper already provides a SidenavContext { open: RwSignal<bool> } to all descendants — the state is already in Leptos signals.
What the current JS does
document.addEventListener("keydown", (e) => {
if (!(Ctrl/Cmd + B)) return;
if (focused element is INPUT/TEXTAREA/contenteditable) return;
// Preferred: click the trigger → routes through Leptos signal
const trigger = querySelector('[data-name="SidenavTrigger"]');
if (trigger) { trigger.click(); return; }
// Fallback: direct DOM toggle (no signal context)
const sidenav = querySelector('[data-name="Sidenav"]');
sidenav.setAttribute("data-state", toggle...);
});
The querySelector dance exists only because JS has no access to Leptos signals.
The right Rust pattern
Move the keyboard listener inside SidenavWrapper using leptos::window_event_listener. With direct signal access, the DOM queries and fallback become unnecessary entirely:
#[component]
pub fn SidenavWrapper(...) -> impl IntoView {
let open = RwSignal::new(default_open);
provide_context(SidenavContext { open });
// Ctrl/Cmd+B shortcut — scoped to when SidenavWrapper is mounted
window_event_listener(ev::keydown, move |e| {
if !((e.ctrl_key() || e.meta_key()) && e.key() == "b") { return; }
// skip if editable element is focused
if let Some(el) = document().active_element() {
let tag = el.tag_name().to_uppercase();
if tag == "INPUT" || tag == "TEXTAREA" || el.is_content_editable() { return; }
}
e.prevent_default();
open.update(|v| *v = !*v); // direct signal toggle, no DOM query
});
view! { ... }
}
Why this is better:
- No
querySelector — signal is already in scope
- No DOM fallback — signals are the single source of truth
- Auto-cleanup when
SidenavWrapper unmounts
- Shortcut only active when a sidenav is actually on the page
Files involved
public/components/sidenav.js — delete
app/src/shell.rs — remove <script async src="/components/sidenav.js?v=1">
app_crates/registry/src/ui/sidenav.rs — add window_event_listener inside SidenavWrapper
public/registry/styles/default/sidenav.md (and variants) — update registry snapshots
Reference
PR #21 replaced lock_scroll.js with Rust. This follow the same spirit but is even simpler — no web_sys DOM manipulation needed, just a signal update.
Context
public/components/sidenav.js(~35 lines) is loaded globally inshell.rson every page. It adds aCtrl/Cmd+Bkeyboard shortcut to toggle the sidenav. It works by querying[data-name="SidenavTrigger"]and calling.click(), or falling back to direct DOMdata-statemanipulation.This is unnecessary because
SidenavWrapperalready provides aSidenavContext { open: RwSignal<bool> }to all descendants — the state is already in Leptos signals.What the current JS does
The querySelector dance exists only because JS has no access to Leptos signals.
The right Rust pattern
Move the keyboard listener inside
SidenavWrapperusingleptos::window_event_listener. With direct signal access, the DOM queries and fallback become unnecessary entirely:Why this is better:
querySelector— signal is already in scopeSidenavWrapperunmountsFiles involved
public/components/sidenav.js— deleteapp/src/shell.rs— remove<script async src="/components/sidenav.js?v=1">app_crates/registry/src/ui/sidenav.rs— addwindow_event_listenerinsideSidenavWrapperpublic/registry/styles/default/sidenav.md(and variants) — update registry snapshotsReference
PR #21 replaced
lock_scroll.jswith Rust. This follow the same spirit but is even simpler — noweb_sysDOM manipulation needed, just a signal update.