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) => {
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
@@ -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 () => {
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
mount();
const grip = (await screen.findAllByRole("button", { name: "views.reorder" }))[0]!;
const grip = await screen.findByRole("button", { name: "common.order.dragNamed:Docs" });
grip.focus();
fireEvent.keyDown(grip, { code: "Space", key: " " });
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
@@ -62,7 +62,11 @@ function ViewRow({
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
selectable
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);
}, [dep, threshold]);
const toTop = () =>
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({
top: 0,
behavior: "smooth",
});
// A keyboard press hides the button it was on: the focus goes to the page it scrolled (its <main>
// is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7).
const toTop = (e: React.MouseEvent) => {
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)
// 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
align="center"
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 ${
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 { X } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
import { focusNeighbour } from "../../lib/useFocusKeeper";
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
@@ -113,7 +114,14 @@ export function DismissibleChip({
)}
<FocusSafeButton
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}
title={dismissLabel}
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 { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useState } from "react";
beforeAll(() => {
class Stub {
@@ -278,6 +279,28 @@ describe("Combobox", () => {
// 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
// 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", () => {
it("says the list failed to load, not that nothing matched", () => {
wrap(
+1 -1
View File
@@ -70,7 +70,7 @@ export default function PanelGroup({
<DragHandle
{...attributes}
{...listeners}
label={t("sidebar.dragToReorder")}
label={t("common.order.dragNamed", { name: title })}
className="-ml-1"
/>
)}
@@ -7,7 +7,12 @@ import { render, screen, fireEvent, cleanup, within } from "@testing-library/rea
vi.mock("react-i18next", async (importOriginal) => {
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";
@@ -56,3 +61,12 @@ describe("PanelGroups — reordering without a drag", () => {
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", () => {
stubMedia({ coarse: false, narrow: false, wide: true });
const { onToggleCollapse } = mount();
fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" }));
fireEvent.click(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }));
expect(onToggleCollapse).toHaveBeenCalledOnce();
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", () => {
it("opens a drawer from the tab instead of pinning, and Esc closes it", () => {
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 { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
import { useScrollFade } from "../../lib/useScrollFade";
@@ -64,6 +64,7 @@ export default function SidePanel({
children: ReactNode;
}) {
const { t } = useTranslation();
const cardRef = useRef<HTMLElement>(null);
const fade = useScrollFade();
const peek = useHoverFocusWithin();
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
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.
`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
{...(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={
drawerMode
? t("sidebar.openPanel", { title })
: pinned
? t("sidebar.unpin")
: t("sidebar.pin")
? t("sidebar.unpinNamed", { title })
: t("sidebar.pinNamed", { title })
}
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 ${
@@ -175,6 +189,7 @@ export default function SidePanel({
{expanded && (
<aside
ref={cardRef}
{...peek.hoverHandlers}
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",
"up": "Move up",
"down": "Move down",
"bottom": "Move to bottom"
"bottom": "Move to bottom",
"dragNamed": "Reorder “{{name}}”"
},
"copy": {
"title": "Copy title",
+2 -1
View File
@@ -6,13 +6,14 @@
"customize": "Customize sidebar",
"done": "Done",
"editHint": "Drag or use the arrows to reorder · eye to show/hide",
"dragToReorder": "Drag to reorder",
"showWidget": "Show widget",
"hideWidget": "Hide widget",
"expand": "Expand",
"collapse": "Collapse",
"pin": "Pin panel open",
"unpin": "Unpin panel",
"pinNamed": "Pin {{title}} open",
"unpinNamed": "Unpin {{title}}",
"openPanel": "Open {{title}}",
"from": "From",
"to": "To",
-1
View File
@@ -6,7 +6,6 @@
"rename": "Rename",
"delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again",
"reorderFailed": "Couldn't save the new order — please try again",
"saved": "Saved views",
+2 -1
View File
@@ -54,7 +54,8 @@
"top": "Legfelülre",
"up": "Eggyel feljebb",
"down": "Eggyel lejjebb",
"bottom": "Legalulra"
"bottom": "Legalulra",
"dragNamed": "„{{name}}” átrendezése"
},
"copy": {
"title": "Cím másolása",
+2 -1
View File
@@ -6,13 +6,14 @@
"customize": "Oldalsáv testreszabása",
"done": "Kész",
"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",
"hideWidget": "Modul elrejtése",
"expand": "Kibontás",
"collapse": "Összecsukás",
"pin": "Panel rögzítése nyitva",
"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",
"from": "Ettől",
"to": "Eddig",
-1
View File
@@ -6,7 +6,6 @@
"rename": "Átnevezés",
"delete": "Törlés",
"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",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra",
"saved": "Mentett nézetek",
+15
View File
@@ -29,6 +29,21 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | 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
// 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