fix(keys): epic-end review round 3 — the part-way clip count shows, busy buttons keep focus, cards say their names

Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed.

- VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was
  written for (req had already reported the error); it now always shows. The cut list holds still while
  Create queues, and a retry continues the "(i/N)" series.
- ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe
  block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on
  hover.
- listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message
  rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot.
- AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus
  back); the dead panel-ref check is gone.
- AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames
  the message for an unreachable server. Pager comment points at scrollParent.
This commit is contained in:
2026-09-13 20:47:13 +02:00
parent 3cd0c525e2
commit c6b552e75f
18 changed files with 312 additions and 98 deletions
+3 -1
View File
@@ -196,7 +196,9 @@ export default function AccountPanel({
<button
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified || resend.isPending}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
>
<Send className="w-4 h-4 shrink-0" />
+7 -3
View File
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
}
>
<button
onClick={() => onSubscribe(c)}
disabled={!canWrite || subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
onClick={() => !subscribe.isPending && onSubscribe(c)}
disabled={!canWrite}
aria-disabled={subscribe.isPending || undefined}
aria-busy={subscribe.isPending || undefined}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
//
// The channel page's Block and Hide toggles while their request runs (deep-review E11 round 2). A
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
// button (round 3: the same row, missed) while their request runs. A
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
@@ -52,13 +53,17 @@ const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
it.each([
["channel.block", "blockChannel"],
["channel.hide", "updateChannel"],
] as const)("%s stays focusable and fires once while its request runs", async (label, call) => {
["channel.block", "blockChannel", true],
["channel.hide", "updateChannel", true],
["channel.subscribe", "subscribeChannel", false],
["channel.subscribedState", "unsubscribeChannel", true],
] as const)(
"%s stays focusable and fires once while its request runs",
async (label, call, subscribed) => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed: true,
subscribed,
blocked: false,
hidden: false,
deep_requested: false,
@@ -89,5 +94,6 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(pending).toHaveBeenCalledTimes(1);
});
},
);
});
+12 -7
View File
@@ -481,14 +481,16 @@ export default function ChannelPage({
<button
// Busy blocks the click, not the focus: a focused button that turns natively
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
// Nor does a busy toggle light up on hover, as if a click would do something.
onClick={() => !block.isPending && block.mutate()}
aria-disabled={block.isPending || undefined}
aria-busy={block.isPending || undefined}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted hover:text-danger hover:border-danger"
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
}`}
>
<Ban className="w-4 h-4" />
@@ -501,12 +503,13 @@ export default function ChannelPage({
<button
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
aria-disabled={toggleHidden.isPending || undefined}
aria-busy={toggleHidden.isPending || undefined}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted hover:text-fg hover:border-fg/40"
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@@ -518,7 +521,7 @@ export default function ChannelPage({
<Button
variant="outline"
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
>
@@ -528,7 +531,7 @@ export default function ChannelPage({
<Button
variant="outline"
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
@@ -575,9 +578,11 @@ export default function ChannelPage({
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
// Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)}
aria-disabled={exploring || undefined}
aria-busy={exploring || undefined}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
+1 -1
View File
@@ -150,7 +150,7 @@ function ConversationList({
return (
<div
key={c.partner.id}
{...navItemAttrs(i)}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
>
<button
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
//
// A split export that fails part-way (deep-review E11 round 2). Every clip is its own job, committed as
// it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor reported a
// plain failure and kept both selected — and trying again queued clip 1 a second time. It now says how
// many made it and deselects them, so another try adds only the rest.
// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job,
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -20,6 +21,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../lib/api";
import { markReported } from "../lib/api/core";
beforeAll(() => {
class Stub {
@@ -48,10 +50,16 @@ const job = {
describe("VideoEditor — a split export that fails part-way", () => {
it("says how many clips made it, and a retry queues only the rest", async () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
let refuse: (e: unknown) => void = () => {};
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new HttpError(422, "errors.generic", { code: "quota" }))
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
)
.mockResolvedValue({} as never);
render(
<QueryClientProvider client={new QueryClient()}>
@@ -59,23 +67,37 @@ describe("VideoEditor — a split export that fails part-way", () => {
</QueryClientProvider>,
);
// Split at 50 s: the playhead is where the video element says it is.
// The playhead is where the video element says it is.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
playhead(50);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
// While the clips are queued the cut list holds still: a split now would be a range never queued.
playhead(75);
fireEvent.click(screen.getByText("editor.splitHere"));
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
const refused = new HttpError(422, "errors.generic", { code: "quota" });
markReported(refused);
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
),
);
expect(enqueue).toHaveBeenCalledTimes(2);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
// The queued first clip is deselected: one clip left to create, and only it is sent again.
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
// the second of the same two.
fireEvent.click(screen.getByText("editor.create"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
+35 -6
View File
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
import { notifyActionError } from "../lib/actionErrors";
import { failureReason, notifyActionError } from "../lib/actionErrors";
import { markReported } from "../lib/api/core";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -60,6 +61,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
// Create started with, so the cut list holds still until it is done: a split or a merge in between
// would deselect a range that was never queued, or keep one that was (deep-review E11 round 3).
const creating = useRef(false);
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
const series = useRef<Map<number, string> | null>(null);
const [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false);
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
@@ -147,7 +156,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
const moveBoundary = (bi: number, time: number) =>
setSegments((segs) => {
if (bi < 0 || bi >= segs.length - 1) return segs;
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
@@ -159,12 +168,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
});
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (segs.length < 2) return segs;
if (creating.current || segs.length < 2) return segs;
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
setSegments((segs) =>
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
);
// --- timeline drag (seek / boundary) + hover preview ---
const [drag, setDrag] = useState<Drag | null>(null);
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const create = useMutation({
mutationFn: async () => {
creating.current = true;
const cp = cropPx();
const base = name.trim() || defaultName;
if (willJoin) {
@@ -307,6 +319,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: 1 };
}
const N = kept.length;
const prev = series.current;
const suffix =
prev && kept.every((s) => prev.has(s.id))
? prev
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
series.current = suffix;
const done: number[] = [];
try {
for (let i = 0; i < N; i++) {
@@ -316,7 +334,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
done.push(s.id);
}
@@ -329,7 +347,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
notifyActionError(e, t("editor.partlyCreated", { done: done.length, total: N }));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
// what the dialog does not tell (deep-review E11 round 3).
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
}
throw e;
}
@@ -342,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
});
const sel = segments.find((s) => s.id === selId) ?? segments[0];
@@ -63,12 +63,12 @@ const user = {
is_suspended: false,
};
function mount() {
function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<AccountPanel user={user as any} onClose={() => {}} />
<AccountPanel user={u as any} onClose={() => {}} />
</QueryClientProvider>,
);
}
@@ -148,3 +148,15 @@ describe("the account panel reports a refused recovery action", () => {
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
});
});
describe("the account panel on the demo account", () => {
// The demo account's address is never confirmed, so "send the confirmation link again" looked
// available — and the server refused it as "nothing left to confirm", beside a row reading
// "unconfirmed" (deep-review E11 round 3).
it("does not offer it a confirmation mail, which the server refuses", async () => {
mount({ ...user, is_demo: true });
await screen.findByText("users.account.recovery");
const resend = screen.getByText("users.account.resend").closest("button")!;
expect(resend.hasAttribute("disabled")).toBe(true);
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ export function DownloadRow({
const running = job.status === "running";
return (
<div
{...(navIndex !== undefined && navItemAttrs(navIndex))}
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
>
<Thumb job={job} />
+6 -3
View File
@@ -79,7 +79,7 @@ export function PlexPosterCard({
<div
className="group text-left"
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
@@ -262,7 +262,10 @@ export function SeasonCard({
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched");
return (
<div className="group text-left" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
<div
className="group text-left"
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div
onClick={onOpen}
role="button"
@@ -491,7 +494,7 @@ export function EpisodeCard({
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
return (
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
<div
onClick={onPlay}
role="button"
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// Which surfaces give the focus back to a card focused as a whole (deep-review E11 rounds 2 and 3). A
// menu does, by hand — floating-ui's own return would land on the card's first link (see
// ContextMenu.dom.test). A hover card never returned focus, and one closing after the reader clicked a
// link in it must not pull the focus back to the card under it.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
function Card({ open }: { open: boolean }) {
const pop = useAnchoredPopover({ open, onOpenChange: () => {}, mode: "hover" });
return (
<>
<div tabIndex={-1} data-testid="card" ref={pop.refs.setReference}>
card
</div>
<PopoverSurface popover={pop}>
<a href="#more" data-testid="link">
more
</a>
</PopoverSurface>
</>
);
}
const page = (open: boolean) => (
<FloatingTree>
<Card open={open} />
</FloatingTree>
);
describe("AnchoredPopover · a hover card closing", () => {
it("does not hand the focus back to the card it was opened over", async () => {
const { rerender } = render(page(false));
const card = screen.getByTestId("card");
act(() => card.focus());
rerender(page(true));
act(() => screen.getByTestId("link").focus());
rerender(page(false));
await act(async () => {}); // the hand-back runs in a microtask
expect(document.activeElement).not.toBe(card);
});
});
+18 -14
View File
@@ -601,6 +601,11 @@ function PopoverSurfaceInner({
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
// Where focus goes back on close. floating-ui returns it to the element focused before the open —
// but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even
// when handed the element as a ref (its return runs through `getFirstTabbableElement` either way).
@@ -611,36 +616,35 @@ function PopoverSurfaceInner({
// panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed:
// focus the reader moved on to stays where they put it. A menu row's action runs a task later
// (`closeThenAct`), so a dialog it opens still captures the card as the element to return to.
//
// Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never
// returned focus, and a hover card closing after the reader clicked a link in it must not pull the
// focus back to the card under it (deep-review E11 round 3).
const [returnTo] = useState(() => {
const active = document.activeElement;
return active instanceof HTMLElement && active !== document.body && active.tabIndex < 0
return managed &&
active instanceof HTMLElement &&
active !== document.body &&
active.tabIndex < 0
? active
: null;
});
const floatingRef = refs.floating;
useLayoutEffect(() => {
if (!returnTo) return;
// Read once here: the panel's ref is attached before layout effects run, and cleared by the time
// this cleanup does.
const floating = floatingRef.current;
return () => {
// "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the
// panel a commit after this surface mounts, so the panel's ref is still empty when this effect
// runs (deep-review E11 round 3).
const active = document.activeElement;
const stillOurs =
!active ||
active === document.body ||
!!floating?.contains(active) ||
!!active.closest("[data-floating-ui-portal]");
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
queueMicrotask(() => {
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
});
};
}, [returnTo, floatingRef]);
}, [returnTo]);
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
const surface = (
<div
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Pager({
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
// The page scroller is the app's <main> (PageScroller documents itself as the ONE scroller; the
// same implicit contract BackToTop and VirtualGrid's getScrollParent already rely on).
// same implicit contract BackToTop and lib/listPaging's scrollParent (VirtualGrid, listNav) already rely on).
function scrollListToTop() {
document.querySelector("main")?.scrollTo({ top: 0 });
}
+2
View File
@@ -125,6 +125,8 @@ export default function Toaster({
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
// uses for the same reason. Clicks between the cards go through to what is underneath.
<div
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
data-toast-region=""
aria-live={isTop && armed ? "polite" : "off"}
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
+1 -1
View File
@@ -25,7 +25,7 @@
"not_applicable": "This account has nothing left to confirm.",
"not_approved": "Only an approved address can be invited.",
"email_off": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email / SMTP.",
"send_failed": "The mail server didn't accept the message. Check Configuration → Email / SMTP, then try again.",
"send_failed": "The mail couldn't be sent: the mail server was unreachable, or it refused the login or the message. Check Configuration → Email / SMTP, then try again.",
"demo": "The demo account is shared, so it can't get a set-password link."
},
"reason": {
+1 -1
View File
@@ -25,7 +25,7 @@
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni.",
"not_approved": "Csak jóváhagyott címre küldhető meghívó.",
"email_off": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Konfiguráció → E-mail / SMTP fület.",
"send_failed": "A levelezőszerver nem fogadta el a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.",
"send_failed": "Nem sikerült elküldeni a levelet: a levelezőszerver nem volt elérhető, vagy elutasította a belépést vagy a levelet. Nézd meg a Konfiguráció → E-mail / SMTP fület, majd próbáld újra.",
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket."
},
"reason": {
+50
View File
@@ -511,4 +511,54 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
expect(a0.hasAttribute("aria-label")).toBe(false);
expect(screen.getByTestId("a1").getAttribute("aria-label")).toBe("title");
});
// A Plex poster's open element is the whole card — badges, title, year, rating and summary — and a
// season card's holds only the hover caption "Open show": named from that text, every season was
// "Open show" (deep-review E11 round 3). The item's own name wins.
it("names a card by the name its item gives, over the text of what it opens", () => {
function Posters() {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref}>
<div {...navItemAttrs(0, "Season 1")} data-testid="p0">
<div role="button" tabIndex={0} data-nav-open="">
Open show
</div>
</div>
</div>
);
}
render(<Posters />);
press("j");
expect(screen.getByTestId("p0").getAttribute("aria-label")).toBe("Season 1");
});
// Hide a card, then Undo in the toast that put up: focus on the toast's button is not the reader
// leaving the list, and `j` goes on from the card put back (deep-review E11 round 3).
it("keeps the slot when focus went to the Undo of a toast", () => {
withLayout(
(_, n) => ({ left: 0, top: n * 100 }),
() => {
const page = (ids: number[]) => (
<>
<div data-toast-region="">
<button data-testid="undo">undo</button>
</div>
<Swappable ids={ids} />
</>
);
const { rerender } = render(page([0, 1, 2, 3]));
press("j");
press("j");
press("j");
rerender(page([0, 1, 3]));
act(() => screen.getByTestId("undo").focus());
rerender(page([0, 1, 2, 3]));
act(() => screen.getByTestId("undo").blur());
press("j");
expect(focused()).toBe("r2");
},
);
});
});
+22 -7
View File
@@ -44,9 +44,16 @@ const LIST = "data-nav-list";
// How long to wait for a virtualized item to mount after asking the list to reveal it.
const REVEAL_FRAMES = 10;
/** Spread onto an item's ROOT element. */
export const navItemAttrs = (index: number): { "data-nav-index": number } => ({
/** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard
* is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster:
* badges, year, rating and summary run together), shows a caption that is not the item's name, or
* holds no text at all (deep-review E11 round 3). */
export const navItemAttrs = (
index: number,
name?: string | null,
): { "data-nav-index": number; "data-nav-name"?: string } => ({
[INDEX]: index,
...(name ? { "data-nav-name": name } : null),
});
const indexOf = (item: Element): number => Number(item.getAttribute(INDEX));
@@ -133,10 +140,11 @@ function onFocusIn(e: FocusEvent): void {
if (item && list) last = { list, index: indexOf(item), el: item };
// Focus went on to page content that is not an item — the search box, a filter chip: the reader
// left the list, and whatever it shows next (new results) is not "the slot they were in" (deep-review
// E11 round 2). A layer opened FROM the item — its menu, a confirm — does not count: focus comes
// back from it, and what it did (a hidden card, a deleted row) is the removal the slot is kept for.
// Removing the focused node itself fires no focusin.
else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"]')) last = null;
// E11 round 2). A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put
// up — does not count: focus comes back from it, and what it did (a hidden card, a deleted row, the
// card put back) is what the slot is kept for. Removing the focused node itself fires no focusin.
else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"], [data-toast-region]'))
last = null;
}
/** Where `j` (`k`: the item before) lands after the last-focused item left the DOM: the item now in
@@ -171,8 +179,15 @@ function focusItem(el: HTMLElement, reveal = true): void {
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
// opens (deep-review E11 round 2). A row (<tr>) keeps its own role.
// The item's own name when it gives one (`navItemAttrs`), else the open element's label or text:
// a title link's text is the title (deep-review E11 round 3).
const open = el.querySelector("[data-nav-open]");
const name = (open?.getAttribute("aria-label") || open?.textContent || "").trim();
const name = (
el.getAttribute("data-nav-name") ||
open?.getAttribute("aria-label") ||
open?.textContent ||
""
).trim();
if (
el.tagName === "DIV" &&
name &&