fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped), plus the admin refusal copy gap round 1 surfaced. - A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link). - Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists. - The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after new results starts at the first visible item; a menu or dialog opened from the item keeps it. - The list keys reach the cheat sheet only while a list has an item. - A j-focused card root is a named group while it holds the focus. - The channel page's Block and Hide toggles keep the focus while their request runs. - A split export that fails part-way says how many clips were added and deselects them, so a retry adds only the rest. - Config save, the one-click share link and "log out other sessions" report failures with their reason. - VirtualGrid uses the one scroll-parent walk listNav measures against. - Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again"; the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail. HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
This commit is contained in:
@@ -7,9 +7,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
@@ -73,10 +73,7 @@ export default function AccountPanel({
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
isEmailOff(err)
|
||||
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
|
||||
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
@@ -319,12 +319,6 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the invitation fail because this instance has no working SMTP? */
|
||||
function inviteEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "invite_send" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||
//
|
||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||
@@ -360,12 +354,9 @@ function PendingAccess() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||
},
|
||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
||||
onError: (err) =>
|
||||
isEmailOff(err)
|
||||
? notifyKnownFailure(err, t("users.pendingAccess.resendEmailOff"))
|
||||
: notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
|
||||
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
});
|
||||
|
||||
const list = rows.data ?? [];
|
||||
@@ -591,10 +582,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||
},
|
||||
onError: (err) =>
|
||||
err instanceof HttpError && inviteEmailOff(err)
|
||||
? notifyKnownFailure(err, t("settings.invites.inviteEmailOff"))
|
||||
: notifyActionError(err, t("settings.invites.inviteFailed")),
|
||||
onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
|
||||
});
|
||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's Block and Hide toggles while their request runs (deep-review E11 round 2). 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.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../lib/api";
|
||||
|
||||
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();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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) => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const button = await screen.findByRole("button", { name: label });
|
||||
act(() => button.focus());
|
||||
fireEvent.click(button);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -479,11 +479,13 @@ export default function ChannelPage({
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
onClick={() => block.mutate()}
|
||||
disabled={block.isPending}
|
||||
// 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={() => !block.isPending && block.mutate()}
|
||||
aria-disabled={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 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
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||
@@ -497,11 +499,11 @@ export default function ChannelPage({
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<button
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
disabled={toggleHidden.isPending}
|
||||
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
||||
aria-disabled={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 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
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-fg/40"
|
||||
|
||||
@@ -113,9 +113,9 @@ export default function ConfigPanel() {
|
||||
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||
setSaveState("saved");
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setSaveState("error");
|
||||
notify({ level: "error", message: t("config.saveFailed") });
|
||||
notifyActionError(e, t("config.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,8 +384,8 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
try {
|
||||
await api.logoutOthers();
|
||||
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||
} catch {
|
||||
notify({ level: "error", message: t("settings.account.sessions.failed") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||
} finally {
|
||||
setSessBusy(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @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.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import VideoEditor from "./VideoEditor";
|
||||
import { api, HttpError, type DownloadJob } from "../lib/api";
|
||||
|
||||
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();
|
||||
vi.restoreAllMocks();
|
||||
notify.mockReset();
|
||||
});
|
||||
|
||||
const job = {
|
||||
id: 7,
|
||||
display_name: "Talk",
|
||||
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
|
||||
} as unknown as DownloadJob;
|
||||
|
||||
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);
|
||||
const enqueue = vi
|
||||
.spyOn(api, "enqueueEdit")
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new HttpError(422, "errors.generic", { code: "quota" }))
|
||||
.mockResolvedValue({} as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Split at 50 s: 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 });
|
||||
fireEvent.timeUpdate(video);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
|
||||
fireEvent.click(screen.getByText("editor.createN"));
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
|
||||
),
|
||||
);
|
||||
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The queued first clip is deselected: one clip left to create, and only it is sent again.
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -307,13 +307,31 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return { files: 1 };
|
||||
}
|
||||
const N = kept.length;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
const done: number[] = [];
|
||||
try {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||
const spec: EditSpec = {
|
||||
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
||||
};
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
done.push(s.id);
|
||||
}
|
||||
} catch (e) {
|
||||
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
|
||||
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
|
||||
// so trying again queued the first two a second time. Say how many made it, and deselect them
|
||||
// so another try adds only the rest.
|
||||
if (done.length) {
|
||||
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 }));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return { files: N };
|
||||
},
|
||||
|
||||
@@ -107,13 +107,21 @@ describe("the account panel reports a refused recovery action", () => {
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
|
||||
@@ -331,16 +331,20 @@ describe("inviting someone the admin whitelisted", () => {
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
||||
expect.objectContaining({
|
||||
title: "settings.invites.inviteFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,17 +159,21 @@ describe("the stuck sign-ups section", () => {
|
||||
const { HttpError } = await import("../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast.
|
||||
// back on. A button the admin pressed must not answer with a success toast. `req` has already
|
||||
// localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -600,6 +601,41 @@ function PopoverSurfaceInner({
|
||||
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
// 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).
|
||||
// A card the keyboard is on (lib/listNav gives its root tabindex=-1) is exactly that: measured in
|
||||
// Chrome (deep-review E11 round 2), j, Shift+F10, Escape left focus on the card's thumbnail link, the
|
||||
// ring off the card and the next Enter on a control. So for such an element the library's return is
|
||||
// off and this gives the focus back to it as it is — captured at open, before focus moves into the
|
||||
// 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.
|
||||
const [returnTo] = useState(() => {
|
||||
const active = document.activeElement;
|
||||
return 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 () => {
|
||||
const active = document.activeElement;
|
||||
const stillOurs =
|
||||
!active ||
|
||||
active === document.body ||
|
||||
!!floating?.contains(active) ||
|
||||
!!active.closest("[data-floating-ui-portal]");
|
||||
queueMicrotask(() => {
|
||||
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, [returnTo, floatingRef]);
|
||||
|
||||
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
|
||||
@@ -666,7 +702,7 @@ function PopoverSurfaceInner({
|
||||
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
|
||||
// input, so such a menu still has a keyboard entry point instead of stranding focus.
|
||||
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
|
||||
returnFocus
|
||||
returnFocus={!returnTo}
|
||||
>
|
||||
{positioned}
|
||||
</FloatingFocusManager>
|
||||
|
||||
@@ -130,6 +130,42 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
// A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and
|
||||
// holds tabbable controls. floating-ui handed the focus back to the first of them instead of the
|
||||
// card — measured in Chrome (deep-review E11 round 2): j, Shift+F10, Escape left it on the thumbnail.
|
||||
// Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened
|
||||
// menu — Escape is measured in the browser).
|
||||
it("gives the focus back to a card focused as a whole, not to a control inside it", async () => {
|
||||
function Card() {
|
||||
const menu = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<div tabIndex={-1} data-testid="card" {...menu.triggerProps("card")}>
|
||||
<a href="#thumb">thumbnail</a>
|
||||
</div>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel="Card actions"
|
||||
items={menu.target ? [{ id: "a", label: "Pin", onSelect: vi.fn() }] : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<FloatingTree>
|
||||
<Card />
|
||||
</FloatingTree>,
|
||||
);
|
||||
const card = screen.getByTestId("card");
|
||||
act(() => card.focus());
|
||||
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
|
||||
expect(menuitems()).toEqual(["Pin"]);
|
||||
await vi.waitFor(() => expect(card.contains(document.activeElement)).toBe(false));
|
||||
fireEvent.click(item("Pin"));
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(card));
|
||||
});
|
||||
|
||||
describe("long press (touch)", () => {
|
||||
const press = (el: HTMLElement, x = 10, y = 10) =>
|
||||
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
import { scrollParent } from "../../lib/listPaging";
|
||||
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
@@ -33,18 +34,6 @@ function chunk<T>(arr: T[], size: number): T[][] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The list scrolls inside an <main overflow-y-auto> (the page scroller), not the window, so the
|
||||
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||
let el = node?.parentElement ?? null;
|
||||
while (el) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export interface VirtualGridProps<T> {
|
||||
items: T[];
|
||||
getKey: (item: T) => string;
|
||||
@@ -105,8 +94,11 @@ export default function VirtualGrid<T>({
|
||||
// this wrong before. This is measured here anyway, for colCount.
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
|
||||
// The list scrolls inside an <main overflow-y-auto> (the page scroller), not the window, so the
|
||||
// virtualizer needs that element. The SAME walk lib/listNav measures its visible band against: the
|
||||
// virtualizer and the keyboard must agree on which element scrolls.
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
if (listRef.current) setScrollEl(scrollParent(listRef.current) ?? document.documentElement);
|
||||
}, []);
|
||||
|
||||
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||
|
||||
@@ -53,6 +53,20 @@ const enumValues = (file: string) =>
|
||||
[...backend(file).matchAll(/^\s+[A-Z_]+ = "(\w+)"$/gm)].map((m) => m[1]!);
|
||||
const auditActions = enumValues("audit.py");
|
||||
const quotaActions = enumValues("quota.py");
|
||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
||||
// ruff wraps over several lines still matches.
|
||||
const adminCodes = [
|
||||
...(
|
||||
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
|
||||
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
|
||||
)?.[1] ?? ""
|
||||
).matchAll(/"(\w+)"/g),
|
||||
].map((m) => m[1]!);
|
||||
const adminRefusals = [
|
||||
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
|
||||
].filter((m) => adminCodes.includes(m[1]!));
|
||||
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
|
||||
|
||||
/** The ids under `prefix` a locale names, `prefix.<id>` or `prefix.<id>.<anything>`. */
|
||||
const idsUnder = (keys: Set<string>, prefix: string) =>
|
||||
@@ -68,6 +82,9 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(jobs).toEqual(expect.arrayContaining(["rss_poll", "audit_gc"]));
|
||||
expect(auditActions).toEqual(expect.arrayContaining(["config_set", "audit_clear"]));
|
||||
expect(quotaActions.length).toBeGreaterThan(0);
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "setpw"]));
|
||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||
});
|
||||
|
||||
for (const lang of ["en", "hu"]) {
|
||||
@@ -99,5 +116,12 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
|
||||
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${lang}: every admin refusal reason`, () => {
|
||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"created_one": "{{count}} clip added to your downloads",
|
||||
"created_other": "{{count}} clips added to your downloads",
|
||||
"failed": "Couldn’t create the clip.",
|
||||
"partlyCreated": "Only {{done}} of {{total}} clips were added — the rest are still selected, so creating again adds just those.",
|
||||
"splitHere": "Split here",
|
||||
"output": {
|
||||
"label": "Output",
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
"recipient": "There's no user with that email address.",
|
||||
"job": "This job doesn't exist any more."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
|
||||
"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.",
|
||||
"demo": "The demo account is shared, so it can't get a set-password link."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "The server couldn't be reached. Try again.",
|
||||
"forbidden": "You don't have permission to do that.",
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
"inviteLabel": "Send an invitation to {{email}}",
|
||||
"invited": "Invitation sent to {{email}}.",
|
||||
"inviteFailed": "Couldn't send that invitation.",
|
||||
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
|
||||
"inviteTitle": "Send invitation?",
|
||||
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
|
||||
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"resend": "Send the confirmation link again",
|
||||
"resendHint": "Send the confirmation link again, to the address on the account.",
|
||||
"resent": "Confirmation link sent to {{email}}.",
|
||||
"resendFailed": "Couldn't send the confirmation link.",
|
||||
"resendEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email."
|
||||
"resendFailed": "Couldn't send the confirmation link."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Users & roles",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"created_one": "{{count}} klip a letöltésekhez adva",
|
||||
"created_other": "{{count}} klip a letöltésekhez adva",
|
||||
"failed": "A klip létrehozása nem sikerült.",
|
||||
"partlyCreated": "{{total}} klipből csak {{done}} került a letöltésekhez — a többi kijelölve maradt, így az újbóli létrehozás csak azokat adja hozzá.",
|
||||
"splitHere": "Vágás itt",
|
||||
"output": {
|
||||
"label": "Kimenet",
|
||||
|
||||
@@ -20,8 +20,16 @@
|
||||
"recipient": "Nincs felhasználó ezzel az e-mail címmel.",
|
||||
"job": "Ez a feladat már nem létezik."
|
||||
},
|
||||
"admin": {
|
||||
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
|
||||
"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.",
|
||||
"demo": "A bemutató fiók közös, ezért nem kaphat jelszóbeállító linket."
|
||||
},
|
||||
"reason": {
|
||||
"unreachable": "A művelet nem ment végbe: a szerver nem volt elérhető. Próbáld újra.",
|
||||
"unreachable": "A szerver nem volt elérhető. Próbáld újra.",
|
||||
"forbidden": "Ehhez nincs jogosultságod.",
|
||||
"rateLimited": "Túl sok kérés egyszerre — várj egy kicsit, és próbáld újra.",
|
||||
"unexpected": "Váratlan hiba történt az alkalmazásban. Próbáld újra."
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
"inviteLabel": "Meghívó küldése ide: {{email}}",
|
||||
"invited": "Meghívó elküldve ide: {{email}}.",
|
||||
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
|
||||
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
|
||||
"inviteTitle": "Küldesz meghívót?",
|
||||
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
|
||||
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"resend": "Megerősítő link újraküldése",
|
||||
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
|
||||
"resent": "Megerősítő link elküldve ide: {{email}}.",
|
||||
"resendFailed": "Nem sikerült elküldeni a megerősítő linket.",
|
||||
"resendEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt."
|
||||
"resendFailed": "Nem sikerült elküldeni a megerősítő linket."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Felhasználók és szerepek",
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
// jsdom because the shared module reaches lib/api, which pulls in i18n's localStorage probe.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { invalidateUserAccess, isEmailOff } from "./adminUsers";
|
||||
import { HttpError } from "./api";
|
||||
import { invalidateUserAccess } from "./adminUsers";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// The set matters, not the call: letting someone in changes three things a user can see at once —
|
||||
@@ -36,20 +35,3 @@ describe("invalidateUserAccess", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The admin-side resend deliberately reports failures (the public one is silent), so it has to be
|
||||
// able to say WHICH — a no-SMTP instance is a configuration answer, not a bug.
|
||||
describe("isEmailOff", () => {
|
||||
it("recognises the no-mail refusal", () => {
|
||||
expect(isEmailOff(new HttpError(409, "x", { code: "resend", reason: "email_off" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for any other failure, including a non-HttpError", () => {
|
||||
expect(isEmailOff(new HttpError(422, "x", { code: "resend", reason: "send_failed" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isEmailOff(new HttpError(500, "x", null))).toBe(false);
|
||||
expect(isEmailOff(new TypeError("Failed to fetch"))).toBe(false);
|
||||
expect(isEmailOff(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpError } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
// Shared bits of the admin's user-management surface, extracted because the SAME two mutations
|
||||
@@ -25,12 +24,3 @@ export function invalidateUserAccess(qc: QueryClient, userId?: number) {
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
if (userId !== undefined) qc.invalidateQueries({ queryKey: qk.adminUserAccess(userId) });
|
||||
}
|
||||
|
||||
/** Did an admin resend fail because the instance has no working mail, rather than for a real
|
||||
* error? The admin-side resend deliberately reports failures (the public one is silent), so the
|
||||
* message must be able to say WHICH — a no-SMTP instance would otherwise read as a bug. */
|
||||
export function isEmailOff(err: unknown): boolean {
|
||||
if (!(err instanceof HttpError)) return false;
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
@@ -149,6 +149,24 @@ describe("req — a structured refusal reads in the user's language", () => {
|
||||
});
|
||||
expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ code: "activate", reason: "not_allowed" }, "errors.admin.not_allowed"],
|
||||
[{ code: "invite_send", reason: "not_approved" }, "errors.admin.not_approved"],
|
||||
[{ code: "resend", reason: "send_failed" }, "errors.admin.send_failed"],
|
||||
[{ code: "setpw", reason: "demo" }, "errors.admin.demo"],
|
||||
])("says why an admin action was refused (%o)", async (detail, localized) => {
|
||||
// These read as the generic "try again" — which no retry fixes when the address is not approved.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), {
|
||||
status: 409,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(req("/api/admin/x", { method: "POST" }, { quiet: true })).rejects.toMatchObject({
|
||||
detail: localized,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("req — what it has already put in front of the user", () => {
|
||||
|
||||
@@ -62,6 +62,10 @@ export class HttpError extends Error {
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send", "setpw"]);
|
||||
|
||||
function localizeDetail(d: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||
@@ -125,6 +129,11 @@ function localizeDetail(d: unknown): string {
|
||||
: t("plex.collection.editFailed");
|
||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||
// An admin action on an account or an invite that the server refused: activating an address that
|
||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||||
// by, a set-password link for the shared demo account. The toast's title already names the button;
|
||||
// this is the WHY, and it used to be the generic "try again", which no retry could fix.
|
||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||||
// generically rather than printing a key.
|
||||
|
||||
@@ -92,6 +92,9 @@ describe("quickShareLink", () => {
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
|
||||
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||
@@ -34,8 +35,8 @@ export async function quickShareLink(
|
||||
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [link]);
|
||||
}
|
||||
} catch {
|
||||
notify({ level: "error", message: t("downloads.share.linkFailed") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + link.url;
|
||||
|
||||
@@ -11,11 +11,11 @@ import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { rememberNavPosition } from "./history";
|
||||
import { navItemAttrs, useListNav } from "./listNav";
|
||||
|
||||
function List({ id, count }: { id: string; count: number }) {
|
||||
function List({ id, count, label }: { id: string; count: number; label?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} aria-label={label}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} data-testid={`${id}${i}`} {...navItemAttrs(i)}>
|
||||
{id}
|
||||
@@ -60,6 +60,9 @@ function landOn(state: unknown) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// A restore that gave up waiting for its list polls on to its deadline; a push cancels it, and one
|
||||
// left running fires after the file's jsdom is torn down ("document is not defined").
|
||||
rememberNavPosition();
|
||||
cleanup();
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
window.history.replaceState(null, "");
|
||||
@@ -142,3 +145,43 @@ describe("rememberNavPosition · Back focuses the item a view was left from", ()
|
||||
expect(document.activeElement).toBe(getByRole("textbox"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("rememberNavPosition · the list it comes back to (deep-review E11 round 2)", () => {
|
||||
// Discover: a title opened from "Recently added" and watched — the page's cache expires and
|
||||
// "Continue watching" fills with that very title, a rail ABOVE. Found by its place, focus landed in
|
||||
// that rail, on a title never picked.
|
||||
it("finds a named list by its name when a list appeared above it", async () => {
|
||||
const Rails = () => (
|
||||
<>
|
||||
<List id="a" count={2} label="Recently added" />
|
||||
<List id="b" count={3} label="Genres" />
|
||||
</>
|
||||
);
|
||||
const { unmount } = render(<Rails />);
|
||||
const stamped = leaveFrom("a1", 2);
|
||||
unmount();
|
||||
landOn(stamped);
|
||||
render(
|
||||
<>
|
||||
<List id="c" count={3} label="Continue watching" />
|
||||
<Rails />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(focused()).toBe("a1"));
|
||||
});
|
||||
|
||||
it("gives up on an unnamed list when the page came back with another number of lists", async () => {
|
||||
const { unmount } = render(<Page />);
|
||||
const stamped = leaveFrom("a1", 2);
|
||||
unmount();
|
||||
landOn(stamped);
|
||||
render(
|
||||
<>
|
||||
<List id="c" count={3} />
|
||||
<Page />
|
||||
</>,
|
||||
);
|
||||
await sleep(150);
|
||||
expect(focused()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,3 +429,86 @@ describe("useListNav · the roving tabindex and a removed item (deep-review E11
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useListNav · leaving the list, and naming the item (deep-review E11 round 2)", () => {
|
||||
function Swappable({ ids }: { ids: number[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{ids.map((id, i) => (
|
||||
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
|
||||
<button>act</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// `s`, a query, Escape: the feed stays mounted with new results and the old card is gone. The slot
|
||||
// kept for a REMOVED card sent the next `j` to the third new result instead of the first.
|
||||
it("starts from the first visible item after focus went on to a field and the items changed", () => {
|
||||
withLayout(
|
||||
(_, n) => ({ left: 0, top: n * 100 }),
|
||||
() => {
|
||||
const page = (ids: number[]) => (
|
||||
<>
|
||||
<input data-testid="field" />
|
||||
<Swappable ids={ids} />
|
||||
</>
|
||||
);
|
||||
const { rerender } = render(page([0, 1, 2, 3]));
|
||||
press("j");
|
||||
press("j");
|
||||
press("j");
|
||||
expect(focused()).toBe("r2");
|
||||
act(() => screen.getByTestId("field").focus());
|
||||
rerender(page([4, 5, 6, 7]));
|
||||
act(() => screen.getByTestId("field").blur());
|
||||
press("j");
|
||||
expect(focused()).toBe("r4");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// A confirm (or the item's own menu) takes the focus before the delete it confirms: back from it, the
|
||||
// next `j` still goes on from the deleted row's slot.
|
||||
it("keeps the slot when the removal was confirmed in a dialog", () => {
|
||||
withLayout(
|
||||
(_, n) => ({ left: 0, top: n * 100 }),
|
||||
() => {
|
||||
const page = (ids: number[]) => (
|
||||
<>
|
||||
<div role="dialog">
|
||||
<button data-testid="ok">ok</button>
|
||||
</div>
|
||||
<Swappable ids={ids} />
|
||||
</>
|
||||
);
|
||||
const { rerender } = render(page([0, 1, 2, 3]));
|
||||
press("j");
|
||||
press("j");
|
||||
press("j");
|
||||
act(() => screen.getByTestId("ok").focus());
|
||||
rerender(page([0, 1, 3]));
|
||||
act(() => screen.getByTestId("ok").blur());
|
||||
press("j");
|
||||
expect(focused()).toBe("r3");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// A card root is a plain <div>: focused by `j` it had no role and no name, so a screen reader read
|
||||
// an unlabelled run of text.
|
||||
it("names a focused card after what it opens while it holds the focus, then gives the name back", () => {
|
||||
render(<List id="a" count={2} />);
|
||||
press("j");
|
||||
const a0 = screen.getByTestId("a0");
|
||||
expect(a0.getAttribute("role")).toBe("group");
|
||||
expect(a0.getAttribute("aria-label")).toBe("title");
|
||||
press("j");
|
||||
expect(a0.hasAttribute("role")).toBe(false);
|
||||
expect(a0.hasAttribute("aria-label")).toBe(false);
|
||||
expect(screen.getByTestId("a1").getAttribute("aria-label")).toBe("title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The `list` key scope — what puts j/k, the arrows and Enter on the cheat sheet — is live only while a
|
||||
// registered list has an item to land on. An empty download queue, a list still loading or one that
|
||||
// failed mounts its container all the same, and the sheet listed the keys over nothing (deep-review
|
||||
// E11 round 2). Its own file: listNav keeps module state.
|
||||
import { useRef } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { navItemAttrs, useListNav } from "./listNav";
|
||||
import { useActiveScopes } from "./keymap";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Scopes() {
|
||||
return <output data-testid="scopes">{useActiveScopes().join(" ")}</output>;
|
||||
}
|
||||
|
||||
function Queue({ count }: { count: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{count === 0 ? (
|
||||
<p>Nothing queued</p>
|
||||
) : (
|
||||
Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} {...navItemAttrs(i)}>
|
||||
item
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const page = (count: number) => (
|
||||
<>
|
||||
<Scopes />
|
||||
<Queue count={count} />
|
||||
</>
|
||||
);
|
||||
const scopes = () => screen.getByTestId("scopes").textContent!.split(" ");
|
||||
|
||||
describe("useListNav · the list key scope", () => {
|
||||
it("is not live over a list with nothing in it, and is while it has items", async () => {
|
||||
const { rerender } = render(page(0));
|
||||
expect(scopes()).not.toContain("list");
|
||||
rerender(page(2));
|
||||
await waitFor(() => expect(scopes()).toContain("list"));
|
||||
rerender(page(0));
|
||||
await waitFor(() => expect(scopes()).not.toContain("list"));
|
||||
});
|
||||
});
|
||||
+58
-11
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { scrollParent } from "./listPaging";
|
||||
|
||||
@@ -35,8 +35,9 @@ interface NavList {
|
||||
}
|
||||
|
||||
const lists = new Map<HTMLElement, NavList>();
|
||||
// The item we gave a tabindex to, so the next move can take it back.
|
||||
// The item we gave a tabindex to, so the next move can take it back — and whether we named it too.
|
||||
let roved: HTMLElement | null = null;
|
||||
let named = false;
|
||||
|
||||
const INDEX = "data-nav-index";
|
||||
const LIST = "data-nav-list";
|
||||
@@ -125,10 +126,17 @@ function firstVisible(): { list: NavList; index: number } | null {
|
||||
let last: { list: NavList; index: number; el: HTMLElement } | null = null;
|
||||
|
||||
function onFocusIn(e: FocusEvent): void {
|
||||
const item = (e.target as HTMLElement | null)?.closest?.<HTMLElement>(`[${INDEX}]`);
|
||||
const target = e.target as HTMLElement | null;
|
||||
const item = target?.closest?.<HTMLElement>(`[${INDEX}]`);
|
||||
const list =
|
||||
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Where `j` (`k`: the item before) lands after the last-focused item left the DOM: the item now in
|
||||
@@ -150,13 +158,31 @@ function slotOfRemoved(delta: 1 | -1): { list: NavList; index: number } | null {
|
||||
|
||||
function focusItem(el: HTMLElement, reveal = true): void {
|
||||
if (roved !== el) {
|
||||
if (named) roved?.removeAttribute("role");
|
||||
if (named) roved?.removeAttribute("aria-label");
|
||||
roved?.removeAttribute("tabindex");
|
||||
roved = null;
|
||||
named = false;
|
||||
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
|
||||
// tabIndex 0) keeps it: a -1 here would take it out of the Tab order.
|
||||
if (!el.hasAttribute("tabindex") && el.tabIndex < 0) {
|
||||
el.setAttribute("tabindex", "-1");
|
||||
roved = el;
|
||||
// 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.
|
||||
const open = el.querySelector("[data-nav-open]");
|
||||
const name = (open?.getAttribute("aria-label") || open?.textContent || "").trim();
|
||||
if (
|
||||
el.tagName === "DIV" &&
|
||||
name &&
|
||||
!el.hasAttribute("role") &&
|
||||
!el.hasAttribute("aria-label")
|
||||
) {
|
||||
el.setAttribute("role", "group");
|
||||
el.setAttribute("aria-label", name);
|
||||
named = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
el.focus({ preventScroll: true });
|
||||
@@ -280,19 +306,22 @@ function onKey(e: KeyboardEvent): void {
|
||||
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
|
||||
|
||||
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
|
||||
* had on screen, and the item's index. */
|
||||
* had on screen, the list's accessible name when it has one (a rail's title), and the item's index. */
|
||||
export interface NavPosition {
|
||||
readonly list: number;
|
||||
readonly lists: number;
|
||||
readonly label?: string | null;
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
const labelOf = (list: NavList): string | null => list.el.getAttribute("aria-label");
|
||||
|
||||
export function navPosition(): NavPosition | null {
|
||||
const cur = current();
|
||||
if (!cur) return null;
|
||||
const all = ordered();
|
||||
const list = all.indexOf(cur.list);
|
||||
return list < 0 ? null : { list, lists: all.length, index: cur.index };
|
||||
return list < 0 ? null : { list, lists: all.length, label: labelOf(cur.list), index: cur.index };
|
||||
}
|
||||
|
||||
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
|
||||
@@ -300,10 +329,14 @@ export function navPosition(): NavPosition | null {
|
||||
const RESTORE_MS = 2000;
|
||||
const RESTORE_POLL_MS = 50;
|
||||
|
||||
/** Focus the item at `pos` once the page has as many lists on screen as when it was left — before
|
||||
* that, list N may not yet be the list it was. Gives up when the time is up, and at once when focus
|
||||
* is already somewhere (the reader moved on, a dialog took it). The item is focused where the scroll
|
||||
* restore put it, never scrolled to. Returns a cancel. */
|
||||
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
|
||||
* name: a rail that appeared above it in the meantime — "Continue watching", filled by the very title
|
||||
* watched in between, after the page's cache expired — shifts every place after it, and matching by
|
||||
* place landed the focus in the wrong rail (deep-review E11 round 2). An unnamed list only by its
|
||||
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
|
||||
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
|
||||
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
|
||||
* where the scroll restore put it, never scrolled to. Returns a cancel. */
|
||||
export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const deadline = Date.now() + RESTORE_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -311,7 +344,12 @@ export function restoreNavPosition(pos: NavPosition): () => void {
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body) return;
|
||||
const all = ordered();
|
||||
const el = all.length >= pos.lists ? itemAt(all[pos.list]!, pos.index) : null;
|
||||
const list = pos.label
|
||||
? all.find((l) => labelOf(l) === pos.label)
|
||||
: all.length === pos.lists
|
||||
? all[pos.list]
|
||||
: undefined;
|
||||
const el = list ? itemAt(list, pos.index) : null;
|
||||
if (el) return focusItem(el, false);
|
||||
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
||||
};
|
||||
@@ -333,7 +371,11 @@ export function useListNav(
|
||||
): void {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
useKeyScope("list", opts.enabled);
|
||||
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
|
||||
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
|
||||
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
|
||||
const [hasItems, setHasItems] = useState(false);
|
||||
useKeyScope("list", opts.enabled && hasItems);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!opts.enabled || !el) return;
|
||||
@@ -344,11 +386,16 @@ export function useListNav(
|
||||
reveal: (i) => optsRef.current.reveal?.(i),
|
||||
});
|
||||
el.setAttribute(LIST, "");
|
||||
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
|
||||
const watch = new MutationObserver(check);
|
||||
watch.observe(el, { childList: true, subtree: true });
|
||||
check();
|
||||
if (lists.size === 1) {
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
}
|
||||
return () => {
|
||||
watch.disconnect();
|
||||
lists.delete(el);
|
||||
el.removeAttribute(LIST);
|
||||
if (lists.size === 0) {
|
||||
|
||||
Reference in New Issue
Block a user