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:
2026-09-13 19:18:04 +02:00
parent f130a1c05a
commit 3cd0c525e2
33 changed files with 640 additions and 113 deletions
+3 -6
View File
@@ -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({
+5 -17
View File
@@ -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);
});
});
+8 -6
View File
@@ -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"
+2 -2
View File
@@ -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"));
}
}
+2 -2
View File
@@ -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 });
});
});
+25 -7
View File
@@ -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",
}),
),
);
});
+37 -1
View File
@@ -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 });
+5 -13
View File
@@ -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.