Skip to content

Commit e660ab2

Browse files
refactor(list): extract Pagination → @dar/ui and ListSkeleton → @dar/list (#428, #303)
Shrinks the ListPage god-component (#428) by moving two self-contained, business-free chunks into their proper packages (#303), so each is individually unit-tested and reusable and the page is a thinner composition layer. - @dar/ui `Pagination` — generic prev/next pager (page/totalPages/onChange + optional className). Lives in @dar/ui per CLAUDE.md §7 (generic, no business knowledge), alongside `Table`. - @dar/list `ListSkeleton` — the list page's first-paint placeholder (title + count, toolbar, card of rows). Now takes optional `rows` / `columns` counts; defaults match the prior inline layout. - ListPage imports both back; drops the local definitions and the now- unused `Skeleton` import. No behaviour change. Tests: @dar/ui Pagination (5: page display, edge-disable both ends, click → neighbour, no-op on disabled edge) + @dar/list ListSkeleton (3: busy + status role, shimmer present, honours row/column counts). Full vitest 132 passed; typecheck + eslint + stylelint + dark-mode guard clean; build ok. Advances #428 and #303. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2863a8e commit e660ab2

9 files changed

Lines changed: 209 additions & 95 deletions

File tree

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 2 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ import {
2727
Checkbox,
2828
EmptyState,
2929
Modal,
30+
Pagination,
3031
RecordCardList,
31-
Skeleton,
3232
Table,
3333
useMediaQuery,
3434
} from '@dar/ui';
3535
import { FieldValueView } from '@dar/details';
36+
import { ListSkeleton } from '@dar/list';
3637
import { FilterBar } from '@dar/search';
3738

3839
import { useToast } from '../toast';
@@ -732,42 +733,6 @@ export function ListPage() {
732733
);
733734
}
734735

735-
// First-paint skeleton: shown while the very first list load is in
736-
// flight (no cached/stale data yet, so the columns aren't known). Mirrors
737-
// the real layout — title + count, the toolbar row, then a card of rows —
738-
// with a sensible default column count so the page has weight instead of
739-
// a lone spinner. Once `data` exists, refetch loading is shown inline by
740-
// the Table's own `loading` skeleton (which uses the real columns).
741-
function ListSkeleton() {
742-
return (
743-
<div className="space-y-4" aria-busy="true">
744-
<span role="status" className="sr-only">
745-
Loading…
746-
</span>
747-
<div className="space-y-2">
748-
<Skeleton className="h-7 w-48" />
749-
<Skeleton className="h-4 w-24" />
750-
</div>
751-
<div className="flex flex-wrap gap-2">
752-
<Skeleton className="h-9 w-72" />
753-
<Skeleton className="h-9 w-24" />
754-
<Skeleton className="h-9 w-28" />
755-
</div>
756-
<Card>
757-
<div className="divide-y divide-gray-100">
758-
{Array.from({ length: 8 }).map((_, i) => (
759-
<div key={i} className="flex items-center gap-4 px-4 py-3">
760-
{Array.from({ length: 5 }).map((__, j) => (
761-
<Skeleton key={j} className="h-4 flex-1" />
762-
))}
763-
</div>
764-
))}
765-
</div>
766-
</Card>
767-
</div>
768-
);
769-
}
770-
771736
function capitalize(value: string): string {
772737
if (!value) return value;
773738
return value.charAt(0).toUpperCase() + value.slice(1);
@@ -780,57 +745,3 @@ function emptyLabel(hasQuery: boolean, chipCount: number): string {
780745
if (hasQuery || chipCount > 0) return 'No results match the current search / filters.';
781746
return 'No objects yet.';
782747
}
783-
784-
interface PaginationProps {
785-
page: number;
786-
totalPages: number;
787-
/** "N object(s)" — shown before the page indicator (#95). */
788-
countLabel: string;
789-
onChange: (next: number) => void;
790-
}
791-
792-
function Pagination({ page, totalPages, countLabel, onChange }: PaginationProps) {
793-
const prevDisabled = page <= 1;
794-
const nextDisabled = page >= totalPages;
795-
const buttonClass = (disabled: boolean): string =>
796-
// Give the enabled button an explicit border-gray-300 (matching the
797-
// Filter/Customize buttons): a bare `border` falls back to Tailwind's
798-
// light-gray default, which the dark-mode utility remap can't catch
799-
// and shows as a white border in dark mode.
800-
`px-3 py-1 rounded border ${
801-
disabled
802-
? 'text-gray-300 border-gray-200 cursor-not-allowed'
803-
: 'border-gray-300 hover:bg-gray-100'
804-
}`;
805-
return (
806-
<nav className="flex items-center justify-between text-sm text-gray-600">
807-
<span>
808-
{countLabel}
809-
{/* A vertically-centered middot separates the count from the page
810-
indicator (#95) — not a period. */}
811-
<span aria-hidden className="px-2 text-gray-400">
812-
·
813-
</span>
814-
Page {page} of {totalPages}
815-
</span>
816-
<div className="flex gap-2">
817-
<button
818-
type="button"
819-
className={buttonClass(prevDisabled)}
820-
disabled={prevDisabled}
821-
onClick={() => onChange(page - 1)}
822-
>
823-
← Prev
824-
</button>
825-
<button
826-
type="button"
827-
className={buttonClass(nextDisabled)}
828-
disabled={nextDisabled}
829-
onClick={() => onChange(page + 1)}
830-
>
831-
Next →
832-
</button>
833-
</div>
834-
</nav>
835-
);
836-
}

frontend/packages/list/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ layer and each unit can be unit-tested in isolation:
1313
- **`DateHierarchyBar`** — the `date_hierarchy` drill-down breadcrumb +
1414
next-level buckets (Django changelist parity). Props-driven
1515
(`dh` + `onNavigate`), no router/business coupling.
16+
- **`ListSkeleton`** — first-paint placeholder for the list page (title +
17+
count, toolbar row, then a card of rows) shown before the columns are
18+
known. Optional `rows` / `columns` counts; no business coupling.
1619

17-
More units (filter/columns modals, pagination, the `list_editable`
18-
controller) land here as the decomposition progresses.
20+
More units (filter/columns modals, the `list_editable` controller) land
21+
here as the decomposition progresses. (Generic, business-free primitives
22+
like `Pagination` live in `@dar/ui`, not here.)
1923

2024
## Rules
2125

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { render, screen } from '@testing-library/react';
5+
6+
import { ListSkeleton } from './ListSkeleton';
7+
8+
describe('ListSkeleton', () => {
9+
it('marks the region busy and exposes a status for screen readers', () => {
10+
const { container } = render(<ListSkeleton />);
11+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
12+
expect(screen.getByRole('status')).toHaveTextContent('Loading');
13+
});
14+
15+
it('renders shimmer placeholders', () => {
16+
const { container } = render(<ListSkeleton />);
17+
expect(container.querySelector('.animate-pulse')).not.toBeNull();
18+
});
19+
20+
it('honours the requested row/column counts', () => {
21+
const { container } = render(<ListSkeleton rows={3} columns={2} />);
22+
// 2 title bars + 3 toolbar bars + (3 rows × 2 cells) = 11 placeholders.
23+
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(2 + 3 + 3 * 2);
24+
});
25+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// ListSkeleton — first-paint placeholder for the list page, shown while
2+
// the very first load is in flight (no cached/stale data yet, so the real
3+
// columns aren't known). Mirrors the real layout — title + count, the
4+
// toolbar row, then a card of rows — so the page has weight instead of a
5+
// lone spinner. Once data exists, refetch loading is shown inline by the
6+
// Table's own `loading` skeleton (which uses the real columns).
7+
//
8+
// Extracted from the ListPage god-component (#428 / #303). Props let a
9+
// caller tune the placeholder shape; the defaults match the prior inline
10+
// layout.
11+
12+
import { Card, Skeleton } from '@dar/ui';
13+
14+
export interface ListSkeletonProps {
15+
/** Placeholder row count (default 8). */
16+
rows?: number;
17+
/** Placeholder cells per row (default 5). */
18+
columns?: number;
19+
}
20+
21+
export function ListSkeleton({ rows = 8, columns = 5 }: ListSkeletonProps = {}) {
22+
return (
23+
<div className="space-y-4" aria-busy="true">
24+
<span role="status" className="sr-only">
25+
Loading…
26+
</span>
27+
<div className="space-y-2">
28+
<Skeleton className="h-7 w-48" />
29+
<Skeleton className="h-4 w-24" />
30+
</div>
31+
<div className="flex flex-wrap gap-2">
32+
<Skeleton className="h-9 w-72" />
33+
<Skeleton className="h-9 w-24" />
34+
<Skeleton className="h-9 w-28" />
35+
</div>
36+
<Card>
37+
<div className="divide-y divide-gray-100">
38+
{Array.from({ length: rows }).map((_, i) => (
39+
<div key={i} className="flex items-center gap-4 px-4 py-3">
40+
{Array.from({ length: columns }).map((__, j) => (
41+
<Skeleton key={j} className="h-4 flex-1" />
42+
))}
43+
</div>
44+
))}
45+
</div>
46+
</Card>
47+
</div>
48+
);
49+
}

frontend/packages/list/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
// here as the decomposition progresses (#303).
55

66
export { DateHierarchyBar, type DateHierarchyBarProps } from './DateHierarchyBar';
7+
export { ListSkeleton, type ListSkeletonProps } from './ListSkeleton';

frontend/packages/ui/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ allowed.** No knowledge of Django, the API, or any consumer model.
55

66
## Exports
77

8-
- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Skeleton`,
9-
`EmptyState`, `Breadcrumb`.
8+
- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Pagination`,
9+
`Skeleton`, `EmptyState`, `Breadcrumb`.
1010
- **Controls:** `Button`, `Input`, `Checkbox`, `Modal`, `Popover`,
1111
`Spinner`.
1212
- **Hooks:** `useMediaQuery(query)` — re-renders on a CSS media-query
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { fireEvent, render, screen } from '@testing-library/react';
5+
6+
import { Pagination } from './Pagination';
7+
8+
describe('Pagination', () => {
9+
it('shows the current page and total', () => {
10+
render(<Pagination page={2} totalPages={5} onChange={() => {}} />);
11+
expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
12+
});
13+
14+
it('renders an optional count label before the page indicator', () => {
15+
render(<Pagination page={1} totalPages={3} countLabel="42 objects" onChange={() => {}} />);
16+
const nav = screen.getByRole('navigation');
17+
expect(nav).toHaveTextContent('42 objects');
18+
expect(nav).toHaveTextContent('Page 1 of 3');
19+
});
20+
21+
it('disables Prev on the first page', () => {
22+
render(<Pagination page={1} totalPages={5} onChange={() => {}} />);
23+
expect(screen.getByRole('button', { name: /Prev/ })).toBeDisabled();
24+
expect(screen.getByRole('button', { name: /Next/ })).toBeEnabled();
25+
});
26+
27+
it('disables Next on the last page', () => {
28+
render(<Pagination page={5} totalPages={5} onChange={() => {}} />);
29+
expect(screen.getByRole('button', { name: /Next/ })).toBeDisabled();
30+
expect(screen.getByRole('button', { name: /Prev/ })).toBeEnabled();
31+
});
32+
33+
it('requests the neighbouring page on click', () => {
34+
const onChange = vi.fn();
35+
render(<Pagination page={3} totalPages={5} onChange={onChange} />);
36+
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
37+
expect(onChange).toHaveBeenCalledWith(4);
38+
fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
39+
expect(onChange).toHaveBeenCalledWith(2);
40+
});
41+
42+
it('does not fire onChange when the disabled edge button is clicked', () => {
43+
const onChange = vi.fn();
44+
render(<Pagination page={1} totalPages={1} onChange={onChange} />);
45+
fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
46+
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
47+
expect(onChange).not.toHaveBeenCalled();
48+
});
49+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Pagination — generic prev/next pager. Props-driven, no business
2+
// knowledge (CLAUDE.md §7): the caller owns the page state and supplies
3+
// the bounds. Extracted from the ListPage god-component (#428).
4+
5+
export interface PaginationProps {
6+
page: number;
7+
totalPages: number;
8+
onChange: (next: number) => void;
9+
/**
10+
* Optional leading label (e.g. "1,234 objects", #95) shown before the
11+
* page indicator, separated by a middot. Omit for a bare pager.
12+
*/
13+
countLabel?: string;
14+
/** Extra classes for the wrapping `<nav>` so callers can adjust spacing. */
15+
className?: string;
16+
}
17+
18+
export function Pagination({
19+
page,
20+
totalPages,
21+
onChange,
22+
countLabel,
23+
className = '',
24+
}: PaginationProps) {
25+
const prevDisabled = page <= 1;
26+
const nextDisabled = page >= totalPages;
27+
const buttonClass = (disabled: boolean): string =>
28+
// Give the enabled button an explicit border-gray-300 (matching the
29+
// Filter/Customize buttons): a bare `border` falls back to Tailwind's
30+
// light-gray default, which the dark-mode utility remap can't catch
31+
// and shows as a white border in dark mode.
32+
`px-3 py-1 rounded border ${
33+
disabled
34+
? 'text-gray-300 border-gray-200 cursor-not-allowed'
35+
: 'border-gray-300 hover:bg-gray-100'
36+
}`;
37+
return (
38+
<nav className={`flex items-center justify-between text-sm text-gray-600 ${className}`}>
39+
<span>
40+
{countLabel != null && (
41+
<>
42+
{countLabel}
43+
{/* A vertically-centered middot separates the count from the
44+
page indicator (#95) — not a period. */}
45+
<span aria-hidden className="px-2 text-gray-400">
46+
·
47+
</span>
48+
</>
49+
)}
50+
Page {page} of {totalPages}
51+
</span>
52+
<div className="flex gap-2">
53+
<button
54+
type="button"
55+
className={buttonClass(prevDisabled)}
56+
disabled={prevDisabled}
57+
onClick={() => onChange(page - 1)}
58+
>
59+
← Prev
60+
</button>
61+
<button
62+
type="button"
63+
className={buttonClass(nextDisabled)}
64+
disabled={nextDisabled}
65+
onClick={() => onChange(page + 1)}
66+
>
67+
Next →
68+
</button>
69+
</div>
70+
</nav>
71+
);
72+
}

frontend/packages/ui/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export type { TableColumn, TableProps } from './Table';
2626
export { RecordCardList } from './RecordCardList';
2727
export type { RecordCardListProps } from './RecordCardList';
2828

29+
export { Pagination } from './Pagination';
30+
export type { PaginationProps } from './Pagination';
31+
2932
export { useMediaQuery } from './useMediaQuery';
3033

3134
export { Input } from './Input';

0 commit comments

Comments
 (0)