From 4d5c236558a4f3193b96a5b4e378ffc2d5793c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 09:10:45 +0000 Subject: [PATCH] fix(web): a11y escape + initial focus on relay popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popover advertises `aria-haspopup="dialog"` but had no Escape handler and no focus pull. Global keybinding stack does not own this popover — local listener required. Seed focus on settings link so keyboard users land inside the dialog. Mark `aria-modal="false"` — popover does not trap focus. Tests: Escape closes, settings link focused on open, aria-modal="false". Closes #352 --- .../web/src/components/relay_signal_button.rs | 38 ++++++ crates/web/tests/browser.rs | 122 ++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/crates/web/src/components/relay_signal_button.rs b/crates/web/src/components/relay_signal_button.rs index d2bf4dec..cb8b8ae3 100644 --- a/crates/web/src/components/relay_signal_button.rs +++ b/crates/web/src/components/relay_signal_button.rs @@ -45,6 +45,40 @@ pub fn RelaySignalButton() -> impl IntoView { let open = RwSignal::new(false); + // Settings-link button receives focus when the popover opens, so + // keyboard users land inside the dialog instead of skipping past + // its contents. Same pattern used by `ConfirmDialog`. + let settings_link_ref = NodeRef::::new(); + + // Pull focus into the popover on each open transition. The global + // keybinding stack (`crate::keybindings`) does not know about this + // popover, so we own the initial-focus contract locally. The dialog + // is non-modal (`aria-modal="false"`) — we do not trap focus, just + // seed it; Tab continues to move through page chrome normally. + Effect::new(move |prev: Option| { + let is_open = open.get(); + let was_open = prev.unwrap_or(false); + if is_open && !was_open { + request_animation_frame(move || { + if let Some(el) = settings_link_ref.get_untracked() { + let _ = el.focus(); + } + }); + } + is_open + }); + + // Local Escape handler scoped to the popover. The global handler in + // `keybindings::install` only knows about the rail / palette / sheet + // stack; this popover is outside that stack, so without a local + // listener Escape would be a no-op while focus is inside the dialog. + let on_popover_keydown = move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + ev.stop_propagation(); + open.set(false); + } + }; + let class_for = move || match relay.get() { RelayStatus::Reachable => "relay-signal-button relay-signal-button--ok", RelayStatus::Unreachable => "relay-signal-button relay-signal-button--warn", @@ -94,6 +128,9 @@ pub fn RelaySignalButton() -> impl IntoView { class="relay-popover" role="dialog" aria-label="relay status" + aria-modal="false" + tabindex="-1" + on:keydown=on_popover_keydown >
{icons::icon_signal()} @@ -106,6 +143,7 @@ pub fn RelaySignalButton() -> impl IntoView {