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
|
<button
|
||||||
onClick={() => resend.mutate()}
|
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"
|
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" />
|
<Send className="w-4 h-4 shrink-0" />
|
||||||
|
|||||||
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSubscribe(c)}
|
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
|
||||||
disabled={!canWrite || subscribe.isPending}
|
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||||
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"
|
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" />
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
{t("channels.discovery.subscribe")}
|
{t("channels.discovery.subscribe")}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @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
|
// 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
|
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||||
// still pressed only once.
|
// still pressed only once.
|
||||||
@@ -52,42 +53,47 @@ const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
|||||||
|
|
||||||
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["channel.block", "blockChannel"],
|
["channel.block", "blockChannel", true],
|
||||||
["channel.hide", "updateChannel"],
|
["channel.hide", "updateChannel", true],
|
||||||
] as const)("%s stays focusable and fires once while its request runs", async (label, call) => {
|
["channel.subscribe", "subscribeChannel", false],
|
||||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
["channel.subscribedState", "unsubscribeChannel", true],
|
||||||
id: CH,
|
] as const)(
|
||||||
title: "LEMMiNO",
|
"%s stays focusable and fires once while its request runs",
|
||||||
subscribed: true,
|
async (label, call, subscribed) => {
|
||||||
blocked: false,
|
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||||
hidden: false,
|
id: CH,
|
||||||
deep_requested: false,
|
title: "LEMMiNO",
|
||||||
backfill_done: true,
|
subscribed,
|
||||||
stored_videos: 10,
|
blocked: false,
|
||||||
} as unknown as ChannelDetail);
|
hidden: false,
|
||||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
deep_requested: false,
|
||||||
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
backfill_done: true,
|
||||||
render(
|
stored_videos: 10,
|
||||||
<QueryClientProvider
|
} as unknown as ChannelDetail);
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||||
>
|
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
||||||
<ChannelPage
|
render(
|
||||||
channelId={CH}
|
<QueryClientProvider
|
||||||
initialName="LEMMiNO"
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
me={{ id: 1, can_write: true } as unknown as Me}
|
>
|
||||||
view="cards"
|
<ChannelPage
|
||||||
setView={() => {}}
|
channelId={CH}
|
||||||
onShowInFeed={() => {}}
|
initialName="LEMMiNO"
|
||||||
/>
|
me={{ id: 1, can_write: true } as unknown as Me}
|
||||||
</QueryClientProvider>,
|
view="cards"
|
||||||
);
|
setView={() => {}}
|
||||||
const button = await screen.findByRole("button", { name: label });
|
onShowInFeed={() => {}}
|
||||||
act(() => button.focus());
|
/>
|
||||||
fireEvent.click(button);
|
</QueryClientProvider>,
|
||||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
);
|
||||||
expect(button.hasAttribute("disabled")).toBe(false);
|
const button = await screen.findByRole("button", { name: label });
|
||||||
expect(document.activeElement).toBe(button);
|
act(() => button.focus());
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
expect(pending).toHaveBeenCalledTimes(1);
|
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||||
});
|
expect(button.hasAttribute("disabled")).toBe(false);
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(pending).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -481,14 +481,16 @@ export default function ChannelPage({
|
|||||||
<button
|
<button
|
||||||
// Busy blocks the click, not the focus: a focused button that turns natively
|
// 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).
|
// `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()}
|
onClick={() => !block.isPending && block.mutate()}
|
||||||
aria-disabled={block.isPending || undefined}
|
aria-disabled={block.isPending || undefined}
|
||||||
|
aria-busy={block.isPending || undefined}
|
||||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
aria-label={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 ${
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||||
ch?.blocked
|
ch?.blocked
|
||||||
? "border-danger text-danger"
|
? "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" />
|
<Ban className="w-4 h-4" />
|
||||||
@@ -501,12 +503,13 @@ export default function ChannelPage({
|
|||||||
<button
|
<button
|
||||||
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
||||||
aria-disabled={toggleHidden.isPending || undefined}
|
aria-disabled={toggleHidden.isPending || undefined}
|
||||||
|
aria-busy={toggleHidden.isPending || undefined}
|
||||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
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 ${
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||||
ch.hidden
|
ch.hidden
|
||||||
? "border-accent text-accent"
|
? "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" />}
|
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
@@ -518,7 +521,7 @@ export default function ChannelPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
disabled={unsubscribe.isPending}
|
loading={unsubscribe.isPending}
|
||||||
title={t("channel.unsubscribe")}
|
title={t("channel.unsubscribe")}
|
||||||
icon={<Check className="w-4 h-4" />}
|
icon={<Check className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
@@ -528,7 +531,7 @@ export default function ChannelPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => subscribe.mutate()}
|
onClick={() => subscribe.mutate()}
|
||||||
disabled={subscribe.isPending}
|
loading={subscribe.isPending}
|
||||||
icon={<Plus className="w-4 h-4" />}
|
icon={<Plus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{t("channel.subscribe")}
|
{t("channel.subscribe")}
|
||||||
@@ -575,9 +578,11 @@ export default function ChannelPage({
|
|||||||
{!ch?.subscribed && nextToken && (
|
{!ch?.subscribed && nextToken && (
|
||||||
<div className="flex justify-center pb-6">
|
<div className="flex justify-center pb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => runExplore(nextToken)}
|
// Busy blocks the click, not the focus (see Block above).
|
||||||
disabled={exploring}
|
onClick={() => !exploring && runExplore(nextToken)}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
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 ? (
|
{exploring ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function ConversationList({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.partner.id}
|
key={c.partner.id}
|
||||||
{...navItemAttrs(i)}
|
{...navItemAttrs(i, c.partner.name)}
|
||||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// A split export that fails part-way (deep-review E11 round 2). Every clip is its own job, committed as
|
// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job,
|
||||||
// it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor reported a
|
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
|
||||||
// plain failure and kept both selected — and trying again queued clip 1 a second time. It now says how
|
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
|
||||||
// many made it and deselects them, so another try adds only the rest.
|
// 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 { 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";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
@@ -20,6 +21,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
|
|
||||||
import VideoEditor from "./VideoEditor";
|
import VideoEditor from "./VideoEditor";
|
||||||
import { api, HttpError, type DownloadJob } from "../lib/api";
|
import { api, HttpError, type DownloadJob } from "../lib/api";
|
||||||
|
import { markReported } from "../lib/api/core";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -48,10 +50,16 @@ const job = {
|
|||||||
describe("VideoEditor — a split export that fails part-way", () => {
|
describe("VideoEditor — a split export that fails part-way", () => {
|
||||||
it("says how many clips made it, and a retry queues only the rest", async () => {
|
it("says how many clips made it, and a retry queues only the rest", async () => {
|
||||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||||
|
let refuse: (e: unknown) => void = () => {};
|
||||||
const enqueue = vi
|
const enqueue = vi
|
||||||
.spyOn(api, "enqueueEdit")
|
.spyOn(api, "enqueueEdit")
|
||||||
.mockResolvedValueOnce({} as never)
|
.mockResolvedValueOnce({} as never)
|
||||||
.mockRejectedValueOnce(new HttpError(422, "errors.generic", { code: "quota" }))
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
refuse = reject;
|
||||||
|
}) as never,
|
||||||
|
)
|
||||||
.mockResolvedValue({} as never);
|
.mockResolvedValue({} as never);
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
@@ -59,23 +67,37 @@ describe("VideoEditor — a split export that fails part-way", () => {
|
|||||||
</QueryClientProvider>,
|
</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")!;
|
const video = document.querySelector("video")!;
|
||||||
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
|
const playhead = (s: number) => {
|
||||||
fireEvent.timeUpdate(video);
|
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.splitHere"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("editor.createN"));
|
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(() =>
|
await waitFor(() =>
|
||||||
expect(notify).toHaveBeenCalledWith(
|
expect(notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
|
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"));
|
fireEvent.click(screen.getByText("editor.create"));
|
||||||
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
|
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].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 { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||||
import { inputCls, btnCls } from "./ui/form";
|
import { inputCls, btnCls } from "./ui/form";
|
||||||
import { Button } from "./ui/Button";
|
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.
|
// mm:ss.d timecode <-> seconds.
|
||||||
function tc(s: number): string {
|
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.
|
// 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 }]);
|
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 [selId, setSelId] = useState(0);
|
||||||
const [output, setOutput] = useState<"separate" | "join">("separate");
|
const [output, setOutput] = useState<"separate" | "join">("separate");
|
||||||
const [cropOn, setCropOn] = useState(false);
|
const [cropOn, setCropOn] = useState(false);
|
||||||
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
// --- cut-list mutations ---
|
// --- cut-list mutations ---
|
||||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||||
const splitAtPlayhead = () => {
|
const splitAtPlayhead = () => {
|
||||||
|
if (creating.current) return;
|
||||||
const time = current;
|
const time = current;
|
||||||
setSegments((segs) => {
|
setSegments((segs) => {
|
||||||
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
|
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) =>
|
const moveBoundary = (bi: number, time: number) =>
|
||||||
setSegments((segs) => {
|
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.
|
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
|
||||||
const lo = segs[bi]!.start + MIN_SEG;
|
const lo = segs[bi]!.start + MIN_SEG;
|
||||||
const hi = segs[bi + 1]!.end - 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) =>
|
const deleteBoundary = (bi: number) =>
|
||||||
setSegments((segs) => {
|
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 };
|
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
|
||||||
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
||||||
});
|
});
|
||||||
const toggleKeep = (id: number) =>
|
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 ---
|
// --- timeline drag (seek / boundary) + hover preview ---
|
||||||
const [drag, setDrag] = useState<Drag | null>(null);
|
const [drag, setDrag] = useState<Drag | null>(null);
|
||||||
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
creating.current = true;
|
||||||
const cp = cropPx();
|
const cp = cropPx();
|
||||||
const base = name.trim() || defaultName;
|
const base = name.trim() || defaultName;
|
||||||
if (willJoin) {
|
if (willJoin) {
|
||||||
@@ -307,6 +319,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
return { files: 1 };
|
return { files: 1 };
|
||||||
}
|
}
|
||||||
const N = kept.length;
|
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[] = [];
|
const done: number[] = [];
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < N; i++) {
|
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;
|
if (cp) spec.crop = cp;
|
||||||
else spec.accurate = accurate;
|
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 });
|
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||||
done.push(s.id);
|
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.downloads() });
|
||||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||||
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
|
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;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -342,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||||
|
onSettled: () => {
|
||||||
|
creating.current = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ const user = {
|
|||||||
is_suspended: false,
|
is_suspended: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function mount() {
|
function mount(u: typeof user = user) {
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<AccountPanel user={user as any} onClose={() => {}} />
|
<AccountPanel user={u as any} onClose={() => {}} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,3 +148,15 @@ describe("the account panel reports a refused recovery action", () => {
|
|||||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
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";
|
const running = job.status === "running";
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
|
||||||
>
|
>
|
||||||
<Thumb job={job} />
|
<Thumb job={job} />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function PlexPosterCard({
|
|||||||
<div
|
<div
|
||||||
className="group text-left"
|
className="group text-left"
|
||||||
{...menuProps?.(card)}
|
{...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).
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||||
>
|
>
|
||||||
@@ -262,7 +262,10 @@ export function SeasonCard({
|
|||||||
? t("plex.series.markSeasonUnwatched")
|
? t("plex.series.markSeasonUnwatched")
|
||||||
: t("plex.series.markSeasonWatched");
|
: t("plex.series.markSeasonWatched");
|
||||||
return (
|
return (
|
||||||
<div className="group text-left" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
|
<div
|
||||||
|
className="group text-left"
|
||||||
|
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -491,7 +494,7 @@ export function EpisodeCard({
|
|||||||
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex))}>
|
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
|
||||||
<div
|
<div
|
||||||
onClick={onPlay}
|
onClick={onPlay}
|
||||||
role="button"
|
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.
|
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
|
||||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
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 —
|
// 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
|
// 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).
|
// 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:
|
// 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
|
// 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.
|
// (`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 [returnTo] = useState(() => {
|
||||||
const active = document.activeElement;
|
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
|
? active
|
||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
const floatingRef = refs.floating;
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!returnTo) return;
|
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 () => {
|
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 active = document.activeElement;
|
||||||
const stillOurs =
|
const stillOurs =
|
||||||
!active ||
|
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
|
||||||
active === document.body ||
|
|
||||||
!!floating?.contains(active) ||
|
|
||||||
!!active.closest("[data-floating-ui-portal]");
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
|
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [returnTo, floatingRef]);
|
}, [returnTo]);
|
||||||
|
|
||||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
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 = (
|
const surface = (
|
||||||
<div
|
<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
|
// 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.
|
// 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
|
// 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() {
|
function scrollListToTop() {
|
||||||
document.querySelector("main")?.scrollTo({ top: 0 });
|
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
|
// 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.
|
// uses for the same reason. Clicks between the cards go through to what is underneath.
|
||||||
<div
|
<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"}
|
aria-live={isTop && armed ? "polite" : "off"}
|
||||||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
|
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"
|
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"not_applicable": "This account has nothing left to confirm.",
|
"not_applicable": "This account has nothing left to confirm.",
|
||||||
"not_approved": "Only an approved address can be invited.",
|
"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.",
|
"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."
|
"demo": "The demo account is shared, so it can't get a set-password link."
|
||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"not_applicable": "Ezen a fiókon már nincs mit megerősíteni.",
|
"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ó.",
|
"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.",
|
"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."
|
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket."
|
||||||
},
|
},
|
||||||
"reason": {
|
"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(a0.hasAttribute("aria-label")).toBe(false);
|
||||||
expect(screen.getByTestId("a1").getAttribute("aria-label")).toBe("title");
|
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.
|
// How long to wait for a virtualized item to mount after asking the list to reveal it.
|
||||||
const REVEAL_FRAMES = 10;
|
const REVEAL_FRAMES = 10;
|
||||||
|
|
||||||
/** Spread onto an item's ROOT element. */
|
/** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard
|
||||||
export const navItemAttrs = (index: number): { "data-nav-index": number } => ({
|
* 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,
|
[INDEX]: index,
|
||||||
|
...(name ? { "data-nav-name": name } : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
const indexOf = (item: Element): number => Number(item.getAttribute(INDEX));
|
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 };
|
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
|
// 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
|
// 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
|
// E11 round 2). A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put
|
||||||
// back from it, and what it did (a hidden card, a deleted row) is the removal the slot is kept for.
|
// up — does not count: focus comes back from it, and what it did (a hidden card, a deleted row, the
|
||||||
// Removing the focused node itself fires no focusin.
|
// 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"]')) last = null;
|
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
|
/** 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
|
// 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
|
// 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.
|
// 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 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 (
|
if (
|
||||||
el.tagName === "DIV" &&
|
el.tagName === "DIV" &&
|
||||||
name &&
|
name &&
|
||||||
|
|||||||
Reference in New Issue
Block a user