Skip to content

Replace sidenav.js with a Rust keyboard shortcut in SidenavWrapper #28

@max-wells

Description

@max-wells

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions