fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real

- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one,
  else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it
  (`focusNeighbour` in lib/useFocusKeeper).
- F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views);
  the two generic "Drag to reorder" keys are gone.
- F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel,
  and a keyboard pin moves the focus into the card.
- F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
This commit is contained in:
2026-09-27 15:01:22 +02:00
parent 0b1d905866
commit 5ebb4cb506
17 changed files with 173 additions and 23 deletions
@@ -19,7 +19,13 @@ const h = vi.hoisted(() => ({
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { name?: string }) =>
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
}),
};
}); });
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters // A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
@@ -225,7 +231,7 @@ describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () =>
it("picks a view up from the keyboard, as its grip's instructions say", async () => { it("picks a view up from the keyboard, as its grip's instructions say", async () => {
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)]; CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
mount(); mount();
const grip = (await screen.findAllByRole("button", { name: "views.reorder" }))[0]!; const grip = await screen.findByRole("button", { name: "common.order.dragNamed:Docs" });
grip.focus(); grip.focus();
fireEvent.keyDown(grip, { code: "Space", key: " " }); fireEvent.keyDown(grip, { code: "Space", key: " " });
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp")); await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
@@ -62,7 +62,11 @@ function ViewRow({
selected ? "bg-accent/15" : "state-layer" selected ? "bg-accent/15" : "state-layer"
}`} }`}
> >
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} /> <DragHandle
label={t("common.order.dragNamed", { name: view.name })}
{...attributes}
{...listeners}
/>
<Pressable <Pressable
selectable selectable
onClick={onSelect} onClick={onSelect}
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// E28 review F7: while hidden the button only went transparent — a Tab still landed on the invisible
// control. And a keyboard press scrolled to the top and hid it with the focus still on it.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }),
}));
import BackToTop from "./BackToTop";
afterEach(cleanup);
function scrolled(main: HTMLElement, top: number) {
main.scrollTop = top;
act(() => void main.dispatchEvent(new Event("scroll")));
}
describe("BackToTop", () => {
it("is no Tab stop while hidden; a keyboard press leaves the focus on the page", () => {
const main = document.createElement("main");
main.tabIndex = -1;
main.scrollTo = vi.fn();
document.body.appendChild(main);
render(<BackToTop />);
const btn = screen.getByText("common.backToTop").closest("button")!;
expect(btn.tabIndex).toBe(-1);
scrolled(main, 900);
expect(btn.tabIndex).toBe(0);
btn.focus();
fireEvent.click(btn, { detail: 0 });
expect(main.scrollTo).toHaveBeenCalled();
expect(document.activeElement).toBe(main);
main.remove();
});
});
+10 -5
View File
@@ -42,11 +42,13 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
setShow(!!el && el.scrollTop > threshold); setShow(!!el && el.scrollTop > threshold);
}, [dep, threshold]); }, [dep, threshold]);
const toTop = () => // A keyboard press hides the button it was on: the focus goes to the page it scrolled (its <main>
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({ // is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7).
top: 0, const toTop = (e: React.MouseEvent) => {
behavior: "smooth", const main = scrollerRef.current ?? document.querySelector("main");
}); main?.scrollTo({ top: 0, behavior: "smooth" });
if (e.detail === 0) main?.focus({ preventScroll: true });
};
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields) // No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"]. // commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
@@ -57,6 +59,9 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
<Pressable <Pressable
align="center" align="center"
onClick={toTop} onClick={toTop}
// Hidden, it only goes transparent (so it can fade): out of the Tab order as well.
tabIndex={show ? undefined : -1}
aria-hidden={show ? undefined : true}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${ className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none" show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`} }`}
+9 -1
View File
@@ -3,6 +3,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton"; import { FocusSafeButton } from "./FocusSafeButton";
import { focusNeighbour } from "../../lib/useFocusKeeper";
import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles"; import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles";
// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that // The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that
@@ -113,7 +114,14 @@ export function DismissibleChip({
)} )}
<FocusSafeButton <FocusSafeButton
type="button" type="button"
onClick={onDismiss} onClick={(e) => {
// From the keyboard the × holds the focus, and the chip unmounts: hand the focus to the
// next chip (else the previous one, else what follows the row) first, not to <body> (E28
// review F5). A mouse press on it leaves the focus where it was.
const chip = e.currentTarget.parentElement;
if (chip && e.currentTarget === document.activeElement) focusNeighbour(chip);
onDismiss();
}}
aria-label={dismissLabel} aria-label={dismissLabel}
title={dismissLabel} title={dismissLabel}
className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target" className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target"
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react"; import { FloatingTree } from "@floating-ui/react";
import { useState } from "react";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -278,6 +279,28 @@ describe("Combobox", () => {
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging. // page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a // E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
// picker whose pick was still being written took another pick. // picker whose pick was still being written took another pick.
// E28 review F5: a chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
// out of the picker. The focus goes to the next chip's × (else the previous one, else the field).
describe("Combobox · removing a chip from the keyboard", () => {
function Harness({ initial }: { initial: string[] }) {
const [sel, setSel] = useState(initial);
return <Combobox label="Genre" options={OPTIONS} selected={sel} onChange={setSel} />;
}
const x = (name: string) =>
screen
.getAllByRole("button", { name: "common.removeNamed" })
.find((b) => b.closest("span")?.textContent?.includes(name))!;
it("hands the focus to the neighbour chip, then to the field", () => {
wrap(<Harness initial={["Drama", "Action"]} />);
x("Drama").focus();
fireEvent.click(x("Drama"), { detail: 0 });
expect(document.activeElement).toBe(x("Action"));
fireEvent.click(x("Action"), { detail: 0 });
expect(document.activeElement).toBe(screen.getByRole("combobox"));
});
});
describe("Combobox · honest states", () => { describe("Combobox · honest states", () => {
it("says the list failed to load, not that nothing matched", () => { it("says the list failed to load, not that nothing matched", () => {
wrap( wrap(
+1 -1
View File
@@ -70,7 +70,7 @@ export default function PanelGroup({
<DragHandle <DragHandle
{...attributes} {...attributes}
{...listeners} {...listeners}
label={t("sidebar.dragToReorder")} label={t("common.order.dragNamed", { name: title })}
className="-ml-1" className="-ml-1"
/> />
)} )}
@@ -7,7 +7,12 @@ import { render, screen, fireEvent, cleanup, within } from "@testing-library/rea
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key),
}),
};
}); });
import PanelGroups, { type PanelItem } from "./PanelGroups"; import PanelGroups, { type PanelItem } from "./PanelGroups";
@@ -56,3 +61,12 @@ describe("PanelGroups — reordering without a drag", () => {
expect(screen.queryByRole("button", { name: "common.order.up" })).toBeNull(); expect(screen.queryByRole("button", { name: "common.order.up" })).toBeNull();
}); });
}); });
// E28 review F4: every group's grip was "Drag to reorder" — a screen reader heard the same name five
// times and could not tell which group it would lift. It names its group, as the playlist grips do.
describe("PanelGroups — the drag grip", () => {
it("is named by its group", () => {
mount(["a", "b", "c"]);
expect(screen.getByRole("button", { name: "common.order.dragNamed:Group b" })).toBeTruthy();
});
});
@@ -78,12 +78,31 @@ describe("SidePanel · a mouse on a wide window", () => {
it("pins from the tab, as before", () => { it("pins from the tab, as before", () => {
stubMedia({ coarse: false, narrow: false, wide: true }); stubMedia({ coarse: false, narrow: false, wide: true });
const { onToggleCollapse } = mount(); const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" })); fireEvent.click(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }));
expect(onToggleCollapse).toHaveBeenCalledOnce(); expect(onToggleCollapse).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
}); });
}); });
// E28 review F6: the pinned (or peeking) card covers the tab, which only goes transparent — yet it
// stayed a Tab stop, an invisible control the focus landed on, named just "Unpin panel".
describe("SidePanel · the tab under an open card", () => {
it("is out of the Tab order while the card is open, and names its panel", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
mount(false); // pinned
const tab = screen.getByRole("button", { name: "sidebar.unpinNamed:Filters", hidden: true });
expect(tab.tabIndex).toBe(-1);
cleanup();
mount(true);
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
});
});
describe("SidePanel · on touch", () => { describe("SidePanel · on touch", () => {
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => { it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
stubMedia({ coarse: true, narrow: false, wide: false }); stubMedia({ coarse: true, narrow: false, wide: false });
+20 -5
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react"; import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react"; import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
@@ -64,6 +64,7 @@ export default function SidePanel({
children: ReactNode; children: ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const cardRef = useRef<HTMLElement>(null);
const fade = useScrollFade(); const fade = useScrollFade();
const peek = useHoverFocusWithin(); const peek = useHoverFocusWithin();
const drawerMode = useMediaQuery(DRAWER_QUERY, false); const drawerMode = useMediaQuery(DRAWER_QUERY, false);
@@ -143,16 +144,29 @@ export default function SidePanel({
hover that opened the card, and unmounting it while focused would throw keyboard focus to hover that opened the card, and unmounting it while focused would throw keyboard focus to
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through. this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */} `opacity-0` (not `invisible`/`hidden`) keeps it hoverable, and a focus already on it stays.
But no Tab lands on it while the card covers it (E28 review F6): an invisible stop, where the
card's own pin says the same. A keyboard pin hands the focus into the card it opened. */}
<Pressable <Pressable
{...(drawerMode ? {} : peek.hoverHandlers)} {...(drawerMode ? {} : peek.hoverHandlers)}
onClick={drawerMode ? () => setDrawerOpen(true) : onToggleCollapse} onClick={
drawerMode
? () => setDrawerOpen(true)
: (e) => {
onToggleCollapse();
if (e.detail === 0)
requestAnimationFrame(() =>
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
);
}
}
tabIndex={expanded ? -1 : undefined}
aria-label={ aria-label={
drawerMode drawerMode
? t("sidebar.openPanel", { title }) ? t("sidebar.openPanel", { title })
: pinned : pinned
? t("sidebar.unpin") ? t("sidebar.unpinNamed", { title })
: t("sidebar.pin") : t("sidebar.pinNamed", { title })
} }
aria-haspopup={drawerMode ? "dialog" : undefined} aria-haspopup={drawerMode ? "dialog" : undefined}
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${ className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${
@@ -175,6 +189,7 @@ export default function SidePanel({
{expanded && ( {expanded && (
<aside <aside
ref={cardRef}
{...peek.hoverHandlers} {...peek.hoverHandlers}
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`} className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
> >
+2 -1
View File
@@ -54,7 +54,8 @@
"top": "Move to top", "top": "Move to top",
"up": "Move up", "up": "Move up",
"down": "Move down", "down": "Move down",
"bottom": "Move to bottom" "bottom": "Move to bottom",
"dragNamed": "Reorder “{{name}}”"
}, },
"copy": { "copy": {
"title": "Copy title", "title": "Copy title",
+2 -1
View File
@@ -6,13 +6,14 @@
"customize": "Customize sidebar", "customize": "Customize sidebar",
"done": "Done", "done": "Done",
"editHint": "Drag or use the arrows to reorder · eye to show/hide", "editHint": "Drag or use the arrows to reorder · eye to show/hide",
"dragToReorder": "Drag to reorder",
"showWidget": "Show widget", "showWidget": "Show widget",
"hideWidget": "Hide widget", "hideWidget": "Hide widget",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"pin": "Pin panel open", "pin": "Pin panel open",
"unpin": "Unpin panel", "unpin": "Unpin panel",
"pinNamed": "Pin {{title}} open",
"unpinNamed": "Unpin {{title}}",
"openPanel": "Open {{title}}", "openPanel": "Open {{title}}",
"from": "From", "from": "From",
"to": "To", "to": "To",
-1
View File
@@ -6,7 +6,6 @@
"rename": "Rename", "rename": "Rename",
"delete": "Delete", "delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?", "deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again", "actionFailed": "Couldn't update your saved views — please try again",
"reorderFailed": "Couldn't save the new order — please try again", "reorderFailed": "Couldn't save the new order — please try again",
"saved": "Saved views", "saved": "Saved views",
+2 -1
View File
@@ -54,7 +54,8 @@
"top": "Legfelülre", "top": "Legfelülre",
"up": "Eggyel feljebb", "up": "Eggyel feljebb",
"down": "Eggyel lejjebb", "down": "Eggyel lejjebb",
"bottom": "Legalulra" "bottom": "Legalulra",
"dragNamed": "„{{name}}” átrendezése"
}, },
"copy": { "copy": {
"title": "Cím másolása", "title": "Cím másolása",
+2 -1
View File
@@ -6,13 +6,14 @@
"customize": "Oldalsáv testreszabása", "customize": "Oldalsáv testreszabása",
"done": "Kész", "done": "Kész",
"editHint": "Húzd vagy a nyilakkal rendezd át · szem a megjelenítéshez/elrejtéshez", "editHint": "Húzd vagy a nyilakkal rendezd át · szem a megjelenítéshez/elrejtéshez",
"dragToReorder": "Húzd az átrendezéshez",
"showWidget": "Modul megjelenítése", "showWidget": "Modul megjelenítése",
"hideWidget": "Modul elrejtése", "hideWidget": "Modul elrejtése",
"expand": "Kibontás", "expand": "Kibontás",
"collapse": "Összecsukás", "collapse": "Összecsukás",
"pin": "Panel rögzítése nyitva", "pin": "Panel rögzítése nyitva",
"unpin": "Panel rögzítésének feloldása", "unpin": "Panel rögzítésének feloldása",
"pinNamed": "{{title}} rögzítése nyitva",
"unpinNamed": "{{title}} rögzítésének feloldása",
"openPanel": "{{title}} megnyitása", "openPanel": "{{title}} megnyitása",
"from": "Ettől", "from": "Ettől",
"to": "Eddig", "to": "Eddig",
-1
View File
@@ -6,7 +6,6 @@
"rename": "Átnevezés", "rename": "Átnevezés",
"delete": "Törlés", "delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?", "deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra", "actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra", "reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra",
"saved": "Mentett nézetek", "saved": "Mentett nézetek",
+15
View File
@@ -29,6 +29,21 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
return null; return null;
} }
/** Move the focus off `el`, which is about to go, to its nearest focusable neighbour — the next one,
* else the previous one, widening to the parent's neighbours — so its removal does not drop the
* focus to <body>. For a removal the caller makes itself (a chip's ×) anywhere, including a modal
* or a side panel, where the keeper below does not watch. False when nothing was found. */
export function focusNeighbour(el: HTMLElement): boolean {
for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) {
const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true);
if (hit) {
hit.focus({ preventScroll: true });
return true;
}
}
return false;
}
// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page // The element the keeper last put the focus on, while it still holds it. A Back that swaps the page
// or a sub-view removes the held control too, and the keeper's stand-in then sat where // or a sub-view removes the held control too, and the keeper's stand-in then sat where
// `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on // `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on