fix(ui): focus, paging and state that a key or a finger can reach

- A key landing on a side panel's tab steps into the card it peeks open, not onto the hidden tab.
- The Channels cards keep their status / pager row at the list's end while the band is folded.
- A menu row's note shows on an enabled row too: the download state under 'Download…', not a title.
- A removal chip hands the focus to its neighbour before it goes, for every caller (the feed's and
  Plex's active filters); the hidden-filter cue's own copy is gone.
- Back to a spot within a page's end spacer lands at the page's end, not its top.
- A running push / revert is tracked per list: another list's push is no longer refused unseen.
This commit is contained in:
2026-09-27 19:43:04 +02:00
parent e31a588f19
commit 740602b9bf
15 changed files with 231 additions and 48 deletions
@@ -42,16 +42,20 @@ describe("ActionMenu", () => {
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
it("says why a disabled row is off, under its label — and only on a disabled row", () => {
it("says under its label why a disabled row is off, and what an enabled one opens stands at", () => {
// A `title` on a disabled row reached nobody: a disabled button fires no pointer events and
// roving focus skips it. The reason is visible text now, undimmed so it can be read.
// roving focus skips it. The reason is visible text now, undimmed so it can be read. An enabled
// row's state ("Downloaded" under "Download…") is the same line: a `title` shows to a resting
// mouse only, never to a key or a finger (round 2, F5).
renderMenu([
{ id: "move", label: "Move to", onSelect: vi.fn(), disabled: true, note: "No other list" },
{ id: "sync", label: "Sync", onSelect: vi.fn(), note: "never shown" },
{ id: "dl", label: "Download…", onSelect: vi.fn(), note: "Downloaded" },
{ id: "sync", label: "Sync", onSelect: vi.fn() },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const [move, sync] = screen.getAllByRole("menuitem", { hidden: true });
const [move, dl, sync] = screen.getAllByRole("menuitem", { hidden: true });
expect(move!.textContent).toBe("Move toNo other list");
expect(dl!.textContent).toBe("Download…Downloaded");
expect(sync!.textContent).toBe("Sync");
const note = screen.getByText("No other list");
expect(note.closest(".opacity-50")).toBeNull();
+5 -5
View File
@@ -27,14 +27,12 @@ const ROW =
"flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition";
const dim = (disabled: boolean | undefined) => (disabled ? " opacity-50" : "");
/** The label, and under it — on a disabled row only — the reason it is off. */
/** The label, and under it the row's note: why it is off, or the state of what it opens. */
function RowLabel({ item }: { item: MenuItemBase }) {
return (
<span className="min-w-0 flex-1">
<span className={`block truncate${dim(item.disabled)}`}>{item.label}</span>
{item.disabled && item.note && (
<span className="block text-2xs leading-snug text-muted">{item.note}</span>
)}
{item.note && <span className="block text-2xs leading-snug text-muted">{item.note}</span>}
</span>
);
}
@@ -50,7 +48,9 @@ interface MenuItemBase {
* fires no pointer events (no tooltip) and roving focus skips it, so a reason there reached
* nobody. An `aria-disabled` row was tried and reverted (E10 review rounds 1–2: floating-ui skips
* it identically, and the mouse got a highlight that then no-opped). Visible text is the one
* place a disabled row's reason can be: seen, and read in a screen reader's browse mode. */
* place a disabled row's reason can be: seen, and read in a screen reader's browse mode.
* On an ENABLED row it is the state of what the row opens ("Downloaded" under "Download…"),
* for the same reason: a `title` shows to a resting mouse only (round 2, F5). */
note?: string;
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
separatorBefore?: boolean;
+30 -1
View File
@@ -3,7 +3,8 @@
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { Chip, DismissibleChip } from "./Chip";
import { chipClass } from "./chipStyles";
@@ -41,6 +42,34 @@ describe("Chip", () => {
expect(onClick).toHaveBeenCalledOnce();
});
// Round 2 (F6): the feed's and Plex's active-filter chips unmount with the filter they remove,
// and a keyboard press left the focus on <body>. The chip hands it on itself, for every caller.
it("a removing chip pressed from the keyboard hands the focus to its neighbour", () => {
function Row() {
const [chips, setChips] = useState(["Music", "News"]);
return (
<div>
{chips.map((c) => (
<Chip
key={c}
variant="token"
removeLabel={`Remove ${c}`}
onClick={() => setChips((all) => all.filter((x) => x !== c))}
>
{c}
</Chip>
))}
</div>
);
}
render(<Row />);
const music = screen.getByRole("button", { name: "Remove Music" });
act(() => music.focus());
fireEvent.click(music, { detail: 0 });
expect(screen.queryByRole("button", { name: "Remove Music" })).toBeNull();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove News" }));
});
it("the measuring mirror lays out exactly like the chip it measures", () => {
render(
<Chip variant="tag" size="xs">
+11 -1
View File
@@ -27,7 +27,8 @@ type ChipProps = ButtonHTMLAttributes<HTMLButtonElement> & {
pressed?: boolean;
/** Leading glyph (a lucide element). */
icon?: ReactNode;
/** The whole chip removes what it stands for: a trailing × and the removal as its name. */
/** The whole chip removes what it stands for: a trailing × and the removal as its name. A press
* that holds the focus hands it to the chip's neighbour before the removal, not to <body>. */
removeLabel?: string;
};
@@ -42,6 +43,7 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
type,
className,
children,
onClick,
...rest
},
ref,
@@ -54,6 +56,14 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
aria-label={removeLabel}
title={removeLabel}
className={clsx(chipClass(variant, size), removeLabel && "pr-1.5", className)}
onClick={(e) => {
// A removal chip unmounts with what it removes: from the keyboard it holds the focus, which
// went to <body> (round 2, F6 — the active-filter chips of the feed and Plex). Every
// removal chip gets it here, once, instead of each caller remembering.
if (removeLabel && e.currentTarget === document.activeElement)
focusNeighbour(e.currentTarget);
onClick?.(e);
}}
{...rest}
>
{icon}
@@ -1,7 +1,5 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { focusNeighbour } from "../../lib/useFocusKeeper";
import {
columnHideClass,
filterValueLabel,
@@ -62,11 +60,8 @@ export function HiddenFilterCue<T>({
variant="token"
className={cue(col)}
removeLabel={t("datatable.hiddenFilter.remove", { name: text })}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
// The chip goes with its filter: hand the focus on first, not to <body>.
focusNeighbour(e.currentTarget);
onFilter(col.key, Array.isArray(filters[col.key]) ? [] : "");
}}
// A removal chip hands its focus on before it goes (ui/Chip).
onClick={() => onFilter(col.key, Array.isArray(filters[col.key]) ? [] : "")}
>
<span className="max-w-56 truncate">{text}</span>
</Chip>
@@ -81,6 +81,50 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(200);
});
// Round 2 (F8): the page's very end, whose Back-to-top spacer only shows once scrolled, is out of
// reach when Back rebuilds the page — and the restore gave up with the page at the top.
it("Back to a spot within the end's spacer lands at the page's end, not its top", () => {
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 500; // the very end, the spacer showing
fireEvent.scroll(main);
setEntry(2);
notifyNav();
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 928 }); // no spacer
setEntry(1);
notifyNav();
expect(main.scrollTop).toBe(428);
});
it("a list still far short of the spot waits for it rather than settling early", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
const flush = (n: number) => {
for (let i = 0; i < n; i++) {
const run = frames;
frames = [];
for (const cb of run) cb(0);
}
};
setEntry(1);
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
const main = sizedMain(container);
main.scrollTop = 450;
fireEvent.scroll(main);
setEntry(2);
notifyNav();
flush(1);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 600 }); // loading
setEntry(1);
notifyNav();
flush(20); // held still, but 350px short: no answer yet
expect(main.scrollTop).toBe(0);
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 }); // loaded
flush(1);
expect(main.scrollTop).toBe(450);
});
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
setEntry(10);
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
+19 -2
View File
@@ -28,6 +28,12 @@ function rememberScroll(key: string, top: number): void {
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
// A spot the page can no longer reach — the last stretch of a page scrolled to its very end, whose
// Back-to-top spacer only shows once scrolled — goes as far as the page does: it used to be left at
// the top (round 2, F8). Only a spacer's worth short, and only once the height has held still, is
// that answer early; a list still loading is further short than that, and gets the full wait.
const SETTLED_FRAMES = 10;
const SPACER_REM = 6;
function restoreScroll(el: HTMLElement, top: number): () => void {
if (top <= 0) {
el.scrollTop = 0;
@@ -35,11 +41,22 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
}
let raf = 0;
let frames = 0;
let still = 0;
let lastMax = -1;
let cancelled = false;
const tick = () => {
if (cancelled) return;
if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
const max = el.scrollHeight - el.clientHeight;
if (max >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1) return; // reached
still = max === lastMax ? still + 1 : 0;
lastMax = max;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const nearEnd = max > 0 && top - max <= SPACER_REM * rem;
if ((nearEnd && still >= SETTLED_FRAMES) || frames++ > 90) {
el.scrollTop = max; // as near as the page reaches now
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
@@ -6,7 +6,7 @@
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
// asks from three switches, and can flip them live (a tablet turning).
import { describe, it, expect, afterEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -105,6 +105,21 @@ describe("SidePanel · the tab under an open card", () => {
mount(true);
expect(screen.getByRole("button", { name: "sidebar.pinNamed:Filters" }).tabIndex).toBe(0);
});
it("hands a key's focus on the tab to the card it peeks open, not to the hidden tab", async () => {
stubMedia({ coarse: false, narrow: false, wide: true });
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
mount(true);
const tab = screen.getByRole("button", { name: "sidebar.pinNamed:Filters" });
act(() => tab.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
await waitFor(() => expect(document.activeElement?.closest("aside")).not.toBeNull());
expect(document.activeElement?.getAttribute("aria-label")).toBe("sidebar.pinNamed:Filters");
expect(document.activeElement).not.toBe(tab);
});
});
describe("SidePanel · on touch", () => {
+9
View File
@@ -160,6 +160,15 @@ export default function SidePanel({
);
}
}
// A key landing on the tab peeks the card open over it, and the tab then goes inert below —
// with the focus still on it: a hidden control, out of the tree, that Enter still pinned
// (round 2, F1). The focus steps into the card instead, onto its pin: the same control.
onFocus={(e) => {
if (drawerMode || pinned || !e.currentTarget.matches(":focus-visible")) return;
requestAnimationFrame(() =>
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
);
}}
// Under an open card the tab is transparent and inert: out of the Tab order AND of the
// accessibility tree, where it would repeat the card's own pin button by name.
tabIndex={expanded ? -1 : undefined}