Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 2 additions & 91 deletions frontend/apps/web/src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="space-y-4" aria-busy="true">
<span role="status" className="sr-only">
Loading…
</span>
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
<Card>
<div className="divide-y divide-gray-100">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3">
{Array.from({ length: 5 }).map((__, j) => (
<Skeleton key={j} className="h-4 flex-1" />
))}
</div>
))}
</div>
</Card>
</div>
);
}

function capitalize(value: string): string {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1);
Expand All @@ -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 (
<nav className="flex items-center justify-between text-sm text-gray-600">
<span>
{countLabel}
{/* A vertically-centered middot separates the count from the page
indicator (#95) — not a period. */}
<span aria-hidden className="px-2 text-gray-400">
·
</span>
Page {page} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
className={buttonClass(prevDisabled)}
disabled={prevDisabled}
onClick={() => onChange(page - 1)}
>
← Prev
</button>
<button
type="button"
className={buttonClass(nextDisabled)}
disabled={nextDisabled}
onClick={() => onChange(page + 1)}
>
Next →
</button>
</div>
</nav>
);
}
8 changes: 6 additions & 2 deletions frontend/packages/list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions frontend/packages/list/src/ListSkeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ListSkeleton />);
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Loading');
});

it('renders shimmer placeholders', () => {
const { container } = render(<ListSkeleton />);
expect(container.querySelector('.animate-pulse')).not.toBeNull();
});

it('honours the requested row/column counts', () => {
const { container } = render(<ListSkeleton rows={3} columns={2} />);
// 2 title bars + 3 toolbar bars + (3 rows × 2 cells) = 11 placeholders.
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(2 + 3 + 3 * 2);
});
});
49 changes: 49 additions & 0 deletions frontend/packages/list/src/ListSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4" aria-busy="true">
<span role="status" className="sr-only">
Loading…
</span>
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
<Card>
<div className="divide-y divide-gray-100">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3">
{Array.from({ length: columns }).map((__, j) => (
<Skeleton key={j} className="h-4 flex-1" />
))}
</div>
))}
</div>
</Card>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/packages/list/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
// here as the decomposition progresses (#303).

export { DateHierarchyBar, type DateHierarchyBarProps } from './DateHierarchyBar';
export { ListSkeleton, type ListSkeletonProps } from './ListSkeleton';
4 changes: 2 additions & 2 deletions frontend/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions frontend/packages/ui/src/Pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Pagination page={2} totalPages={5} onChange={() => {}} />);
expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
});

it('renders an optional count label before the page indicator', () => {
render(<Pagination page={1} totalPages={3} countLabel="42 objects" onChange={() => {}} />);
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(<Pagination page={1} totalPages={5} onChange={() => {}} />);
expect(screen.getByRole('button', { name: /Prev/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Next/ })).toBeEnabled();
});

it('disables Next on the last page', () => {
render(<Pagination page={5} totalPages={5} onChange={() => {}} />);
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(<Pagination page={3} totalPages={5} onChange={onChange} />);
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(<Pagination page={1} totalPages={1} onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: /Prev/ }));
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
expect(onChange).not.toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions frontend/packages/ui/src/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 `<nav>` 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 (
<nav className={`flex items-center justify-between text-sm text-gray-600 ${className}`}>
<span>
{countLabel != null && (
<>
{countLabel}
{/* A vertically-centered middot separates the count from the
page indicator (#95) — not a period. */}
<span aria-hidden className="px-2 text-gray-400">
·
</span>
</>
)}
Page {page} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
className={buttonClass(prevDisabled)}
disabled={prevDisabled}
onClick={() => onChange(page - 1)}
>
← Prev
</button>
<button
type="button"
className={buttonClass(nextDisabled)}
disabled={nextDisabled}
onClick={() => onChange(page + 1)}
>
Next →
</button>
</div>
</nav>
);
}
3 changes: 3 additions & 0 deletions frontend/packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading