Skip to content

Commit e3ebbce

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 4afc63c commit e3ebbce

9 files changed

Lines changed: 181 additions & 88 deletions

File tree

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

Lines changed: 2 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +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 { DateHierarchyBar } from '@dar/list';
36+
import { DateHierarchyBar, ListSkeleton } from '@dar/list';
3737
import { FilterBar } from '@dar/search';
3838

3939
import { useToast } from '../toast';
@@ -742,42 +742,6 @@ export function ListPage() {
742742
);
743743
}
744744

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

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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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('disables Prev on the first page', () => {
15+
render(<Pagination page={1} totalPages={5} onChange={() => {}} />);
16+
expect(screen.getByRole('button', { name: /Prev/ })).toBeDisabled();
17+
expect(screen.getByRole('button', { name: /Next/ })).toBeEnabled();
18+
});
19+
20+
it('disables Next on the last page', () => {
21+
render(<Pagination page={5} totalPages={5} onChange={() => {}} />);
22+
expect(screen.getByRole('button', { name: /Next/ })).toBeDisabled();
23+
expect(screen.getByRole('button', { name: /Prev/ })).toBeEnabled();
24+
});
25+
26+
it('requests the neighbouring page on click', () => {
27+
const onChange = vi.fn();
28+
render(<Pagination page={3} totalPages={5} onChange={onChange} />);
29+
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
30+
expect(onChange).toHaveBeenCalledWith(4);
31+
fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
32+
expect(onChange).toHaveBeenCalledWith(2);
33+
});
34+
35+
it('does not fire onChange when the disabled edge button is clicked', () => {
36+
const onChange = vi.fn();
37+
render(<Pagination page={1} totalPages={1} onChange={onChange} />);
38+
fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
39+
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
40+
expect(onChange).not.toHaveBeenCalled();
41+
});
42+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
/** Extra classes for the wrapping `<nav>` so callers can adjust spacing. */
10+
className?: string;
11+
}
12+
13+
export function Pagination({ page, totalPages, onChange, className = '' }: PaginationProps) {
14+
const prevDisabled = page <= 1;
15+
const nextDisabled = page >= totalPages;
16+
const buttonClass = (disabled: boolean): string =>
17+
// Give the enabled button an explicit border-gray-300 (matching the
18+
// Filter/Customize buttons): a bare `border` falls back to Tailwind's
19+
// light-gray default, which the dark-mode utility remap can't catch
20+
// and shows as a white border in dark mode.
21+
`px-3 py-1 rounded border ${
22+
disabled
23+
? 'text-gray-300 border-gray-200 cursor-not-allowed'
24+
: 'border-gray-300 hover:bg-gray-100'
25+
}`;
26+
return (
27+
<nav className={`flex items-center justify-between text-sm text-gray-600 ${className}`}>
28+
<span>
29+
Page {page} of {totalPages}
30+
</span>
31+
<div className="flex gap-2">
32+
<button
33+
type="button"
34+
className={buttonClass(prevDisabled)}
35+
disabled={prevDisabled}
36+
onClick={() => onChange(page - 1)}
37+
>
38+
← Prev
39+
</button>
40+
<button
41+
type="button"
42+
className={buttonClass(nextDisabled)}
43+
disabled={nextDisabled}
44+
onClick={() => onChange(page + 1)}
45+
>
46+
Next →
47+
</button>
48+
</div>
49+
</nav>
50+
);
51+
}

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)