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