Skip to content

Commit 0cf572c

Browse files
committed
Pagination to loadout snapshot page
1 parent f53ffa4 commit 0cf572c

4 files changed

Lines changed: 280 additions & 5 deletions

File tree

PLANS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Keep this checklist aligned with repository behavior. Check off work in the same
5656
- [x] Record required Conquest bosses with canonical portraits, use a fixed segmented clear-time format, and default new snapshot names to the trimmed source loadout name.
5757
- [x] Require Rift clear times with the shared segmented control, normalize legacy Rift snapshots, and allow Normal Conquest levels through 15 while retaining level 10 caps elsewhere.
5858
- [x] Filter snapshot lists by conditional Legendary Conquest elements or Conquest bosses with multi-select OR semantics, single-select Conquest difficulty banners, tag-aware reset behavior, extracted toolbar/predicate modules, and focused verification.
59+
- [x] Paginate filtered and sorted loadout snapshots with selectable page sizes and accessible boundary navigation.
5960
- [x] Emphasize loadout snapshot field values while keeping creation timestamps only in interactive preview headers.
6061
- [x] Arrange Conquest snapshot metadata responsively in a two-column mobile, three-column small-and-up, two-row grid across saved cards, previews, and generated images, preserving reserved RES Element space and accessible Difficulty labels (verified with focused snapshot tests and repository formatting).
6162
- [x] Add Drive-synced Settings control for equipment set-name captions and show active equipment sets with notification dots and portal effect tooltips in loadout previews.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
ChevronLeftIcon,
3+
ChevronRightIcon,
4+
ChevronsLeftIcon,
5+
ChevronsRightIcon,
6+
} from "lucide-react";
7+
import { Button } from "@/components/ui/button";
8+
import { ButtonGroup } from "@/components/ui/button-group";
9+
import {
10+
Select,
11+
SelectContent,
12+
SelectItem,
13+
SelectTrigger,
14+
SelectValue,
15+
} from "@/components/ui/select";
16+
17+
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const;
18+
19+
type LoadoutSnapshotPaginationProps = {
20+
page: number;
21+
pageCount: number;
22+
pageSize: number;
23+
onPageChange: (page: number) => void;
24+
onPageSizeChange: (pageSize: number) => void;
25+
};
26+
27+
export const LoadoutSnapshotPagination = ({
28+
page,
29+
pageCount,
30+
pageSize,
31+
onPageChange,
32+
onPageSizeChange,
33+
}: LoadoutSnapshotPaginationProps) => {
34+
const isFirstPage = page === 1;
35+
const isLastPage = page === pageCount;
36+
37+
return (
38+
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
39+
<label
40+
className="flex items-center gap-2 text-sm text-muted-foreground"
41+
htmlFor="snapshot-page-size"
42+
>
43+
<span>Rows per page</span>
44+
<Select
45+
value={pageSize.toString()}
46+
onValueChange={(value) => onPageSizeChange(Number(value))}
47+
>
48+
<SelectTrigger
49+
id="snapshot-page-size"
50+
aria-label="Rows per page"
51+
size="sm"
52+
>
53+
<SelectValue />
54+
</SelectTrigger>
55+
<SelectContent>
56+
{PAGE_SIZE_OPTIONS.map((option) => (
57+
<SelectItem key={option} value={option.toString()}>
58+
{option}
59+
</SelectItem>
60+
))}
61+
</SelectContent>
62+
</Select>
63+
</label>
64+
<div aria-live="polite" className="text-sm text-muted-foreground">
65+
Page {page} of {pageCount}
66+
</div>
67+
<ButtonGroup aria-label="Snapshot pagination" className="ml-auto">
68+
<Button
69+
type="button"
70+
variant="outline"
71+
size="icon"
72+
aria-label="First page"
73+
disabled={isFirstPage}
74+
onClick={() => onPageChange(1)}
75+
>
76+
<ChevronsLeftIcon />
77+
</Button>
78+
<Button
79+
type="button"
80+
variant="outline"
81+
size="icon"
82+
aria-label="Previous page"
83+
disabled={isFirstPage}
84+
onClick={() => onPageChange(page - 1)}
85+
>
86+
<ChevronLeftIcon />
87+
</Button>
88+
<Button
89+
type="button"
90+
variant="outline"
91+
size="icon"
92+
aria-label="Next page"
93+
disabled={isLastPage}
94+
onClick={() => onPageChange(page + 1)}
95+
>
96+
<ChevronRightIcon />
97+
</Button>
98+
<Button
99+
type="button"
100+
variant="outline"
101+
size="icon"
102+
aria-label="Last page"
103+
disabled={isLastPage}
104+
onClick={() => onPageChange(pageCount)}
105+
>
106+
<ChevronsRightIcon />
107+
</Button>
108+
</ButtonGroup>
109+
</div>
110+
);
111+
};

src/components/loadout-snapshots/components/loadout-snapshots-list.test.tsx

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe("LoadoutSnapshotsList", () => {
211211
"Legendary Conquest",
212212
"Others",
213213
]);
214-
expect(screen.getAllByRole("combobox")).toHaveLength(1);
214+
expect(screen.getAllByRole("combobox")).toHaveLength(2);
215215

216216
for (const [label, visibleName] of [
217217
["Conquest", "Conquest clear"],
@@ -498,6 +498,140 @@ describe("LoadoutSnapshotsList", () => {
498498
expect(copyImage.mock.calls[0].slice(2)).toEqual([false, false]);
499499
});
500500

501+
it("paginates sorted rows and resets the page for page size and search changes", () => {
502+
const snapshots = Object.fromEntries(
503+
Array.from({ length: 21 }, (_, index) => {
504+
const id = `snapshot-${index}`;
505+
return [id, snapshot(id, `Snapshot ${index}`, "others", index)];
506+
}),
507+
);
508+
useAppStore.setState({ loadoutSnapshots: snapshots });
509+
render(<LoadoutSnapshotsList />);
510+
const visibleRows = () =>
511+
screen
512+
.getAllByRole("button", { name: /snapshot row$/ })
513+
.map((button) => button.getAttribute("aria-label"));
514+
515+
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
516+
expect(visibleRows()).toEqual(
517+
Array.from(
518+
{ length: 10 },
519+
(_, index) => `Preview Snapshot ${20 - index} snapshot row`,
520+
),
521+
);
522+
expect(
523+
(screen.getByRole("button", { name: "First page" }) as HTMLButtonElement)
524+
.disabled,
525+
).toBe(true);
526+
expect(
527+
(
528+
screen.getByRole("button", {
529+
name: "Previous page",
530+
}) as HTMLButtonElement
531+
).disabled,
532+
).toBe(true);
533+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
534+
expect(screen.getByText("Page 2 of 3")).toBeTruthy();
535+
expect(visibleRows()).toEqual(
536+
Array.from(
537+
{ length: 10 },
538+
(_, index) => `Preview Snapshot ${10 - index} snapshot row`,
539+
),
540+
);
541+
expect(
542+
(screen.getByRole("button", { name: "First page" }) as HTMLButtonElement)
543+
.disabled,
544+
).toBe(false);
545+
fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
546+
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
547+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
548+
fireEvent.click(screen.getByRole("button", { name: "Last page" }));
549+
expect(screen.getByText("Page 3 of 3")).toBeTruthy();
550+
expect(visibleRows()).toEqual(["Preview Snapshot 0 snapshot row"]);
551+
expect(
552+
(screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement)
553+
.disabled,
554+
).toBe(true);
555+
fireEvent.click(screen.getByRole("button", { name: "First page" }));
556+
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
557+
fireEvent.click(screen.getByRole("button", { name: "Last page" }));
558+
559+
const pageSize = screen.getByRole("combobox", { name: "Rows per page" });
560+
fireEvent.keyDown(pageSize, { key: "ArrowDown" });
561+
fireEvent.click(screen.getByRole("option", { name: "20" }));
562+
expect(screen.getByText("Page 1 of 2")).toBeTruthy();
563+
expect(screen.getByText("Snapshot 20")).toBeTruthy();
564+
fireEvent.keyDown(pageSize, { key: "ArrowDown" });
565+
fireEvent.click(screen.getByRole("option", { name: "50" }));
566+
expect(screen.getByText("Page 1 of 1")).toBeTruthy();
567+
expect(screen.getByText("Snapshot 0")).toBeTruthy();
568+
fireEvent.keyDown(pageSize, { key: "ArrowDown" });
569+
fireEvent.click(screen.getByRole("option", { name: "10" }));
570+
571+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
572+
const sort = screen.getByRole("combobox", {
573+
name: "Sort loadout snapshots",
574+
});
575+
fireEvent.keyDown(sort, { key: "ArrowDown" });
576+
fireEvent.click(screen.getByRole("option", { name: "Created: Oldest" }));
577+
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
578+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
579+
fireEvent.click(
580+
screen.getByRole("button", { name: "Clear loadout snapshot filters" }),
581+
);
582+
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
583+
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
584+
fireEvent.change(
585+
screen.getByRole("textbox", { name: "Search loadout snapshots" }),
586+
{
587+
target: { value: "Snapshot 0" },
588+
},
589+
);
590+
expect(screen.getByText("Page 1 of 1")).toBeTruthy();
591+
expect(screen.getByText("Snapshot 0")).toBeTruthy();
592+
expect(
593+
(screen.getByRole("button", { name: "First page" }) as HTMLButtonElement)
594+
.disabled,
595+
).toBe(true);
596+
});
597+
598+
it("clamps after deletion and hides pagination only for empty results", () => {
599+
useAppStore.setState({
600+
loadoutSnapshots: Object.fromEntries(
601+
Array.from({ length: 11 }, (_, index) => {
602+
const id = `snapshot-${index}`;
603+
return [id, snapshot(id, `Snapshot ${index}`, "others", index)];
604+
}),
605+
),
606+
});
607+
const { rerender } = render(<LoadoutSnapshotsList />);
608+
609+
fireEvent.click(screen.getByRole("button", { name: "Last page" }));
610+
expect(screen.getByText("Page 2 of 2")).toBeTruthy();
611+
fireEvent.click(screen.getByRole("button", { name: "Delete Snapshot 0" }));
612+
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
613+
expect(screen.getByText("Page 1 of 1")).toBeTruthy();
614+
expect(screen.getByText("Snapshot 10")).toBeTruthy();
615+
for (const label of [
616+
"First page",
617+
"Previous page",
618+
"Next page",
619+
"Last page",
620+
])
621+
expect(
622+
(screen.getByRole("button", { name: label }) as HTMLButtonElement)
623+
.disabled,
624+
).toBe(true);
625+
626+
useAppStore.setState({ loadoutSnapshots: {} });
627+
rerender(<LoadoutSnapshotsList />);
628+
expect(screen.getByText("No loadout snapshots yet")).toBeTruthy();
629+
expect(screen.queryByText(/Page \d+ of \d+/)).toBeNull();
630+
expect(
631+
screen.queryByRole("combobox", { name: "Rows per page" }),
632+
).toBeNull();
633+
});
634+
501635
it("places and resets the Difficulty filter after bosses", () => {
502636
useAppStore.setState({
503637
loadoutSnapshots: {

src/components/loadout-snapshots/components/loadout-snapshots-list.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useMemo, useRef, useState } from "react";
1+
import { useEffect, useMemo, useRef, useState } from "react";
22
import { flushSync } from "react-dom";
33
import { useGoogleAnalytics } from "tanstack-router-ga4";
44
import { LoadoutSnapshotDialog } from "@/components/loadout-snapshots/components/create-loadout-snapshot-dialog";
55
import { LoadoutSnapshotFilter } from "@/components/loadout-snapshots/components/loadout-snapshot-filter";
66
import { LoadoutSnapshotMetadata } from "@/components/loadout-snapshots/components/loadout-snapshot-metadata";
7+
import { LoadoutSnapshotPagination } from "@/components/loadout-snapshots/components/loadout-snapshot-pagination";
78
import {
89
LOADOUT_SNAPSHOT_SORTS,
910
type LoadoutSnapshotSort,
@@ -60,6 +61,8 @@ export const LoadoutSnapshotsList = () => {
6061
const [sort, setSort] = useState<LoadoutSnapshotSort>(
6162
LOADOUT_SNAPSHOT_SORTS.CREATED_DESC,
6263
);
64+
const [page, setPage] = useState(1);
65+
const [pageSize, setPageSize] = useState(10);
6366
const [previewId, setPreviewId] = useState<string | null>(null);
6467
const [exportId, setExportId] = useState<string | null>(null);
6568
const [editId, setEditId] = useState<string | null>(null);
@@ -81,6 +84,15 @@ export const LoadoutSnapshotsList = () => {
8184
: b.created_at - a.created_at || a.name.localeCompare(b.name);
8285
});
8386
}, [filters, snapshots, sort]);
87+
const pageCount = Math.max(1, Math.ceil(entries.length / pageSize));
88+
const safePage = Math.min(page, pageCount);
89+
useEffect(() => {
90+
setPage((currentPage) => Math.min(currentPage, pageCount));
91+
}, [pageCount]);
92+
const paginatedEntries = entries.slice(
93+
(safePage - 1) * pageSize,
94+
safePage * pageSize,
95+
);
8496
const preview = previewId ? (snapshots[previewId] ?? null) : null;
8597
const exported = exportId ? (snapshots[exportId] ?? null) : null;
8698
const editing = editId ? (snapshots[editId] ?? null) : null;
@@ -109,8 +121,14 @@ export const LoadoutSnapshotsList = () => {
109121
<LoadoutSnapshotFilter
110122
filters={filters}
111123
sort={sort}
112-
onFiltersChange={setFilters}
113-
onSortChange={setSort}
124+
onFiltersChange={(nextFilters) => {
125+
setFilters(nextFilters);
126+
setPage(1);
127+
}}
128+
onSortChange={(nextSort) => {
129+
setSort(nextSort);
130+
setPage(1);
131+
}}
114132
onClear={() => {
115133
setFilters({
116134
search: "",
@@ -120,6 +138,7 @@ export const LoadoutSnapshotsList = () => {
120138
difficulty: null,
121139
});
122140
setSort(LOADOUT_SNAPSHOT_SORTS.CREATED_DESC);
141+
setPage(1);
123142
}}
124143
/>
125144
</div>
@@ -139,7 +158,7 @@ export const LoadoutSnapshotsList = () => {
139158
/>
140159
) : (
141160
<div className="grid gap-2">
142-
{entries.map((snapshot) => (
161+
{paginatedEntries.map((snapshot) => (
143162
<div
144163
key={snapshot.id}
145164
className="relative flex cursor-pointer flex-col gap-3 rounded-lg border bg-card p-4 transition-colors hover:border-primary focus-within:border-primary sm:flex-row sm:items-center sm:justify-between"
@@ -178,6 +197,16 @@ export const LoadoutSnapshotsList = () => {
178197
/>
179198
</div>
180199
))}
200+
<LoadoutSnapshotPagination
201+
page={safePage}
202+
pageCount={pageCount}
203+
pageSize={pageSize}
204+
onPageChange={setPage}
205+
onPageSizeChange={(nextPageSize) => {
206+
setPageSize(nextPageSize);
207+
setPage(1);
208+
}}
209+
/>
181210
</div>
182211
)}
183212

0 commit comments

Comments
 (0)