Skip to content

Commit 9cfc44c

Browse files
felipebalbiCopilot
andcommitted
t18: A11y + dismissal polish for the mobile nav
The mobile nav was a single signal-driven flyout with no accessibility affordances and no dismissal paths beyond clicking the hamburger again. Add the missing pieces a screen reader / keyboard user expects: * Hamburger button now exposes `aria-expanded` (bound to the `menu_open` signal), `aria-controls` pointing at the flyout's new `id="primary-mobile-nav"`, and a dynamic `aria-label` that flips between "Open menu" / "Close menu". * Both `<nav>` regions get `aria-label="Primary"` so assistive tech can name them. * The active-route detection that already drives the `odp-header-btn-active` styling now also emits `aria-current="page"` on the matching `<A>`. * Pressing **Escape** while the flyout is open closes it (`window_event_listener(ev::keydown, ...)`). * Clicking outside the flyout closes it -- a transparent backdrop `<div class="fixed inset-0 z-40 lg:hidden">` is rendered when the menu is open and dismisses on click. Flyout sits on `z-50` so it stays above the backdrop. * Tapping any link inside the flyout closes it. `NavButton` and `ExternalNavButton` gained an optional `on_navigate: Option<Callback<()>>` prop that fires from the underlying `on:click`; the call sites in the mobile nav pass a closure that flips `menu_open` to `false`. Desktop nav passes no callback so its behaviour is unchanged. * `ExternalNavButton` now sets `rel="noopener noreferrer"` to fix the long-standing reverse-tabnabbing risk on the Library link. The desktop / mobile breakpoint switch (now at `lg` after t15) is left in place. The plan's "compact nav at lg, full nav at xl" idea is intentionally skipped: with the current label lengths the full nav fits cleanly at `lg` and a separate icon-only ladder would add maintenance burden without a real visual win. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0295f24 commit 9cfc44c

1 file changed

Lines changed: 59 additions & 9 deletions

File tree

src/components/header.rs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
use crate::components::themed_icon::ThemedIcon;
2+
use leptos::ev;
23
use leptos::prelude::RwSignal;
34
use leptos::prelude::*;
45
use leptos_router::components::A;
56

67
#[component]
78
pub fn Header(#[prop(optional, default = "header_background")] background_class: &'static str) -> impl IntoView {
89
let menu_open = RwSignal::new(false);
10+
let close_menu = move || menu_open.set(false);
11+
12+
// ESC closes the mobile menu when it is open.
13+
window_event_listener(ev::keydown, move |e| {
14+
if e.key() == "Escape" && menu_open.get_untracked() {
15+
menu_open.set(false);
16+
}
17+
});
18+
919
view! {
1020
<header class=format!(
1121
"w-full h-[80px] lg:h-[160px] px-4 sm:px-8 md:px-16 {} flex items-center justify-between z-50 m-0 p-0 relative",
@@ -21,15 +31,17 @@ pub fn Header(#[prop(optional, default = "header_background")] background_class:
2131

2232
<button
2333
class="lg:hidden flex flex-col justify-center items-center w-10 h-10 p-2 focus:outline-none"
24-
aria-label="Open menu"
34+
aria-label=move || if menu_open.get() { "Close menu" } else { "Open menu" }
35+
aria-expanded=move || if menu_open.get() { "true" } else { "false" }
36+
aria-controls="primary-mobile-nav"
2537
on:click=move |_| menu_open.update(|v| *v = !*v)
2638
>
2739
<span class="block w-6 h-0.5 bg-black dark:bg-white mb-1"></span>
2840
<span class="block w-6 h-0.5 bg-black dark:bg-white mb-1"></span>
2941
<span class="block w-6 h-0.5 bg-black dark:bg-white"></span>
3042
</button>
3143

32-
<nav class="hidden lg:flex [column-gap:25px]">
44+
<nav class="hidden lg:flex [column-gap:25px]" aria-label="Primary">
3345
<NavButton href="/getting-started" label="Getting Started" />
3446
<NavButton href="/projects" label="Projects" />
3547
<ExternalNavButton
@@ -40,26 +52,47 @@ pub fn Header(#[prop(optional, default = "header_background")] background_class:
4052
<NavButton href="/home" label="Home" />
4153
</nav>
4254

55+
// Backdrop: catches clicks outside the open mobile menu and dismisses it.
56+
<div
57+
class="fixed inset-0 z-40 lg:hidden"
58+
style=move || { if menu_open.get() { "display: block;" } else { "display: none;" } }
59+
on:click=move |_| close_menu()
60+
aria-hidden="true"
61+
></div>
62+
4363
<nav
44-
class="absolute right-0 top-full w-[80vw] max-w-xs background_primary flex-col items-end px-4 py-4 space-y-2 shadow-lg lg:hidden transition-all duration-200"
64+
id="primary-mobile-nav"
65+
aria-label="Primary"
66+
class="absolute right-0 top-full w-[80vw] max-w-xs background_primary flex-col items-end px-4 py-4 space-y-2 shadow-lg lg:hidden transition-all duration-200 z-50"
4567
style=move || if menu_open.get() { "display: flex;" } else { "display: none;" }
4668
>
47-
<NavButton href="/getting-started" label="Getting Started" mobile=true />
48-
<NavButton href="/projects" label="Projects" mobile=true />
69+
<NavButton
70+
href="/getting-started"
71+
label="Getting Started"
72+
mobile=true
73+
on_navigate=close_menu
74+
/>
75+
<NavButton href="/projects" label="Projects" mobile=true on_navigate=close_menu />
4976
<ExternalNavButton
5077
href="https://opendevicepartnership.github.io/documentation/"
5178
label="Library"
5279
mobile=true
80+
on_navigate=close_menu
5381
/>
54-
<NavButton href="/community" label="Community" mobile=true />
55-
<NavButton href="/home" label="Home" mobile=true />
82+
<NavButton href="/community" label="Community" mobile=true on_navigate=close_menu />
83+
<NavButton href="/home" label="Home" mobile=true on_navigate=close_menu />
5684
</nav>
5785
</header>
5886
}
5987
}
6088

6189
#[component]
62-
fn NavButton(href: &'static str, label: &'static str, #[prop(optional)] mobile: bool) -> impl IntoView {
90+
fn NavButton(
91+
href: &'static str,
92+
label: &'static str,
93+
#[prop(optional)] mobile: bool,
94+
#[prop(optional, into)] on_navigate: Option<Callback<()>>,
95+
) -> impl IntoView {
6396
let location = leptos_router::hooks::use_location();
6497
let is_active = move || location.pathname.get().starts_with(href);
6598

@@ -71,14 +104,25 @@ fn NavButton(href: &'static str, label: &'static str, #[prop(optional)] mobile:
71104
class:odp-header-btn-active=is_active
72105
class:odp-header-btn-active-text=is_active
73106
class:w-full=mobile
107+
attr:aria-current=move || if is_active() { Some("page") } else { None }
108+
on:click=move |_| {
109+
if let Some(cb) = on_navigate {
110+
cb.run(());
111+
}
112+
}
74113
>
75114
{label}
76115
</A>
77116
}
78117
}
79118

80119
#[component]
81-
fn ExternalNavButton(href: &'static str, label: &'static str, #[prop(optional)] mobile: bool) -> impl IntoView {
120+
fn ExternalNavButton(
121+
href: &'static str,
122+
label: &'static str,
123+
#[prop(optional)] mobile: bool,
124+
#[prop(optional, into)] on_navigate: Option<Callback<()>>,
125+
) -> impl IntoView {
82126
view! {
83127
<a
84128
href=href
@@ -87,6 +131,12 @@ fn ExternalNavButton(href: &'static str, label: &'static str, #[prop(optional)]
87131
if mobile { " w-full" } else { "" },
88132
)
89133
target="_blank"
134+
rel="noopener noreferrer"
135+
on:click=move |_| {
136+
if let Some(cb) = on_navigate {
137+
cb.run(());
138+
}
139+
}
90140
>
91141
{label}
92142
</a>

0 commit comments

Comments
 (0)