Files
siftlode/frontend/src/lib/useCardPager.ts
T
peter 5e5c8a42a9 feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)
Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
2026-09-26 17:34:47 +02:00

67 lines
3.0 KiB
TypeScript

import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARD_PAGE_SIZES, parseCardSize } from "./channelLayout";
import { readAccount, writeAccount } from "./storage";
// Client-side pagination for the channel manager's card layout, owned once instead of twice. Both
// manager tabs page an in-memory list the same way, and keeping two copies meant every fix landed
// twice (and the day one copy was missed, the tabs would page differently). The caller keeps only
// what genuinely differs: which rows, which storage key, and when to jump back to page one.
//
// The page size persists per account; the page itself is transient. `enabled` is the card layout
// flag — in table mode DataTable does its own paging, so this does no work and holds still.
export function useCardPager<T>(
rows: T[],
sizeKey: string,
enabled: boolean,
/** Follow a row the caller just moved: each new `seq` turns to the page holding the row `match`
* finds — the same contract as `DataTable`'s `reveal`, for the card paint of the same list. */
reveal?: { seq: number; match: (row: T) => boolean } | null,
) {
const { t } = useTranslation();
const [size, setSize] = useState(() => parseCardSize(readAccount(sizeKey, null)));
const [page, setPage] = useState(0);
// During render against the last seq seen, as in DataTable (seeded so a mount never jumps).
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = rows.findIndex(reveal.match);
if (enabled && at >= 0 && size > 0) setPage(Math.floor(at / size));
}
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
const safePage = Math.min(page, totalPages - 1);
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: `safePage`
// above only HIDES that, so commit it or it springs back the moment the list grows again. Only
// fires when the page you are on stopped existing, so it never costs you your place otherwise.
//
// Adjusted DURING RENDER, not from an effect: React re-runs this component immediately and throws
// the first pass away, so nothing is committed with an out-of-range page and there is no second
// paint. The condition is what makes that safe — the very write that triggers it also falsifies it.
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
const paged = !enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
return {
paged,
/** Spread onto <Pager/>. */
pagerProps: {
page: safePage,
size,
total: rows.length,
pageSizeOptions: CARD_PAGE_SIZES,
onPageChange: setPage,
onSizeChange: (s: number) => {
setSize(s);
writeAccount(sizeKey, s);
setPage(0);
},
sizeLabel: t("channels.cardsPerPage"),
},
/** Call when the caller's own filters change — a different list starts at page one. */
resetPage: useCallback(() => setPage(0), []),
};
}