Skip to content

Commit 4afc63c

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(spa): responsive record-cards for the list on narrow viewports (#421) (#528)
On phones/tablets a wide changelist table is unreadable. Below Tailwind's `md` breakpoint the list now renders each object as a stacked, tappable record-card instead of the table. - @dar/ui `RecordCardList<Row>`: a generic, model-agnostic card layout that consumes the SAME `TableColumn<Row>[]` descriptors, rows, selection and navigation props as `Table` — a page defines its columns once and renders either layout. First column = card title (carries the open-in-new-tab anchor, #253); the rest become a label/value list. Selection checkboxes, loading skeletons, and the empty-state mirror `Table`. - @dar/ui `useMediaQuery(query)`: a generic `useSyncExternalStore`-based hook (no first-paint flash, inert without `matchMedia`). Switching the layout in JS keeps a single layout in the DOM, so there are no duplicate inputs/checkboxes. - ListPage swaps `Table` → `RecordCardList` under `(max-width: 767px)`, reusing the existing columns, selection and preserved-filter nav. Unit tests: RecordCardList (render, tap-to-open, new-tab anchor, modified-click guard, selection, loading, empty) + useMediaQuery (initial match, change, unsubscribe, no-matchMedia). Full vitest 124 passed; typecheck + eslint + stylelint + dark-mode guard clean; build ok. Closes #421 Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11408f9 commit 4afc63c

7 files changed

Lines changed: 412 additions & 17 deletions

File tree

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

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,18 @@ import {
2020
usePersistedState,
2121
writeJSON,
2222
} from '@dar/customization';
23-
import { Breadcrumb, Button, Card, Checkbox, EmptyState, Modal, Skeleton, Table } from '@dar/ui';
23+
import {
24+
Breadcrumb,
25+
Button,
26+
Card,
27+
Checkbox,
28+
EmptyState,
29+
Modal,
30+
RecordCardList,
31+
Skeleton,
32+
Table,
33+
useMediaQuery,
34+
} from '@dar/ui';
2435
import { FieldValueView } from '@dar/details';
2536
import { DateHierarchyBar } from '@dar/list';
2637
import { FilterBar } from '@dar/search';
@@ -47,6 +58,11 @@ export function ListPage() {
4758
// Router basename (the SPA mount) so row anchors carry a full, openable
4859
// href for native open-in-new-tab (#253).
4960
const hrefBase = useHref('/').replace(/\/$/, '');
61+
// Below Tailwind's `md` breakpoint (768px) a wide table is unreadable on
62+
// phones/tablets — render the same rows as stacked record-cards instead
63+
// (#421). Switching in JS (not a CSS `hidden`/`md:block` pair) keeps a
64+
// single layout in the DOM, so there are no duplicate inputs/checkboxes.
65+
const isNarrow = useMediaQuery('(max-width: 767px)');
5066
const client = useApiClient();
5167
const toast = useToast();
5268
const [searchParams, setSearchParams] = useSearchParams();
@@ -562,19 +578,19 @@ export function ListPage() {
562578
</div>
563579
)}
564580

565-
{/* Table is always full-width now — filters live in the modal.
566-
Row checkboxes appear only when the model has bulk actions
567-
the user can run (#182). An empty list renders a proper
568-
empty-state with a "+ Add" call-to-action (#293) instead of a
569-
bare message, so a fresh model has an obvious next step. */}
570-
<Card>
571-
{/* A foreground refetch (filter / search / sort / page change)
572-
keeps the previous `data` in hand, so without this the stale
573-
rows would just sit there with no sign anything is happening.
574-
Show skeleton rows while `loading` so the reload is visible —
575-
and only fall back to the empty-state when we're genuinely
576-
idle-and-empty, not mid-fetch. */}
577-
{!loading && data.results.length === 0 ? (
581+
{/* The list is a full-width table on desktop and stacked record-cards
582+
on narrow viewports (#421); both read from the same `columns`.
583+
Row checkboxes appear only when the model has bulk actions the
584+
user can run (#182). An empty list renders a proper empty-state
585+
with a "+ Add" call-to-action (#293) instead of a bare message,
586+
so a fresh model has an obvious next step.
587+
588+
A foreground refetch (filter / search / sort / page change) keeps
589+
the previous `data` in hand; showing skeletons while `loading`
590+
makes the reload visible, and we only fall back to the
591+
empty-state when genuinely idle-and-empty, not mid-fetch. */}
592+
{!loading && data.results.length === 0 ? (
593+
<Card>
578594
<EmptyState
579595
title={q || activeFilterCount > 0 ? 'No matches' : 'No objects yet'}
580596
description={emptyLabel(Boolean(q), activeFilterCount)}
@@ -589,7 +605,34 @@ export function ListPage() {
589605
) : undefined
590606
}
591607
/>
592-
) : (
608+
</Card>
609+
) : isNarrow ? (
610+
// Stacked record-cards: the card list is its own bordered surface,
611+
// so it isn't wrapped in the table's <Card>. Inline list_editable
612+
// cells (the `columns` render functions) still work; sort / resize
613+
// are desktop-only affordances not surfaced on the cards.
614+
<RecordCardList
615+
columns={columns}
616+
rows={data.results}
617+
rowKey={(r) => r.pk}
618+
onRowClick={(row) =>
619+
navigate(
620+
withPreservedFilters(`/${appLabel}/${modelName}/${row.pk}`, searchParams.toString()),
621+
)
622+
}
623+
rowHref={(row) =>
624+
withPreservedFilters(
625+
`${hrefBase}/${appLabel}/${modelName}/${row.pk}`,
626+
searchParams.toString(),
627+
)
628+
}
629+
selectable={canRunActions}
630+
selectedKeys={selected}
631+
onToggleRow={toggleRow}
632+
loading={loading}
633+
/>
634+
) : (
635+
<Card>
593636
<Table
594637
columns={columns}
595638
rows={data.results}
@@ -616,8 +659,8 @@ export function ListPage() {
616659
columnWidths={colWidths}
617660
onColumnResize={resizeColumn}
618661
/>
619-
)}
620-
</Card>
662+
</Card>
663+
)}
621664
<Pagination page={data.page} totalPages={totalPages} onChange={setPage} />
622665

623666
{pendingAction && (

frontend/packages/ui/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@
33
Generic, reusable, props-driven React components. **No business logic
44
allowed.** No knowledge of Django, the API, or any consumer model.
55

6+
## Exports
7+
8+
- **Layout / data:** `Card`, `Table`, `RecordCardList`, `Skeleton`,
9+
`EmptyState`, `Breadcrumb`.
10+
- **Controls:** `Button`, `Input`, `Checkbox`, `Modal`, `Popover`,
11+
`Spinner`.
12+
- **Hooks:** `useMediaQuery(query)` — re-renders on a CSS media-query
13+
match change (e.g. switching the list between `Table` and
14+
`RecordCardList` at the `md` breakpoint, #421).
15+
16+
`Table` and `RecordCardList` share the same `TableColumn<Row>[]`
17+
descriptor, so a page defines its columns once and renders the table on
18+
wide viewports or stacked record-cards on narrow ones. `RecordCardList`
19+
treats the first column as the card title and the rest as a label/value
20+
list.
21+
622
Planned components (PR #6 / #7):
723

824
- `Button`, `IconButton`
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 { RecordCardList } from './RecordCardList';
7+
import type { TableColumn } from './Table';
8+
9+
interface Row {
10+
id: number;
11+
name: string;
12+
email: string;
13+
}
14+
15+
const columns: TableColumn<Row>[] = [
16+
{ key: 'id', header: 'ID', render: (r) => r.id },
17+
{ key: 'name', header: 'Name', render: (r) => r.name },
18+
{ key: 'email', header: 'Email', render: (r) => r.email },
19+
];
20+
21+
const rows: Row[] = [
22+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
23+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
24+
];
25+
26+
function renderList(props: Partial<React.ComponentProps<typeof RecordCardList<Row>>> = {}) {
27+
return render(<RecordCardList columns={columns} rows={rows} rowKey={(r) => r.id} {...props} />);
28+
}
29+
30+
describe('RecordCardList', () => {
31+
it('renders one card per row with the non-title columns as labelled values', () => {
32+
renderList();
33+
// First column is the identity/title; the rest become label/value pairs.
34+
expect(screen.getByText('Alice')).toBeInTheDocument();
35+
expect(screen.getByText('Bob')).toBeInTheDocument();
36+
expect(screen.getAllByText('Email')).toHaveLength(2);
37+
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
38+
// The identity column does not get repeated as a label/value row.
39+
expect(screen.queryByText('ID')).not.toBeInTheDocument();
40+
});
41+
42+
it('navigates on card tap via onRowClick', () => {
43+
const onRowClick = vi.fn();
44+
renderList({ onRowClick });
45+
// Tap a body value (the card surface) — opens the record.
46+
fireEvent.click(screen.getByText('Alice'));
47+
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
48+
});
49+
50+
it('renders the title (first column) as an open-in-new-tab anchor when rowHref is set', () => {
51+
renderList({ rowHref: (r) => `/app/model/${r.id}` });
52+
// The first column (`id`) is the card title and carries the anchor.
53+
const link = screen.getByText('1').closest('a');
54+
expect(link).toHaveAttribute('href', '/app/model/1');
55+
});
56+
57+
it('does not navigate in-app on a modified (new-tab) click of the title', () => {
58+
const onRowClick = vi.fn();
59+
renderList({ onRowClick, rowHref: (r) => `/app/model/${r.id}` });
60+
fireEvent.click(screen.getByText('1'), { metaKey: true });
61+
expect(onRowClick).not.toHaveBeenCalled();
62+
});
63+
64+
it('toggles selection without opening the record', () => {
65+
const onToggleRow = vi.fn();
66+
const onRowClick = vi.fn();
67+
renderList({ selectable: true, onToggleRow, onRowClick, selectedKeys: new Set() });
68+
const [firstBox] = screen.getAllByRole('checkbox');
69+
if (!firstBox) throw new Error('expected a selection checkbox per card');
70+
fireEvent.click(firstBox);
71+
expect(onToggleRow).toHaveBeenCalledWith(1);
72+
// The selection click must not bubble up to the card's navigation.
73+
expect(onRowClick).not.toHaveBeenCalled();
74+
});
75+
76+
it('shows shimmer cards and marks the region busy while loading', () => {
77+
const { container } = renderList({ loading: true });
78+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
79+
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
80+
expect(container.querySelector('.animate-pulse')).not.toBeNull();
81+
});
82+
83+
it('renders the empty label when idle and empty', () => {
84+
render(
85+
<RecordCardList columns={columns} rows={[]} rowKey={(r) => r.id} emptyLabel="Nothing here" />,
86+
);
87+
expect(screen.getByText('Nothing here')).toBeInTheDocument();
88+
});
89+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// RecordCardList — a stacked card layout for tabular data on narrow
2+
// viewports (#421). It consumes the SAME `TableColumn<Row>[]` descriptors,
3+
// rows, selection, and navigation props as `<Table>`, so a page defines
4+
// its columns once and renders either layout from a single source. The
5+
// first column is the record's identity (the card title); the rest become
6+
// a label/value list. Generic and model-agnostic (CLAUDE.md §7).
7+
8+
import type { ReactNode } from 'react';
9+
10+
import { Checkbox } from './Checkbox';
11+
import { Skeleton } from './Skeleton';
12+
import type { TableColumn } from './Table';
13+
14+
export interface RecordCardListProps<Row> {
15+
/** Same column descriptors as `<Table>`; the first is the card title. */
16+
columns: TableColumn<Row>[];
17+
rows: Row[];
18+
rowKey: (row: Row) => string | number;
19+
/** Tap a card to open the record (in-app navigation). */
20+
onRowClick?: (row: Row) => void;
21+
/**
22+
* When set, the card title becomes a real `<a href>` so the browser's
23+
* native open-in-new-tab works (Cmd/Ctrl/middle-click); a plain tap is
24+
* intercepted for in-app nav via `onRowClick`. Mirrors `<Table>` (#253).
25+
* Should be the full app path including the router basename.
26+
*/
27+
rowHref?: (row: Row) => string;
28+
/** Render a per-card selection checkbox wired to the props below. */
29+
selectable?: boolean;
30+
selectedKeys?: Set<string | number>;
31+
onToggleRow?: (key: string | number) => void;
32+
/** Show shimmer placeholder cards instead of `rows` during a refetch. */
33+
loading?: boolean;
34+
/** Placeholder card count while `loading` (defaults to a stable count). */
35+
skeletonRows?: number;
36+
emptyLabel?: string;
37+
}
38+
39+
export function RecordCardList<Row>({
40+
columns,
41+
rows,
42+
rowKey,
43+
onRowClick,
44+
rowHref,
45+
selectable = false,
46+
selectedKeys,
47+
onToggleRow,
48+
loading = false,
49+
skeletonRows,
50+
emptyLabel = 'No results.',
51+
}: RecordCardListProps<Row>) {
52+
// Only fall back to the empty-state when genuinely idle-and-empty — not
53+
// mid-fetch, where skeleton cards are shown instead (matches `<Table>`).
54+
if (!loading && rows.length === 0) {
55+
return <div className="py-8 text-center text-sm text-gray-500">{emptyLabel}</div>;
56+
}
57+
58+
const selected = selectedKeys ?? new Set<string | number>();
59+
const skeletonCount = skeletonRows ?? Math.min(Math.max(rows.length || 6, 3), 12);
60+
// First column = identity → title; the rest become the label/value body.
61+
const [titleCol, ...detailCols] = columns;
62+
63+
if (loading) {
64+
return (
65+
<ul className="space-y-2" aria-busy="true">
66+
{Array.from({ length: skeletonCount }).map((_, i) => (
67+
<li key={`dar-card-skeleton-${i}`} className="rounded-lg border border-gray-300 bg-white p-4">
68+
<Skeleton className="mb-3 h-5 w-1/3" />
69+
<div className="space-y-2">
70+
{Array.from({ length: Math.max(detailCols.length, 2) }).map((__, j) => (
71+
<Skeleton key={j} className="h-4 w-2/3" />
72+
))}
73+
</div>
74+
</li>
75+
))}
76+
</ul>
77+
);
78+
}
79+
80+
return (
81+
<ul className="space-y-2">
82+
{rows.map((row) => {
83+
const key = rowKey(row);
84+
const isSelected = selected.has(key);
85+
const title: ReactNode = titleCol ? titleCol.render(row) : null;
86+
return (
87+
<li
88+
key={key}
89+
onClick={
90+
onRowClick
91+
? (e) => {
92+
// A modified click (Cmd/Ctrl/Shift/Alt) is the browser's
93+
// open-in-new-tab gesture — let the title anchor handle
94+
// it; don't also navigate in-app.
95+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
96+
onRowClick(row);
97+
}
98+
: undefined
99+
}
100+
className={`rounded-lg border bg-white p-4 ${
101+
isSelected ? 'border-primary' : 'border-gray-300'
102+
} ${onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''}`}
103+
>
104+
<div className="flex items-start justify-between gap-3">
105+
<div className="min-w-0 flex-1 font-medium text-gray-900">
106+
{rowHref && titleCol ? (
107+
// Real anchor so native open-in-new-tab works; a plain
108+
// left-click is intercepted for in-app nav (#253).
109+
<a
110+
href={rowHref(row)}
111+
className="text-inherit no-underline hover:underline"
112+
onClick={(e) => {
113+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
114+
e.preventDefault();
115+
e.stopPropagation();
116+
onRowClick?.(row);
117+
}}
118+
>
119+
{title}
120+
</a>
121+
) : (
122+
title
123+
)}
124+
</div>
125+
{selectable && (
126+
// stopPropagation so toggling selection doesn't also open
127+
// the record (the card's onClick navigates).
128+
<span onClick={(e) => e.stopPropagation()} className="shrink-0">
129+
<Checkbox
130+
aria-label="Select row"
131+
checked={isSelected}
132+
onChange={() => onToggleRow?.(key)}
133+
/>
134+
</span>
135+
)}
136+
</div>
137+
{detailCols.length > 0 && (
138+
<dl className="mt-3 space-y-2 text-sm">
139+
{detailCols.map((col) => (
140+
<div key={col.key}>
141+
<dt className="text-xs uppercase tracking-wide text-gray-500">{col.header}</dt>
142+
<dd className="mt-0.5 text-gray-700">{col.render(row)}</dd>
143+
</div>
144+
))}
145+
</dl>
146+
)}
147+
</li>
148+
);
149+
})}
150+
</ul>
151+
);
152+
}

frontend/packages/ui/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export type { EmptyStateProps } from './EmptyState';
2323
export { Table } from './Table';
2424
export type { TableColumn, TableProps } from './Table';
2525

26+
export { RecordCardList } from './RecordCardList';
27+
export type { RecordCardListProps } from './RecordCardList';
28+
29+
export { useMediaQuery } from './useMediaQuery';
30+
2631
export { Input } from './Input';
2732
export type { InputProps } from './Input';
2833

0 commit comments

Comments
 (0)