Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions ui/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
24 changes: 23 additions & 1 deletion ui/src/components/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1759,14 +1759,20 @@ 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",
class: format!(
"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() {
Expand All @@ -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}" }
}
Expand Down
99 changes: 99 additions & 0 deletions ui/tests/message-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading