diff --git a/frontend/apps/web/src/pages/ListPage.tsx b/frontend/apps/web/src/pages/ListPage.tsx
index 8d6701f..ee7d2fa 100644
--- a/frontend/apps/web/src/pages/ListPage.tsx
+++ b/frontend/apps/web/src/pages/ListPage.tsx
@@ -27,12 +27,13 @@ import {
Checkbox,
EmptyState,
Modal,
+ Pagination,
RecordCardList,
- Skeleton,
Table,
useMediaQuery,
} from '@dar/ui';
import { FieldValueView } from '@dar/details';
+import { ListSkeleton } from '@dar/list';
import { FilterBar } from '@dar/search';
import { useToast } from '../toast';
@@ -732,42 +733,6 @@ export function ListPage() {
);
}
-// First-paint skeleton: shown while the very first list load is in
-// flight (no cached/stale data yet, so the columns aren't known). Mirrors
-// the real layout — title + count, the toolbar row, then a card of rows —
-// with a sensible default column count so the page has weight instead of
-// a lone spinner. Once `data` exists, refetch loading is shown inline by
-// the Table's own `loading` skeleton (which uses the real columns).
-function ListSkeleton() {
- return (
-
-
- Loading…
-
-
-
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, i) => (
-
- {Array.from({ length: 5 }).map((__, j) => (
-
- ))}
-
- ))}
-
-
-
- );
-}
-
function capitalize(value: string): string {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
@@ -780,57 +745,3 @@ function emptyLabel(hasQuery: boolean, chipCount: number): string {
if (hasQuery || chipCount > 0) return 'No results match the current search / filters.';
return 'No objects yet.';
}
-
-interface PaginationProps {
- page: number;
- totalPages: number;
- /** "N object(s)" — shown before the page indicator (#95). */
- countLabel: string;
- onChange: (next: number) => void;
-}
-
-function Pagination({ page, totalPages, countLabel, onChange }: PaginationProps) {
- const prevDisabled = page <= 1;
- const nextDisabled = page >= totalPages;
- const buttonClass = (disabled: boolean): string =>
- // Give the enabled button an explicit border-gray-300 (matching the
- // Filter/Customize buttons): a bare `border` falls back to Tailwind's
- // light-gray default, which the dark-mode utility remap can't catch
- // and shows as a white border in dark mode.
- `px-3 py-1 rounded border ${
- disabled
- ? 'text-gray-300 border-gray-200 cursor-not-allowed'
- : 'border-gray-300 hover:bg-gray-100'
- }`;
- return (
-
-
- {countLabel}
- {/* A vertically-centered middot separates the count from the page
- indicator (#95) — not a period. */}
-
- ·
-
- Page {page} of {totalPages}
-
-
- onChange(page - 1)}
- >
- ← Prev
-
- onChange(page + 1)}
- >
- Next →
-
-
-
- );
-}
diff --git a/frontend/packages/list/README.md b/frontend/packages/list/README.md
index 2da81a7..7a7f888 100644
--- a/frontend/packages/list/README.md
+++ b/frontend/packages/list/README.md
@@ -13,9 +13,13 @@ layer and each unit can be unit-tested in isolation:
- **`DateHierarchyBar`** — the `date_hierarchy` drill-down breadcrumb +
next-level buckets (Django changelist parity). Props-driven
(`dh` + `onNavigate`), no router/business coupling.
+- **`ListSkeleton`** — first-paint placeholder for the list page (title +
+ count, toolbar row, then a card of rows) shown before the columns are
+ known. Optional `rows` / `columns` counts; no business coupling.
-More units (filter/columns modals, pagination, the `list_editable`
-controller) land here as the decomposition progresses.
+More units (filter/columns modals, the `list_editable` controller) land
+here as the decomposition progresses. (Generic, business-free primitives
+like `Pagination` live in `@dar/ui`, not here.)
## Rules
diff --git a/frontend/packages/list/src/ListSkeleton.test.tsx b/frontend/packages/list/src/ListSkeleton.test.tsx
new file mode 100644
index 0000000..786a27d
--- /dev/null
+++ b/frontend/packages/list/src/ListSkeleton.test.tsx
@@ -0,0 +1,25 @@
+import '@testing-library/jest-dom/vitest';
+
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+import { ListSkeleton } from './ListSkeleton';
+
+describe('ListSkeleton', () => {
+ it('marks the region busy and exposes a status for screen readers', () => {
+ const { container } = render( );
+ expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
+ expect(screen.getByRole('status')).toHaveTextContent('Loading');
+ });
+
+ it('renders shimmer placeholders', () => {
+ const { container } = render( );
+ expect(container.querySelector('.animate-pulse')).not.toBeNull();
+ });
+
+ it('honours the requested row/column counts', () => {
+ const { container } = render( );
+ // 2 title bars + 3 toolbar bars + (3 rows × 2 cells) = 11 placeholders.
+ expect(container.querySelectorAll('.animate-pulse')).toHaveLength(2 + 3 + 3 * 2);
+ });
+});
diff --git a/frontend/packages/list/src/ListSkeleton.tsx b/frontend/packages/list/src/ListSkeleton.tsx
new file mode 100644
index 0000000..1db983e
--- /dev/null
+++ b/frontend/packages/list/src/ListSkeleton.tsx
@@ -0,0 +1,49 @@
+// ListSkeleton — first-paint placeholder for the list page, shown while
+// the very first load is in flight (no cached/stale data yet, so the real
+// columns aren't known). Mirrors the real layout — title + count, the
+// toolbar row, then a card of rows — so the page has weight instead of a
+// lone spinner. Once data exists, refetch loading is shown inline by the
+// Table's own `loading` skeleton (which uses the real columns).
+//
+// Extracted from the ListPage god-component (#428 / #303). Props let a
+// caller tune the placeholder shape; the defaults match the prior inline
+// layout.
+
+import { Card, Skeleton } from '@dar/ui';
+
+export interface ListSkeletonProps {
+ /** Placeholder row count (default 8). */
+ rows?: number;
+ /** Placeholder cells per row (default 5). */
+ columns?: number;
+}
+
+export function ListSkeleton({ rows = 8, columns = 5 }: ListSkeletonProps = {}) {
+ return (
+
+
+ Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ {Array.from({ length: columns }).map((__, j) => (
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/packages/list/src/index.ts b/frontend/packages/list/src/index.ts
index c5a492c..8284cad 100644
--- a/frontend/packages/list/src/index.ts
+++ b/frontend/packages/list/src/index.ts
@@ -4,3 +4,4 @@
// here as the decomposition progresses (#303).
export { DateHierarchyBar, type DateHierarchyBarProps } from './DateHierarchyBar';
+export { ListSkeleton, type ListSkeletonProps } from './ListSkeleton';
diff --git a/frontend/packages/ui/README.md b/frontend/packages/ui/README.md
index 3fa0f35..a5146fa 100644
--- a/frontend/packages/ui/README.md
+++ b/frontend/packages/ui/README.md
@@ -5,8 +5,8 @@ allowed.** No knowledge of Django, the API, or any consumer model.
## Exports
-- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Skeleton`,
- `EmptyState`, `Breadcrumb`.
+- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Pagination`,
+ `Skeleton`, `EmptyState`, `Breadcrumb`.
- **Controls:** `Button`, `Input`, `Checkbox`, `Modal`, `Popover`,
`Spinner`.
- **Hooks:** `useMediaQuery(query)` — re-renders on a CSS media-query
diff --git a/frontend/packages/ui/src/Pagination.test.tsx b/frontend/packages/ui/src/Pagination.test.tsx
new file mode 100644
index 0000000..a546e3e
--- /dev/null
+++ b/frontend/packages/ui/src/Pagination.test.tsx
@@ -0,0 +1,49 @@
+import '@testing-library/jest-dom/vitest';
+
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import { Pagination } from './Pagination';
+
+describe('Pagination', () => {
+ it('shows the current page and total', () => {
+ render( {}} />);
+ expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
+ });
+
+ it('renders an optional count label before the page indicator', () => {
+ render( {}} />);
+ const nav = screen.getByRole('navigation');
+ expect(nav).toHaveTextContent('42 objects');
+ expect(nav).toHaveTextContent('Page 1 of 3');
+ });
+
+ it('disables Prev on the first page', () => {
+ render( {}} />);
+ expect(screen.getByRole('button', { name: /Prev/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Next/ })).toBeEnabled();
+ });
+
+ it('disables Next on the last page', () => {
+ render( {}} />);
+ expect(screen.getByRole('button', { name: /Next/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Prev/ })).toBeEnabled();
+ });
+
+ it('requests the neighbouring page on click', () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Next/ }));
+ expect(onChange).toHaveBeenCalledWith(4);
+ fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
+ expect(onChange).toHaveBeenCalledWith(2);
+ });
+
+ it('does not fire onChange when the disabled edge button is clicked', () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
+ fireEvent.click(screen.getByRole('button', { name: /Next/ }));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/packages/ui/src/Pagination.tsx b/frontend/packages/ui/src/Pagination.tsx
new file mode 100644
index 0000000..19c302e
--- /dev/null
+++ b/frontend/packages/ui/src/Pagination.tsx
@@ -0,0 +1,72 @@
+// Pagination — generic prev/next pager. Props-driven, no business
+// knowledge (CLAUDE.md §7): the caller owns the page state and supplies
+// the bounds. Extracted from the ListPage god-component (#428).
+
+export interface PaginationProps {
+ page: number;
+ totalPages: number;
+ onChange: (next: number) => void;
+ /**
+ * Optional leading label (e.g. "1,234 objects", #95) shown before the
+ * page indicator, separated by a middot. Omit for a bare pager.
+ */
+ countLabel?: string;
+ /** Extra classes for the wrapping `` so callers can adjust spacing. */
+ className?: string;
+}
+
+export function Pagination({
+ page,
+ totalPages,
+ onChange,
+ countLabel,
+ className = '',
+}: PaginationProps) {
+ const prevDisabled = page <= 1;
+ const nextDisabled = page >= totalPages;
+ const buttonClass = (disabled: boolean): string =>
+ // Give the enabled button an explicit border-gray-300 (matching the
+ // Filter/Customize buttons): a bare `border` falls back to Tailwind's
+ // light-gray default, which the dark-mode utility remap can't catch
+ // and shows as a white border in dark mode.
+ `px-3 py-1 rounded border ${
+ disabled
+ ? 'text-gray-300 border-gray-200 cursor-not-allowed'
+ : 'border-gray-300 hover:bg-gray-100'
+ }`;
+ return (
+
+
+ {countLabel != null && (
+ <>
+ {countLabel}
+ {/* A vertically-centered middot separates the count from the
+ page indicator (#95) — not a period. */}
+
+ ·
+
+ >
+ )}
+ Page {page} of {totalPages}
+
+
+ onChange(page - 1)}
+ >
+ ← Prev
+
+ onChange(page + 1)}
+ >
+ Next →
+
+
+
+ );
+}
diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts
index 20bb9a1..6e66ed0 100644
--- a/frontend/packages/ui/src/index.ts
+++ b/frontend/packages/ui/src/index.ts
@@ -26,6 +26,9 @@ export type { TableColumn, TableProps } from './Table';
export { RecordCardList } from './RecordCardList';
export type { RecordCardListProps } from './RecordCardList';
+export { Pagination } from './Pagination';
+export type { PaginationProps } from './Pagination';
+
export { useMediaQuery } from './useMediaQuery';
export { Input } from './Input';