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 ( - - ); -} 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 `