@lifesg/react-design-system 4.0.0-alpha.13 → 4.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/external/dompurify/dist/purify.es.js +4 -2
- package/cjs/external/dompurify/dist/purify.es.js.map +1 -1
- package/cjs/modal-v2/slots/slot-styles.js +1 -1
- package/cjs/modal-v2/slots/slot-styles.js.map +1 -1
- package/cjs/modal-v2/slots/{slot-styles_ayb9il.css → slot-styles_ygu6zc.css} +1 -1
- package/cjs/notification-banner/notification-banner-hoc.js +1 -1
- package/cjs/notification-banner/notification-banner-hoc.js.map +1 -1
- package/external/dompurify/dist/purify.es.js +4 -2
- package/external/dompurify/dist/purify.es.js.map +1 -1
- package/modal-v2/slots/slot-styles.js +1 -1
- package/modal-v2/slots/slot-styles.js.map +1 -1
- package/modal-v2/slots/{slot-styles_ayb9il.css → slot-styles_ygu6zc.css} +1 -1
- package/notification-banner/notification-banner-hoc.js +1 -1
- package/notification-banner/notification-banner-hoc.js.map +1 -1
- package/package.json +2 -2
- package/scripts/cli.js +18 -0
- package/skills/fds-build/SKILL.md +167 -0
- package/skills/fds-build/catalogue.md +181 -0
- package/skills/fds-build/evals/evals.json +519 -0
- package/skills/fds-build/resources/components/accordion.md +128 -0
- package/skills/fds-build/resources/components/alert.md +54 -0
- package/skills/fds-build/resources/components/animation.md +18 -0
- package/skills/fds-build/resources/components/avatar.md +10 -0
- package/skills/fds-build/resources/components/badge.md +18 -0
- package/skills/fds-build/resources/components/box-container.md +61 -0
- package/skills/fds-build/resources/components/breadcrumb.md +60 -0
- package/skills/fds-build/resources/components/button.md +74 -0
- package/skills/fds-build/resources/components/calendar.md +113 -0
- package/skills/fds-build/resources/components/card.md +43 -0
- package/skills/fds-build/resources/components/checkbox.md +53 -0
- package/skills/fds-build/resources/components/countdown-timer.md +20 -0
- package/skills/fds-build/resources/components/data-table.md +142 -0
- package/skills/fds-build/resources/components/date-navigator.md +25 -0
- package/skills/fds-build/resources/components/divider.md +23 -0
- package/skills/fds-build/resources/components/drawer.md +78 -0
- package/skills/fds-build/resources/components/error-display.md +137 -0
- package/skills/fds-build/resources/components/feedback-rating.md +61 -0
- package/skills/fds-build/resources/components/file-download.md +54 -0
- package/skills/fds-build/resources/components/file-upload.md +105 -0
- package/skills/fds-build/resources/components/filter.md +228 -0
- package/skills/fds-build/resources/components/footer.md +94 -0
- package/skills/fds-build/resources/components/form-date-input.md +85 -0
- package/skills/fds-build/resources/components/form-date-range-input.md +146 -0
- package/skills/fds-build/resources/components/form-e-signature.md +55 -0
- package/skills/fds-build/resources/components/form-input.md +65 -0
- package/skills/fds-build/resources/components/form-masked-input.md +69 -0
- package/skills/fds-build/resources/components/form-multi-select.md +100 -0
- package/skills/fds-build/resources/components/form-nested-multi-select.md +159 -0
- package/skills/fds-build/resources/components/form-nested-select.md +151 -0
- package/skills/fds-build/resources/components/form-otp-verification.md +175 -0
- package/skills/fds-build/resources/components/form-phone-number-input.md +87 -0
- package/skills/fds-build/resources/components/form-predictive-text-input.md +108 -0
- package/skills/fds-build/resources/components/form-select.md +103 -0
- package/skills/fds-build/resources/components/form-slider.md +116 -0
- package/skills/fds-build/resources/components/form-textarea.md +116 -0
- package/skills/fds-build/resources/components/form-time-range-picker.md +98 -0
- package/skills/fds-build/resources/components/form-timepicker.md +77 -0
- package/skills/fds-build/resources/components/form-unit-number-input.md +138 -0
- package/skills/fds-build/resources/components/fullscreen-image-carousel.md +159 -0
- package/skills/fds-build/resources/components/histogram-slider.md +41 -0
- package/skills/fds-build/resources/components/icon.md +167 -0
- package/skills/fds-build/resources/components/image-button.md +12 -0
- package/skills/fds-build/resources/components/input.md +51 -0
- package/skills/fds-build/resources/components/language-switcher.md +15 -0
- package/skills/fds-build/resources/components/layout.md +156 -0
- package/skills/fds-build/resources/components/link-list.md +120 -0
- package/skills/fds-build/resources/components/local-nav.md +119 -0
- package/skills/fds-build/resources/components/masthead.md +14 -0
- package/skills/fds-build/resources/components/menu.md +109 -0
- package/skills/fds-build/resources/components/modal.md +150 -0
- package/skills/fds-build/resources/components/navbar.md +270 -0
- package/skills/fds-build/resources/components/notification-banner.md +106 -0
- package/skills/fds-build/resources/components/pagination.md +104 -0
- package/skills/fds-build/resources/components/pill.md +55 -0
- package/skills/fds-build/resources/components/popover.md +80 -0
- package/skills/fds-build/resources/components/progress-indicator.md +47 -0
- package/skills/fds-build/resources/components/radio-button.md +66 -0
- package/skills/fds-build/resources/components/schedule.md +78 -0
- package/skills/fds-build/resources/components/sidenav.md +183 -0
- package/skills/fds-build/resources/components/singpass-button.md +14 -0
- package/skills/fds-build/resources/components/smart-app-banner.md +28 -0
- package/skills/fds-build/resources/components/tab.md +107 -0
- package/skills/fds-build/resources/components/table.md +65 -0
- package/skills/fds-build/resources/components/tag.md +68 -0
- package/skills/fds-build/resources/components/text-list.md +44 -0
- package/skills/fds-build/resources/components/time-slot-bar-week.md +66 -0
- package/skills/fds-build/resources/components/time-slot-bar.md +57 -0
- package/skills/fds-build/resources/components/time-slot-week-view.md +70 -0
- package/skills/fds-build/resources/components/timeline.md +69 -0
- package/skills/fds-build/resources/components/timetable.md +123 -0
- package/skills/fds-build/resources/components/toast.md +72 -0
- package/skills/fds-build/resources/components/toggle.md +119 -0
- package/skills/fds-build/resources/components/typography.md +86 -0
- package/skills/fds-build/resources/components/uneditable-section.md +84 -0
- package/skills/fds-build/resources/foundations/layout-composition-patterns.md +53 -0
- package/skills/fds-build/resources/foundations/tokens.md +547 -0
- package/skills/fds-build/resources/patterns/states.md +300 -0
- package/skills/fds-build/resources/setup/setup.md +202 -0
- package/skills/fds-build/resources/templates/form.md +427 -0
- package/skills/fds-build/resources/templates/listing.md +435 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Listing Template
|
|
2
|
+
|
|
3
|
+
Pages whose primary goal is finding, scanning, or filtering a collection of records.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
- Search results, service directories, record indexes
|
|
8
|
+
- Transaction histories, application lists
|
|
9
|
+
- Any screen presenting multiple instances of the same entity type
|
|
10
|
+
|
|
11
|
+
Do not use for:
|
|
12
|
+
|
|
13
|
+
- A single record's detail → use `detail.md`
|
|
14
|
+
- A mixed overview with stats and no primary table → use `dashboard.md`
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Page anatomy
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Navbar
|
|
22
|
+
──────────────────────────────────────────────────────────
|
|
23
|
+
<main>
|
|
24
|
+
└─ pageWrapper (padding: spacing-32 0; lg: spacing-48)
|
|
25
|
+
└─ Layout.Container type="grid"
|
|
26
|
+
└─ Layout.ColDiv xxsCols={[1, -1]}
|
|
27
|
+
├─ searchRow (optional)
|
|
28
|
+
└─ twoColumn
|
|
29
|
+
├─ filterSidebar (278px) — omit for search-only variant
|
|
30
|
+
├─ Filter
|
|
31
|
+
└─ resultsArea (flex 1)
|
|
32
|
+
├─ resultCountRow
|
|
33
|
+
├─ ErrorDisplay type="no-item-found" ← empty state
|
|
34
|
+
├─ Card list / DataTable
|
|
35
|
+
└─ paginationWrapper (centred)
|
|
36
|
+
──────────────────────────────────────────────────────────
|
|
37
|
+
Footer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
On mobile the `Filter` sidebar automatically collapses into a button that opens a modal.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Required components
|
|
45
|
+
|
|
46
|
+
| Slot | Component | Required |
|
|
47
|
+
| -------------- | ----------------------------------------- | -------- |
|
|
48
|
+
| Navigation | `Navbar` (masthead enabled) | Yes |
|
|
49
|
+
| Results | `Card` list/grid **or** `DataTable` | Yes |
|
|
50
|
+
| Result count | `Typography.BodyMD` — "X result(s) found" | Yes |
|
|
51
|
+
| Footer | `Footer` | Yes |
|
|
52
|
+
| Search bar | `Form.Input` + `Button` | No\* |
|
|
53
|
+
| Filter sidebar | `Filter` (~278px wide) | No\* |
|
|
54
|
+
| Pagination | `Pagination showFirstAndLastNav` | No |
|
|
55
|
+
|
|
56
|
+
\* Search bar and filter sidebar are individually optional but **at least one must be present**.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## CSS (ListingPage.module.css)
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
.pageWrapper {
|
|
64
|
+
padding: var(--fds-spacing-32) 0;
|
|
65
|
+
|
|
66
|
+
:global(body.fds-breakpoint-lg-min) & {
|
|
67
|
+
padding: var(--fds-spacing-48) 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.twoColumn {
|
|
72
|
+
display: flex;
|
|
73
|
+
gap: var(--fds-spacing-32);
|
|
74
|
+
align-items: flex-start;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.filterSidebar {
|
|
78
|
+
width: 278px;
|
|
79
|
+
flex-shrink: 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.resultsArea {
|
|
83
|
+
flex: 1;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
display: flex;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
gap: var(--fds-spacing-16);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.searchRow {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: var(--fds-spacing-16);
|
|
93
|
+
align-items: flex-end;
|
|
94
|
+
margin-bottom: var(--fds-spacing-24);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.resultCountRow {
|
|
98
|
+
display: flex;
|
|
99
|
+
justify-content: space-between;
|
|
100
|
+
align-items: center;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.cardBody {
|
|
104
|
+
padding: var(--fds-spacing-24);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.paginationWrapper {
|
|
108
|
+
display: flex;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
margin-top: var(--fds-spacing-24);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Rules
|
|
117
|
+
|
|
118
|
+
- Always show result count even when no filters are active — format: "X result(s) found"
|
|
119
|
+
- Always show `ErrorDisplay type="no-item-found"` with contextual copy when results are empty — never an empty container
|
|
120
|
+
- `Pagination` or lazy load — never both on the same list
|
|
121
|
+
- `showFirstAndLastNav` must be set on `Pagination`
|
|
122
|
+
- Filter sidebar width is fixed at ~278px; results area is `flex: 1`
|
|
123
|
+
- Inline action buttons (edit, delete) do not belong on listing cards — those go on the detail screen
|
|
124
|
+
- Changing `keyword`, `filters`, or `sortBy` must reset `page` to `1`
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Complete template
|
|
129
|
+
|
|
130
|
+
A single-file listing page with search, side filters, pagination, loading/error/empty states, and API stub wired in. Remove `filterSidebar` / `twoColumn` for search-only variant.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
// ListingPage.tsx
|
|
134
|
+
import { useState, useEffect, useMemo } from "react";
|
|
135
|
+
import { Navbar } from "@lifesg/react-design-system/navbar";
|
|
136
|
+
import { Footer } from "@lifesg/react-design-system/footer";
|
|
137
|
+
import { Layout } from "@lifesg/react-design-system/layout";
|
|
138
|
+
import { Typography } from "@lifesg/react-design-system/typography";
|
|
139
|
+
import { Card } from "@lifesg/react-design-system/card";
|
|
140
|
+
import { Filter } from "@lifesg/react-design-system/filter";
|
|
141
|
+
import { RadioButton } from "@lifesg/react-design-system/radio-button";
|
|
142
|
+
import { Pagination } from "@lifesg/react-design-system/pagination";
|
|
143
|
+
import { ErrorDisplay } from "@lifesg/react-design-system/error-display";
|
|
144
|
+
import { Alert } from "@lifesg/react-design-system/alert";
|
|
145
|
+
import { Button } from "@lifesg/react-design-system/button";
|
|
146
|
+
import { Form } from "@lifesg/react-design-system/form";
|
|
147
|
+
import styles from "./ListingPage.module.css";
|
|
148
|
+
|
|
149
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
interface Item {
|
|
152
|
+
id: string;
|
|
153
|
+
title: string;
|
|
154
|
+
description: string;
|
|
155
|
+
category: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ListingState {
|
|
159
|
+
keyword: string;
|
|
160
|
+
filters: Record<string, string[]>;
|
|
161
|
+
sortBy: string;
|
|
162
|
+
page: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const PAGE_SIZE = 10;
|
|
168
|
+
|
|
169
|
+
const SORT_OPTIONS = [
|
|
170
|
+
{ label: "Relevance", value: "relevance" },
|
|
171
|
+
{ label: "Newest first", value: "newest" },
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
const CATEGORY_OPTIONS = [
|
|
175
|
+
{ label: "Category A", value: "a" },
|
|
176
|
+
{ label: "Category B", value: "b" },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
// FIXME: replace with real seed data or remove once API is wired up
|
|
180
|
+
const MOCK_ITEMS: Item[] = [];
|
|
181
|
+
|
|
182
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function matchesKeyword(item: Item, keyword: string): boolean {
|
|
185
|
+
if (!keyword.trim()) return true;
|
|
186
|
+
const q = keyword.toLowerCase();
|
|
187
|
+
return (
|
|
188
|
+
item.title.toLowerCase().includes(q) ||
|
|
189
|
+
item.description.toLowerCase().includes(q)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchesFilters(
|
|
194
|
+
item: Item,
|
|
195
|
+
filters: Record<string, string[]>
|
|
196
|
+
): boolean {
|
|
197
|
+
const cats = filters.category;
|
|
198
|
+
if (!cats || cats.length === 0) return true;
|
|
199
|
+
return cats.includes(item.category);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sortItems(items: Item[], sortBy: string): Item[] {
|
|
203
|
+
if (sortBy === "newest") return [...items].reverse();
|
|
204
|
+
return items;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export default function ListingPage() {
|
|
210
|
+
const [allItems, setAllItems] = useState<Item[]>(MOCK_ITEMS);
|
|
211
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
212
|
+
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
213
|
+
|
|
214
|
+
const [state, setState] = useState<ListingState>({
|
|
215
|
+
keyword: "",
|
|
216
|
+
filters: {},
|
|
217
|
+
sortBy: "relevance",
|
|
218
|
+
page: 1,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
setIsLoading(true);
|
|
223
|
+
setFetchError(null);
|
|
224
|
+
// FIXME: replace with actual API client and URL to fetch data, e.g.:
|
|
225
|
+
// apiClient
|
|
226
|
+
// .get<Item[]>("/api/v1/items")
|
|
227
|
+
// .then(({ data }) => setAllItems(data))
|
|
228
|
+
// .catch((err) => setFetchError(err.message))
|
|
229
|
+
// .finally(() => setIsLoading(false));
|
|
230
|
+
setAllItems(MOCK_ITEMS);
|
|
231
|
+
setIsLoading(false);
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
const results = useMemo(() => {
|
|
235
|
+
let rows = allItems.filter(
|
|
236
|
+
(r) =>
|
|
237
|
+
matchesKeyword(r, state.keyword) &&
|
|
238
|
+
matchesFilters(r, state.filters)
|
|
239
|
+
);
|
|
240
|
+
return sortItems(rows, state.sortBy);
|
|
241
|
+
}, [allItems, state.keyword, state.filters, state.sortBy]);
|
|
242
|
+
|
|
243
|
+
const pageCount = Math.ceil(results.length / PAGE_SIZE);
|
|
244
|
+
const pageRows = results.slice(
|
|
245
|
+
(state.page - 1) * PAGE_SIZE,
|
|
246
|
+
state.page * PAGE_SIZE
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
function handleClear() {
|
|
250
|
+
setState((s) => ({ ...s, filters: {}, page: 1 }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<>
|
|
255
|
+
<Navbar masthead items={{ desktop: [] }} />
|
|
256
|
+
<main style={{ flex: 1 }}>
|
|
257
|
+
<div className={styles.pageWrapper}>
|
|
258
|
+
<Layout.Container type="grid">
|
|
259
|
+
<Layout.ColDiv xsCols={8} lgCols={12}>
|
|
260
|
+
{/* Search bar */}
|
|
261
|
+
<div className={styles.searchRow}>
|
|
262
|
+
<Form.Input
|
|
263
|
+
label="Search"
|
|
264
|
+
placeholder="Search by keyword"
|
|
265
|
+
value={state.keyword}
|
|
266
|
+
onChange={(e) =>
|
|
267
|
+
setState((s) => ({
|
|
268
|
+
...s,
|
|
269
|
+
keyword: e.target.value,
|
|
270
|
+
page: 1,
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
<Button
|
|
275
|
+
onClick={() =>
|
|
276
|
+
setState((s) => ({ ...s, page: 1 }))
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
Search
|
|
280
|
+
</Button>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* Loading */}
|
|
284
|
+
{isLoading && (
|
|
285
|
+
<Typography.BodyMD>
|
|
286
|
+
Loading...
|
|
287
|
+
</Typography.BodyMD>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Fetch error */}
|
|
291
|
+
{!isLoading && fetchError && (
|
|
292
|
+
<Alert
|
|
293
|
+
type="error"
|
|
294
|
+
actionLink={{
|
|
295
|
+
children: "Try again",
|
|
296
|
+
onClick: () => window.location.reload(),
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{fetchError}
|
|
300
|
+
</Alert>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Results */}
|
|
304
|
+
{!isLoading && !fetchError && (
|
|
305
|
+
<div className={styles.twoColumn}>
|
|
306
|
+
{/* Filter sidebar — omit for search-only variant */}
|
|
307
|
+
<div className={styles.filterSidebar}>
|
|
308
|
+
<Filter onClear={handleClear}>
|
|
309
|
+
<Filter.Item
|
|
310
|
+
title="Sort by"
|
|
311
|
+
collapsible={false}
|
|
312
|
+
>
|
|
313
|
+
{SORT_OPTIONS.map((opt) => (
|
|
314
|
+
<RadioButton
|
|
315
|
+
key={opt.value}
|
|
316
|
+
checked={
|
|
317
|
+
state.sortBy ===
|
|
318
|
+
opt.value
|
|
319
|
+
}
|
|
320
|
+
onChange={() =>
|
|
321
|
+
setState((s) => ({
|
|
322
|
+
...s,
|
|
323
|
+
sortBy: opt.value,
|
|
324
|
+
page: 1,
|
|
325
|
+
}))
|
|
326
|
+
}
|
|
327
|
+
>
|
|
328
|
+
{opt.label}
|
|
329
|
+
</RadioButton>
|
|
330
|
+
))}
|
|
331
|
+
</Filter.Item>
|
|
332
|
+
<Filter.Checkbox
|
|
333
|
+
title="Category"
|
|
334
|
+
options={CATEGORY_OPTIONS}
|
|
335
|
+
selected={
|
|
336
|
+
state.filters.category ?? []
|
|
337
|
+
}
|
|
338
|
+
onChange={(values) =>
|
|
339
|
+
setState((s) => ({
|
|
340
|
+
...s,
|
|
341
|
+
page: 1,
|
|
342
|
+
filters: {
|
|
343
|
+
...s.filters,
|
|
344
|
+
category: values,
|
|
345
|
+
},
|
|
346
|
+
}))
|
|
347
|
+
}
|
|
348
|
+
/>
|
|
349
|
+
</Filter>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div className={styles.resultsArea}>
|
|
353
|
+
{/* Result count */}
|
|
354
|
+
<div className={styles.resultCountRow}>
|
|
355
|
+
<Typography.BodyMD>
|
|
356
|
+
{results.length} result
|
|
357
|
+
{results.length !== 1
|
|
358
|
+
? "s"
|
|
359
|
+
: ""} found
|
|
360
|
+
</Typography.BodyMD>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{/* Empty state */}
|
|
364
|
+
{results.length === 0 && (
|
|
365
|
+
<ErrorDisplay
|
|
366
|
+
type="no-item-found"
|
|
367
|
+
title="No results found"
|
|
368
|
+
description="Try adjusting your search or filters."
|
|
369
|
+
actionButton={{
|
|
370
|
+
children: "Clear filters",
|
|
371
|
+
onClick: handleClear,
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* Card list */}
|
|
377
|
+
{pageRows.map((item) => (
|
|
378
|
+
<Card key={item.id}>
|
|
379
|
+
<div
|
|
380
|
+
className={styles.cardBody}
|
|
381
|
+
>
|
|
382
|
+
<Typography.HeadingSM>
|
|
383
|
+
{item.title}
|
|
384
|
+
</Typography.HeadingSM>
|
|
385
|
+
<Typography.BodyMD>
|
|
386
|
+
{item.description}
|
|
387
|
+
</Typography.BodyMD>
|
|
388
|
+
</div>
|
|
389
|
+
</Card>
|
|
390
|
+
))}
|
|
391
|
+
|
|
392
|
+
{/* Pagination */}
|
|
393
|
+
{pageCount > 1 && (
|
|
394
|
+
<div
|
|
395
|
+
className={
|
|
396
|
+
styles.paginationWrapper
|
|
397
|
+
}
|
|
398
|
+
>
|
|
399
|
+
<Pagination
|
|
400
|
+
totalItems={results.length}
|
|
401
|
+
pageSize={PAGE_SIZE}
|
|
402
|
+
activePage={state.page}
|
|
403
|
+
onPageChange={(page) =>
|
|
404
|
+
setState((s) => ({
|
|
405
|
+
...s,
|
|
406
|
+
page,
|
|
407
|
+
}))
|
|
408
|
+
}
|
|
409
|
+
showFirstAndLastNav
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
</Layout.ColDiv>
|
|
417
|
+
</Layout.Container>
|
|
418
|
+
</div>
|
|
419
|
+
</main>
|
|
420
|
+
<Footer />
|
|
421
|
+
</>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Anti-patterns
|
|
429
|
+
|
|
430
|
+
- Do not omit the result count
|
|
431
|
+
- Do not show an empty results area with no state message
|
|
432
|
+
- Do not use `DataTable` for citizen-facing records
|
|
433
|
+
- Do not use pagination AND lazy loading together
|
|
434
|
+
- Do not leave stale `page` values pointing past the end of a newly-filtered result set
|
|
435
|
+
- Do not split filtering and sorting into separate `useEffect` calls — derive from a single `useMemo`
|