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
+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`}
>