Skip to content

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

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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