diff --git a/ui/assets/main.css b/ui/assets/main.css index 9f4e9e96..abf743f6 100644 --- a/ui/assets/main.css +++ b/ui/assets/main.css @@ -48,3 +48,13 @@ text-overflow: clip; } } + +/* Keyboard users get the same full-preview expansion via focus-visible, + * on all devices (not just those with a pointer). The strip is reachable + * with Tab because it has role="button" and tabindex="0". */ +.reply-strip:focus-visible { + white-space: normal; + text-overflow: clip; + outline: 2px solid currentColor; + outline-offset: 1px; +} diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs index 78146e77..bdae7bb1 100644 --- a/ui/src/components/conversation.rs +++ b/ui/src/components/conversation.rs @@ -1759,6 +1759,9 @@ fn MessageGroupComponent( if let (Some(author), Some(preview)) = (reply_author_inner, reply_preview_inner) { { let target_id_str = reply_target_inner.map(|id| format!("{:?}", id.0)).unwrap_or_default(); + // Clone the target id so we can own one copy in the + // onclick handler and one in the onkeydown handler. + let target_id_for_key = target_id_str.clone(); rsx! { div { "data-testid": "reply-strip", @@ -1766,7 +1769,10 @@ fn MessageGroupComponent( "reply-strip min-w-0 w-full text-[11px] leading-normal px-3 pt-1.5 pb-1.5 cursor-pointer {}", if is_self { "bg-white/25 text-white/90" } else { "bg-black/[0.12] text-text-muted" } ), - title: "Click to scroll to original message", + title: "Scroll to original message (Enter or Space to activate)", + role: "button", + tabindex: "0", + "aria-label": "Scroll to the message this is a reply to", onclick: move |_| { if let Some(window) = web_sys::window() { if let Some(doc) = window.document() { @@ -1777,6 +1783,22 @@ fn MessageGroupComponent( } } }, + onkeydown: move |e: KeyboardEvent| { + // Activate the same scroll-to-original + // behaviour via Enter or Space so keyboard + // users can reach it without a mouse. + if e.key() == Key::Enter || e.key() == Key::Character(" ".to_string()) { + e.prevent_default(); + if let Some(window) = web_sys::window() { + if let Some(doc) = window.document() { + if let Some(el) = doc.get_element_by_id(&format!("msg-{}", target_id_for_key)) { + el.scroll_into_view(); + let _ = el.class_list().add_1("reply-highlight"); + } + } + } + } + }, span { class: "font-medium", "\u{21a9} @{author}: " } span { "{preview}" } } diff --git a/ui/tests/message-layout.spec.ts b/ui/tests/message-layout.spec.ts index 715cf49b..716fa99d 100644 --- a/ui/tests/message-layout.spec.ts +++ b/ui/tests/message-layout.spec.ts @@ -199,3 +199,102 @@ test.describe("Reply bubble layout (#206, #207)", () => { expect(Math.abs(widthAfter - widthBefore)).toBeLessThanOrEqual(0.5); }); }); + +// #210: the reply strip has onclick and cursor-pointer but was previously a +// plain div with no tabindex / role / key handler, and the hover-expand CSS +// had no :focus-visible equivalent, so keyboard users couldn't reach or +// activate it. +test.describe("Reply strip keyboard accessibility (#210)", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test("reply strip is keyboard-focusable and announces as a button", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + await selectRoom(page, "Your Private Room"); + + const replyStrip = page.locator(".reply-strip").first(); + await expect(replyStrip).toBeVisible({ timeout: 10_000 }); + + // ARIA contract + await expect(replyStrip).toHaveAttribute("role", "button"); + await expect(replyStrip).toHaveAttribute("tabindex", "0"); + await expect(replyStrip).toHaveAttribute("aria-label", /reply/i); + + // Focusable via .focus() — this also verifies the element accepts focus + // at the DOM level (tabindex >= 0). + await replyStrip.evaluate((el) => (el as HTMLElement).focus()); + const isFocused = await replyStrip.evaluate( + (el) => document.activeElement === el + ); + expect(isFocused).toBe(true); + }); + + test("a :focus-visible CSS rule exists for the reply strip", async ({ + page, + }) => { + // Playwright's programmatic `.focus()` does not reliably trigger + // `:focus-visible` in headless Chromium (the spec defines it via a + // heuristic that considers the input modality, and scripted focus + // is treated as mouse-like). Instead of trying to simulate keyboard + // focus, verify the stylesheet actually contains the rule — that's + // what the a11y contract requires, and it's what would regress if + // someone deleted the CSS. + await page.goto("/"); + await waitForApp(page); + await selectRoom(page, "Your Private Room"); + + const hasFocusVisibleRule = await page.evaluate(() => { + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList | null = null; + try { + rules = sheet.cssRules; + } catch { + continue; + } + if (!rules) continue; + for (const rule of Array.from(rules)) { + if ( + rule instanceof CSSStyleRule && + rule.selectorText && + rule.selectorText.includes(".reply-strip") && + rule.selectorText.includes(":focus-visible") + ) { + return true; + } + } + } + return false; + }); + expect( + hasFocusVisibleRule, + ".reply-strip:focus-visible CSS rule must exist so keyboard users see full preview (#210)" + ).toBe(true); + }); + + test("pressing Enter or Space on the focused reply strip scrolls to the original", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + await selectRoom(page, "Your Private Room"); + + const replyStrip = page.locator(".reply-strip").first(); + await expect(replyStrip).toBeVisible({ timeout: 10_000 }); + + // The onclick handler adds the `reply-highlight` class to the target + // message after scrolling; pressing Enter/Space on the focused strip + // must do the same (Space needs preventDefault to stop the page from + // scrolling). + await replyStrip.focus(); + await page.keyboard.press("Enter"); + + // Wait for the highlight class to appear on any `[id^='msg-']` element. + await expect + .poll(async () => + page.locator("[id^='msg-'].reply-highlight").count() + ) + .toBeGreaterThan(0); + }); +});