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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user