@ethanhann/mantine-dataview 0.1.1 → 0.2.0
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/README.md +363 -242
- package/dist/core/colBuilder.d.ts +37 -0
- package/dist/core/useDataViewFetcher.d.ts +3 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +387 -285
- package/dist/index.js.map +1 -1
- package/dist/types/options.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,19 +7,17 @@
|
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
9
9
|
A reusable React library that renders **server-driven, paginated datasets** as either a
|
|
10
|
-
**table** or a **card grid**, switchable at runtime, with full feature parity between the two
|
|
11
|
-
built on [Mantine](https://mantine.dev) v9 and [TanStack Table](https://tanstack.com/table) v8.
|
|
10
|
+
**table** or a **card grid**, switchable at runtime, with full feature parity between the two.
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
> headless state. Sort, filter, search, selection, visibility and pagination all live in a single
|
|
15
|
-
> core, so parity between the views is guaranteed by construction, not maintained by hand.
|
|
12
|
+
Built on [Mantine](https://mantine.dev) v9 and [TanStack Table](https://tanstack.com/table) v8.
|
|
16
13
|
|
|
17
14
|
## Features
|
|
18
15
|
|
|
19
16
|
- One hook drives both a Mantine `Table` and a Mantine `Card` grid; switch at runtime.
|
|
20
17
|
- Server-side pagination, sorting (including multi-sort), column filters, and global search.
|
|
21
18
|
- Column data types (`text`, `number`, `currency`, `date`, `boolean`) with automatic Intl-based formatting.
|
|
22
|
-
- Seven filter variants with smart controls: `SegmentedControl` for booleans, `RangeSlider` for bounded numbers,
|
|
19
|
+
- Seven filter variants with smart controls: `SegmentedControl` for booleans, `RangeSlider` for bounded numbers,
|
|
20
|
+
`DatePickerInput` for dates.
|
|
23
21
|
- Custom filter components — bring your own UI per column.
|
|
24
22
|
- Column pinning (left/right) with sticky positioning.
|
|
25
23
|
- CSV export with optional formatted output.
|
|
@@ -49,7 +47,7 @@ tree in a provider:
|
|
|
49
47
|
```tsx
|
|
50
48
|
import "@mantine/core/styles.css";
|
|
51
49
|
import "@mantine/dates/styles.css";
|
|
52
|
-
import {
|
|
50
|
+
import {MantineProvider} from "@mantine/core";
|
|
53
51
|
|
|
54
52
|
<MantineProvider>{/* ... */}</MantineProvider>;
|
|
55
53
|
```
|
|
@@ -60,74 +58,115 @@ The easiest path is `useDataViewFetcher`, which owns the fetch lifecycle for you
|
|
|
60
58
|
|
|
61
59
|
```tsx
|
|
62
60
|
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
DataView,
|
|
62
|
+
useDataViewFetcher,
|
|
63
|
+
createColumnHelper,
|
|
64
|
+
type DataColumnDef,
|
|
67
65
|
} from "@ethanhann/mantine-dataview";
|
|
68
66
|
|
|
69
67
|
interface User {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
email: string;
|
|
71
|
+
status: "active" | "invited";
|
|
74
72
|
}
|
|
75
73
|
|
|
76
74
|
const col = createColumnHelper<User>();
|
|
77
75
|
const columns = [
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
76
|
+
col.accessor("name", {header: "Name", meta: {card: {role: "title"}}}),
|
|
77
|
+
col.accessor("email", {header: "Email", meta: {card: {role: "subtitle"}}}),
|
|
78
|
+
col.accessor("status", {
|
|
79
|
+
header: "Status",
|
|
80
|
+
meta: {
|
|
81
|
+
card: {role: "badge"},
|
|
82
|
+
filter: {
|
|
83
|
+
variant: "select",
|
|
84
|
+
options: [
|
|
85
|
+
{value: "active", label: "Active"},
|
|
86
|
+
{value: "invited", label: "Invited"},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
93
91
|
] satisfies DataColumnDef<User>[];
|
|
94
92
|
|
|
95
93
|
function Users() {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
const view = useDataViewFetcher<User>({
|
|
95
|
+
columns,
|
|
96
|
+
getRowId: (u) => u.id,
|
|
97
|
+
fetcher: async (request) => {
|
|
98
|
+
const res = await fetch(`/api/users?${toParams(request)}`);
|
|
99
|
+
const json = await res.json();
|
|
100
|
+
return {rows: json.items, rowCount: json.total};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return <DataView view={view}/>;
|
|
107
105
|
}
|
|
108
106
|
```
|
|
109
107
|
|
|
110
108
|
`<DataView view={view} />` renders the toolbar, the active presentation, and pagination.
|
|
111
109
|
|
|
110
|
+
## Column builder
|
|
111
|
+
|
|
112
|
+
The fluent `col<T>()` builder reduces column definition verbosity. Each method sets
|
|
113
|
+
`dataType`, `filter`, `align`, and `meta.label` with sensible defaults for the type:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import {col} from "@ethanhann/mantine-dataview";
|
|
117
|
+
|
|
118
|
+
const columns = col<User>()
|
|
119
|
+
.text("name", {card: "title"})
|
|
120
|
+
.text("email", {card: "subtitle", filter: false})
|
|
121
|
+
.currency("salary", {card: "meta"})
|
|
122
|
+
.number("age", {card: "meta", filter: {min: 18, max: 100}})
|
|
123
|
+
.boolean("active", {card: "badge"})
|
|
124
|
+
.date("createdAt", {card: "meta"})
|
|
125
|
+
.select("role", {
|
|
126
|
+
options: [{value: "admin", label: "Admin"}, {value: "user", label: "User"}],
|
|
127
|
+
card: "badge",
|
|
128
|
+
})
|
|
129
|
+
.build();
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Available presets
|
|
133
|
+
|
|
134
|
+
| Method | `dataType` | `filter` | `align` |
|
|
135
|
+
|------------------------------------|------------|---------------|---------|
|
|
136
|
+
| `.text(field)` | `text` | `text` | left |
|
|
137
|
+
| `.number(field)` | `number` | `numberRange` | right |
|
|
138
|
+
| `.currency(field)` | `currency` | `numberRange` | right |
|
|
139
|
+
| `.date(field)` | `date` | `dateRange` | left |
|
|
140
|
+
| `.boolean(field)` | `boolean` | `boolean` | left |
|
|
141
|
+
| `.select(field, { options })` | — | `select` | left |
|
|
142
|
+
| `.multiselect(field, { options })` | — | `multiselect` | left |
|
|
143
|
+
| `.custom(colDef)` | — | — | — |
|
|
144
|
+
|
|
145
|
+
Headers are auto-humanized from field names (`createdAt` → `"Created At"`,
|
|
146
|
+
`first_name` → `"First Name"`). Override with `{ header: "Custom Label" }`.
|
|
147
|
+
|
|
148
|
+
Options: `header`, `card` (role shorthand), `cardOrder`, `filter` (`false` to disable,
|
|
149
|
+
or object to merge), `format`, `align`, `cell`, `enableSorting`.
|
|
150
|
+
|
|
112
151
|
## Custom layout
|
|
113
152
|
|
|
114
153
|
Compose your own layout by passing children:
|
|
115
154
|
|
|
116
155
|
```tsx
|
|
117
156
|
<DataView view={view}>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
<DataView.Toolbar/>
|
|
158
|
+
<DataView.BulkActions/>
|
|
159
|
+
<DataView.Body/>
|
|
160
|
+
<DataView.Pagination/>
|
|
122
161
|
</DataView>
|
|
123
162
|
```
|
|
124
163
|
|
|
125
164
|
Or use the standalone components directly for full control:
|
|
126
165
|
|
|
127
166
|
```tsx
|
|
128
|
-
<DataToolbar view={view} showSearch showFilters
|
|
129
|
-
<DataTable view={view} striped highlightOnHover
|
|
130
|
-
<DataPagination view={view}
|
|
167
|
+
<DataToolbar view={view} showSearch showFilters/>
|
|
168
|
+
<DataTable view={view} striped highlightOnHover/>
|
|
169
|
+
<DataPagination view={view}/>
|
|
131
170
|
```
|
|
132
171
|
|
|
133
172
|
## Controlled (bring your own data layer)
|
|
@@ -136,29 +175,56 @@ Or use the standalone components directly for full control:
|
|
|
136
175
|
you supply `rows`/`rowCount`/`status` and respond to `onRequestChange`:
|
|
137
176
|
|
|
138
177
|
```tsx
|
|
139
|
-
const [resp, setResp] = useState({
|
|
178
|
+
const [resp, setResp] = useState({rows: [], rowCount: 0});
|
|
140
179
|
const [status, setStatus] = useState<Status>("idle");
|
|
141
180
|
|
|
142
181
|
const view = useDataView<User>({
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
182
|
+
columns,
|
|
183
|
+
getRowId: (u) => u.id,
|
|
184
|
+
rows: resp.rows,
|
|
185
|
+
rowCount: resp.rowCount,
|
|
186
|
+
status,
|
|
187
|
+
onRequestChange: async (request) => {
|
|
188
|
+
setStatus("loading");
|
|
189
|
+
try {
|
|
190
|
+
setResp(await myApi.list(request));
|
|
191
|
+
setStatus("success");
|
|
192
|
+
} catch {
|
|
193
|
+
setStatus("error");
|
|
194
|
+
}
|
|
195
|
+
},
|
|
157
196
|
});
|
|
158
197
|
```
|
|
159
198
|
|
|
160
199
|
The request is emitted immediately for pagination/sorting and debounced for search/filters.
|
|
161
200
|
|
|
201
|
+
### Refetching on external changes
|
|
202
|
+
|
|
203
|
+
When state outside the dataview (e.g. a tenant selector, date range from another component)
|
|
204
|
+
should trigger a refetch, pass it in `deps`:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
const view = useDataViewFetcher<User>({
|
|
208
|
+
columns,
|
|
209
|
+
getRowId,
|
|
210
|
+
fetcher,
|
|
211
|
+
deps: [selectedTenantId, externalDateRange],
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
When any value in `deps` changes, the current request is re-emitted to the fetcher.
|
|
216
|
+
|
|
217
|
+
### Manual refresh
|
|
218
|
+
|
|
219
|
+
Re-fetch the current data without changing any state:
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
<Button onClick={() => view.refetch()}>Refresh</Button>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This re-emits the current request to the fetcher. It's the same mechanism the built-in
|
|
226
|
+
error retry button uses.
|
|
227
|
+
|
|
162
228
|
## Column data types and formatting
|
|
163
229
|
|
|
164
230
|
Set `dataType` on a column's meta to enable automatic value formatting. When no explicit
|
|
@@ -168,23 +234,23 @@ sorting, and filtering — formatting is display-only.
|
|
|
168
234
|
|
|
169
235
|
```tsx
|
|
170
236
|
col.accessor("price", {
|
|
171
|
-
|
|
172
|
-
|
|
237
|
+
header: "Price",
|
|
238
|
+
meta: {dataType: "currency", align: "right"},
|
|
173
239
|
});
|
|
174
240
|
|
|
175
241
|
col.accessor("createdAt", {
|
|
176
|
-
|
|
177
|
-
|
|
242
|
+
header: "Created",
|
|
243
|
+
meta: {dataType: "date"},
|
|
178
244
|
});
|
|
179
245
|
```
|
|
180
246
|
|
|
181
|
-
| Data type | Default format
|
|
182
|
-
|
|
183
|
-
| `text` | `String(value)`
|
|
184
|
-
| `number` | `Intl.NumberFormat`
|
|
185
|
-
| `currency` | `Intl.NumberFormat` with currency | `$1,234.56`
|
|
186
|
-
| `date` | `Intl.DateTimeFormat`
|
|
187
|
-
| `boolean` | `"Yes"` / `"No"`
|
|
247
|
+
| Data type | Default format | Example |
|
|
248
|
+
|------------|-----------------------------------|---------------|
|
|
249
|
+
| `text` | `String(value)` | `"hello"` |
|
|
250
|
+
| `number` | `Intl.NumberFormat` | `1,234` |
|
|
251
|
+
| `currency` | `Intl.NumberFormat` with currency | `$1,234.56` |
|
|
252
|
+
| `date` | `Intl.DateTimeFormat` | `Jun 2, 2026` |
|
|
253
|
+
| `boolean` | `"Yes"` / `"No"` | `Yes` |
|
|
188
254
|
|
|
189
255
|
### Format overrides (three levels)
|
|
190
256
|
|
|
@@ -194,32 +260,32 @@ col.accessor("createdAt", {
|
|
|
194
260
|
|
|
195
261
|
```tsx
|
|
196
262
|
const view = useDataViewFetcher({
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
263
|
+
columns,
|
|
264
|
+
getRowId,
|
|
265
|
+
fetcher,
|
|
266
|
+
// All dates in this table use short format, currency is EUR
|
|
267
|
+
formatDefaults: {
|
|
268
|
+
date: {dateStyle: "short"},
|
|
269
|
+
currency: {currency: "EUR"},
|
|
270
|
+
},
|
|
205
271
|
});
|
|
206
272
|
|
|
207
273
|
// This column overrides the table default
|
|
208
274
|
col.accessor("createdAt", {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
275
|
+
header: "Created",
|
|
276
|
+
meta: {
|
|
277
|
+
dataType: "date",
|
|
278
|
+
format: {dateStyle: "long"},
|
|
279
|
+
},
|
|
214
280
|
});
|
|
215
281
|
|
|
216
282
|
// Or use a function for full control
|
|
217
283
|
col.accessor("revenue", {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
284
|
+
header: "Revenue",
|
|
285
|
+
meta: {
|
|
286
|
+
dataType: "currency",
|
|
287
|
+
format: (v) => `€${(v as number).toFixed(0)}`,
|
|
288
|
+
},
|
|
223
289
|
});
|
|
224
290
|
```
|
|
225
291
|
|
|
@@ -244,7 +310,7 @@ headers only.
|
|
|
244
310
|
Disable sorting on a specific column:
|
|
245
311
|
|
|
246
312
|
```tsx
|
|
247
|
-
col.accessor("avatar", {
|
|
313
|
+
col.accessor("avatar", {header: "Avatar", enableSorting: false});
|
|
248
314
|
```
|
|
249
315
|
|
|
250
316
|
## Custom headers
|
|
@@ -254,13 +320,13 @@ function to the `header` property:
|
|
|
254
320
|
|
|
255
321
|
```tsx
|
|
256
322
|
col.accessor("revenue", {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
323
|
+
header: () => (
|
|
324
|
+
<Group gap={4}>
|
|
325
|
+
<IconCurrencyDollar size={14}/>
|
|
326
|
+
<span>Revenue</span>
|
|
327
|
+
</Group>
|
|
328
|
+
),
|
|
329
|
+
meta: {align: "right"},
|
|
264
330
|
});
|
|
265
331
|
```
|
|
266
332
|
|
|
@@ -272,18 +338,18 @@ Export the current page's visible columns as a CSV file:
|
|
|
272
338
|
<Button onClick={() => view.exportCsv()}>Export CSV</Button>
|
|
273
339
|
|
|
274
340
|
// With options
|
|
275
|
-
view.exportCsv({
|
|
341
|
+
view.exportCsv({filename: "users.csv", separator: ";"});
|
|
276
342
|
|
|
277
343
|
// Export formatted values instead of raw data
|
|
278
|
-
view.exportCsv({
|
|
344
|
+
view.exportCsv({formatted: true});
|
|
279
345
|
```
|
|
280
346
|
|
|
281
347
|
The `exportCsv` function is also available as a standalone utility:
|
|
282
348
|
|
|
283
349
|
```tsx
|
|
284
|
-
import {
|
|
350
|
+
import {exportCsv} from "@ethanhann/mantine-dataview";
|
|
285
351
|
|
|
286
|
-
exportCsv(view.table, {
|
|
352
|
+
exportCsv(view.table, {filename: "report.csv"});
|
|
287
353
|
```
|
|
288
354
|
|
|
289
355
|
## Column pinning
|
|
@@ -294,12 +360,12 @@ Pin columns to the left or right edge so they stay visible while scrolling horiz
|
|
|
294
360
|
|
|
295
361
|
```tsx
|
|
296
362
|
const view = useDataViewFetcher<User>({
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
363
|
+
columns,
|
|
364
|
+
getRowId,
|
|
365
|
+
fetcher,
|
|
366
|
+
initialState: {
|
|
367
|
+
columnPinning: {left: ["name"], right: ["actions"]},
|
|
368
|
+
},
|
|
303
369
|
});
|
|
304
370
|
```
|
|
305
371
|
|
|
@@ -322,28 +388,50 @@ view.table.getColumn("name")?.pin(false); // unpin
|
|
|
322
388
|
|
|
323
389
|
Define filters declaratively on column meta. Seven variants are built in:
|
|
324
390
|
|
|
325
|
-
| Variant
|
|
326
|
-
|
|
327
|
-
| `text`
|
|
328
|
-
| `select`
|
|
329
|
-
| `multiselect` | `MultiSelect`
|
|
330
|
-
| `boolean`
|
|
391
|
+
| Variant | Control | Notes |
|
|
392
|
+
|---------------|-------------------------------------|---------------------------------|
|
|
393
|
+
| `text` | `TextInput` | Free-text search |
|
|
394
|
+
| `select` | `Select` (dropdown) | Single choice, clearable |
|
|
395
|
+
| `multiselect` | `MultiSelect` | Multiple choices |
|
|
396
|
+
| `boolean` | `SegmentedControl` (All/Yes/No) | One-click toggle |
|
|
331
397
|
| `numberRange` | `RangeSlider` or two `NumberInput`s | Slider when `min`/`max` are set |
|
|
332
|
-
| `date`
|
|
333
|
-
| `dateRange`
|
|
398
|
+
| `date` | `DatePickerInput` | Calendar picker |
|
|
399
|
+
| `dateRange` | `DatePickerInput` (range) | Two-date calendar picker |
|
|
334
400
|
|
|
335
401
|
```tsx
|
|
336
402
|
// Boolean — renders as a segmented control
|
|
337
|
-
meta: {
|
|
403
|
+
meta: {
|
|
404
|
+
filter: {
|
|
405
|
+
variant: "boolean"
|
|
406
|
+
}
|
|
407
|
+
}
|
|
338
408
|
|
|
339
409
|
// Number range with slider
|
|
340
|
-
meta: {
|
|
410
|
+
meta: {
|
|
411
|
+
filter: {
|
|
412
|
+
variant: "numberRange", min
|
|
413
|
+
:
|
|
414
|
+
0, max
|
|
415
|
+
:
|
|
416
|
+
1000, step
|
|
417
|
+
:
|
|
418
|
+
10
|
|
419
|
+
}
|
|
420
|
+
}
|
|
341
421
|
|
|
342
422
|
// Number range without bounds (falls back to two number inputs)
|
|
343
|
-
meta: {
|
|
423
|
+
meta: {
|
|
424
|
+
filter: {
|
|
425
|
+
variant: "numberRange"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
344
428
|
|
|
345
429
|
// Date range
|
|
346
|
-
meta: {
|
|
430
|
+
meta: {
|
|
431
|
+
filter: {
|
|
432
|
+
variant: "dateRange"
|
|
433
|
+
}
|
|
434
|
+
}
|
|
347
435
|
```
|
|
348
436
|
|
|
349
437
|
### Custom filter component
|
|
@@ -351,22 +439,22 @@ meta: { filter: { variant: "dateRange" } }
|
|
|
351
439
|
For filters that don't fit the built-in variants, provide a `component` instead:
|
|
352
440
|
|
|
353
441
|
```tsx
|
|
354
|
-
import type {
|
|
355
|
-
|
|
356
|
-
function LocationFilter({
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
442
|
+
import type {CustomFilterComponentProps} from "@ethanhann/mantine-dataview";
|
|
443
|
+
|
|
444
|
+
function LocationFilter({value, onChange}: CustomFilterComponentProps) {
|
|
445
|
+
return (
|
|
446
|
+
<Chip.Group value={(value as string) ?? ""} onChange={(v) => onChange(v || undefined)}>
|
|
447
|
+
<Group gap={4}>
|
|
448
|
+
<Chip value="london" size="xs">London</Chip>
|
|
449
|
+
<Chip value="berlin" size="xs">Berlin</Chip>
|
|
450
|
+
</Group>
|
|
451
|
+
</Chip.Group>
|
|
452
|
+
);
|
|
365
453
|
}
|
|
366
454
|
|
|
367
455
|
col.accessor("location", {
|
|
368
|
-
|
|
369
|
-
|
|
456
|
+
header: "Location",
|
|
457
|
+
meta: {filter: {component: LocationFilter}},
|
|
370
458
|
});
|
|
371
459
|
```
|
|
372
460
|
|
|
@@ -375,18 +463,30 @@ col.accessor("location", {
|
|
|
375
463
|
`FilterControl` is exported so you can place individual filters anywhere in your layout:
|
|
376
464
|
|
|
377
465
|
```tsx
|
|
378
|
-
import {
|
|
466
|
+
import {FilterControl} from "@ethanhann/mantine-dataview";
|
|
379
467
|
|
|
380
468
|
<DataView view={view}>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
469
|
+
{view.table.getColumn("inStock") && (
|
|
470
|
+
<FilterControl column={view.table.getColumn("inStock")!}/>
|
|
471
|
+
)}
|
|
472
|
+
<DataView.Toolbar/>
|
|
473
|
+
<DataView.Body/>
|
|
474
|
+
<DataView.Pagination/>
|
|
387
475
|
</DataView>
|
|
388
476
|
```
|
|
389
477
|
|
|
478
|
+
### Programmatic filter control
|
|
479
|
+
|
|
480
|
+
Reset all filters or clear a specific column from anywhere — no need to be inside the toolbar:
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
// Reset all filters
|
|
484
|
+
<Button onClick={() => view.resetAllFilters()}>Reset all filters</Button>
|
|
485
|
+
|
|
486
|
+
// Clear a single column's filter
|
|
487
|
+
<Button onClick={() => view.resetFilter("status")}>Clear status filter</Button>
|
|
488
|
+
```
|
|
489
|
+
|
|
390
490
|
### Filter display behavior
|
|
391
491
|
|
|
392
492
|
- **Desktop, few filters** (at or below `filterInlineThreshold`, default 3): rendered inline in the toolbar.
|
|
@@ -399,7 +499,7 @@ import { FilterControl } from "@ethanhann/mantine-dataview";
|
|
|
399
499
|
In card view, each visible column is placed by its `meta.card.role`:
|
|
400
500
|
|
|
401
501
|
| role | rendered as |
|
|
402
|
-
|
|
502
|
+
|------------|-----------------------------|
|
|
403
503
|
| `title` | card heading |
|
|
404
504
|
| `subtitle` | dimmed line under the title |
|
|
405
505
|
| `media` | full-bleed top section |
|
|
@@ -416,14 +516,14 @@ For full control over card content, use `renderCard`:
|
|
|
416
516
|
|
|
417
517
|
```tsx
|
|
418
518
|
<DataView
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
519
|
+
view={view}
|
|
520
|
+
renderCard={({data, selected, toggleSelected}) => (
|
|
521
|
+
<Card withBorder padding="md" onClick={toggleSelected}>
|
|
522
|
+
<Text fw={700}>{data.name}</Text>
|
|
523
|
+
<Text size="sm" c="dimmed">{data.email}</Text>
|
|
524
|
+
{selected && <Badge>Selected</Badge>}
|
|
525
|
+
</Card>
|
|
526
|
+
)}
|
|
427
527
|
/>
|
|
428
528
|
```
|
|
429
529
|
|
|
@@ -431,18 +531,18 @@ To keep the default composition but wrap it in a custom card shell, use the `Car
|
|
|
431
531
|
|
|
432
532
|
```tsx
|
|
433
533
|
<DataView
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
534
|
+
view={view}
|
|
535
|
+
slots={{
|
|
536
|
+
Card: ({data, selected, children}) => (
|
|
537
|
+
<Card
|
|
538
|
+
withBorder
|
|
539
|
+
padding="lg"
|
|
540
|
+
style={{background: selected ? "var(--mantine-color-blue-light)" : undefined}}
|
|
541
|
+
>
|
|
542
|
+
{children}
|
|
543
|
+
</Card>
|
|
544
|
+
),
|
|
545
|
+
}}
|
|
446
546
|
/>
|
|
447
547
|
```
|
|
448
548
|
|
|
@@ -452,21 +552,21 @@ Provide a `BulkActions` slot to add actions when rows are selected:
|
|
|
452
552
|
|
|
453
553
|
```tsx
|
|
454
554
|
<DataView
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
555
|
+
view={view}
|
|
556
|
+
slots={{
|
|
557
|
+
BulkActions: (selection) => (
|
|
558
|
+
<Button
|
|
559
|
+
color="red"
|
|
560
|
+
variant="light"
|
|
561
|
+
onClick={() => {
|
|
562
|
+
deleteUsers(selection.ids);
|
|
563
|
+
selection.clear();
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
Delete {selection.count}
|
|
567
|
+
</Button>
|
|
568
|
+
),
|
|
569
|
+
}}
|
|
470
570
|
/>
|
|
471
571
|
```
|
|
472
572
|
|
|
@@ -479,18 +579,18 @@ Override loading, empty, and error states:
|
|
|
479
579
|
|
|
480
580
|
```tsx
|
|
481
581
|
<DataView
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
582
|
+
view={view}
|
|
583
|
+
slots={{
|
|
584
|
+
Empty: () => <Text>No users found.</Text>,
|
|
585
|
+
ErrorState: ({retry}) => (
|
|
586
|
+
<Stack align="center">
|
|
587
|
+
<Text c="red">Something went wrong.</Text>
|
|
588
|
+
<Button onClick={retry}>Retry</Button>
|
|
589
|
+
</Stack>
|
|
590
|
+
),
|
|
591
|
+
LoadingTable: () => <MySkeleton/>,
|
|
592
|
+
LoadingCards: () => <MyCardSkeleton/>,
|
|
593
|
+
}}
|
|
494
594
|
/>
|
|
495
595
|
```
|
|
496
596
|
|
|
@@ -502,14 +602,14 @@ users can reset without manually removing each filter.
|
|
|
502
602
|
Router-agnostic. The default adapter uses the History API; memoize it once:
|
|
503
603
|
|
|
504
604
|
```tsx
|
|
505
|
-
import {
|
|
605
|
+
import {windowHistoryAdapter} from "@ethanhann/mantine-dataview/url";
|
|
506
606
|
|
|
507
607
|
const adapter = useMemo(() => windowHistoryAdapter(), []);
|
|
508
608
|
const view = useDataViewFetcher<User>({
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
609
|
+
columns,
|
|
610
|
+
getRowId,
|
|
611
|
+
fetcher,
|
|
612
|
+
urlSync: {adapter},
|
|
513
613
|
});
|
|
514
614
|
```
|
|
515
615
|
|
|
@@ -523,12 +623,14 @@ To integrate with a router, implement these three methods:
|
|
|
523
623
|
|
|
524
624
|
```ts
|
|
525
625
|
interface UrlStateAdapter {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
626
|
+
/** Current query params as a flat record. */
|
|
627
|
+
read(): Record<string, string>;
|
|
628
|
+
|
|
629
|
+
/** Write the next params; `replace` controls history entry vs push. */
|
|
630
|
+
write(next: Record<string, string>, opts?: { replace?: boolean }): void;
|
|
631
|
+
|
|
632
|
+
/** Optional: notify on external nav (back/forward). Returns an unsubscribe fn. */
|
|
633
|
+
subscribe?(onChange: () => void): () => void;
|
|
532
634
|
}
|
|
533
635
|
```
|
|
534
636
|
|
|
@@ -537,36 +639,36 @@ interface UrlStateAdapter {
|
|
|
537
639
|
### React Router
|
|
538
640
|
|
|
539
641
|
```tsx
|
|
540
|
-
import {
|
|
541
|
-
import type {
|
|
642
|
+
import {useSearchParams} from "react-router-dom";
|
|
643
|
+
import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
|
|
542
644
|
|
|
543
645
|
function useReactRouterAdapter(): UrlStateAdapter {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
646
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
647
|
+
return useMemo<UrlStateAdapter>(
|
|
648
|
+
() => ({
|
|
649
|
+
read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
|
|
650
|
+
write: (next, opts) => setSearchParams(next, {replace: opts?.replace}),
|
|
651
|
+
}),
|
|
652
|
+
[searchParams, setSearchParams],
|
|
653
|
+
);
|
|
552
654
|
}
|
|
553
655
|
```
|
|
554
656
|
|
|
555
657
|
### TanStack Router
|
|
556
658
|
|
|
557
659
|
```tsx
|
|
558
|
-
import {
|
|
559
|
-
import type {
|
|
660
|
+
import {useNavigate} from "@tanstack/react-router";
|
|
661
|
+
import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
|
|
560
662
|
|
|
561
663
|
function useTanStackRouterAdapter(): UrlStateAdapter {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
664
|
+
const navigate = useNavigate();
|
|
665
|
+
return useMemo<UrlStateAdapter>(
|
|
666
|
+
() => ({
|
|
667
|
+
read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
|
|
668
|
+
write: (next, opts) => navigate({search: () => next, replace: opts?.replace}),
|
|
669
|
+
}),
|
|
670
|
+
[navigate],
|
|
671
|
+
);
|
|
570
672
|
}
|
|
571
673
|
```
|
|
572
674
|
|
|
@@ -582,16 +684,17 @@ Force the card view below a breakpoint:
|
|
|
582
684
|
|
|
583
685
|
```tsx
|
|
584
686
|
const view = useDataViewFetcher<User>({
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
687
|
+
columns,
|
|
688
|
+
getRowId,
|
|
689
|
+
fetcher,
|
|
690
|
+
responsive: {forceCardsBelow: "sm", lockSwitcherOnMobile: true},
|
|
589
691
|
});
|
|
590
692
|
|
|
591
|
-
<DataView view={view} lockSwitcherOnMobile
|
|
693
|
+
<DataView view={view} lockSwitcherOnMobile/>;
|
|
592
694
|
```
|
|
593
695
|
|
|
594
696
|
When `forceCardsBelow` is set and the viewport is below that breakpoint:
|
|
697
|
+
|
|
595
698
|
- The view is forced to cards regardless of the user's choice.
|
|
596
699
|
- The user's explicit choice is preserved and restored above the breakpoint.
|
|
597
700
|
- The view switcher is disabled (or hidden entirely with `lockSwitcherOnMobile`).
|
|
@@ -599,15 +702,15 @@ When `forceCardsBelow` is set and the viewport is below that breakpoint:
|
|
|
599
702
|
|
|
600
703
|
## API overview
|
|
601
704
|
|
|
602
|
-
| Export
|
|
603
|
-
|
|
604
|
-
| `useDataView`
|
|
605
|
-
| `useDataViewFetcher`
|
|
705
|
+
| Export | Purpose |
|
|
706
|
+
|----------------------------------------------------------------------|-----------------------------------------------|
|
|
707
|
+
| `useDataView` | Headless core — owns all feature state |
|
|
708
|
+
| `useDataViewFetcher` | Convenience wrapper that manages the fetch |
|
|
606
709
|
| `DataView` (+ `.Toolbar` / `.BulkActions` / `.Body` / `.Pagination`) | Orchestrator + compound parts |
|
|
607
|
-
| `DataTable`, `DataCards`
|
|
710
|
+
| `DataTable`, `DataCards` | The two presentations (usable standalone) |
|
|
608
711
|
| `DataToolbar`, `DataPagination`, `DataBulkActions` | Standalone affordances |
|
|
609
|
-
| `FilterControl`
|
|
610
|
-
| `exportCsv`
|
|
712
|
+
| `FilterControl` | Individual filter control (place anywhere) |
|
|
713
|
+
| `exportCsv` | Standalone CSV export utility |
|
|
611
714
|
| `createColumnHelper`, `composeCardLayout`, `resolveColumnLabel` | Column helpers |
|
|
612
715
|
| `@ethanhann/mantine-dataview/url` | `windowHistoryAdapter` + serializer utilities |
|
|
613
716
|
|
|
@@ -615,15 +718,33 @@ When `forceCardsBelow` is set and the viewport is below that breakpoint:
|
|
|
615
718
|
|
|
616
719
|
Passed via the `slots` prop on `DataView` or the presentation components:
|
|
617
720
|
|
|
618
|
-
| Slot | Receives
|
|
619
|
-
|
|
620
|
-
| `Empty` | `{ view }`
|
|
621
|
-
| `ErrorState` | `{ retry }`
|
|
622
|
-
| `LoadingTable` | —
|
|
623
|
-
| `LoadingCards`
|
|
624
|
-
| `Row` | `{ row, children }`
|
|
625
|
-
| `Card` | `{ row, data, selected, children }`
|
|
626
|
-
| `BulkActions` | `{ count, ids, rows, clear }`
|
|
721
|
+
| Slot | Receives | Purpose |
|
|
722
|
+
|----------------|-------------------------------------|----------------------------|
|
|
723
|
+
| `Empty` | `{ view }` | No data state |
|
|
724
|
+
| `ErrorState` | `{ retry }` | Error with retry action |
|
|
725
|
+
| `LoadingTable` | — | Table skeleton replacement |
|
|
726
|
+
| `LoadingCards` | — | Card skeleton replacement |
|
|
727
|
+
| `Row` | `{ row, children }` | Wrap each table row |
|
|
728
|
+
| `Card` | `{ row, data, selected, children }` | Wrap each card |
|
|
729
|
+
| `BulkActions` | `{ count, ids, rows, clear }` | Bulk action bar content |
|
|
730
|
+
|
|
731
|
+
## Known issues
|
|
732
|
+
|
|
733
|
+
### `DataView` name shadows the JS global
|
|
734
|
+
|
|
735
|
+
The `DataView` component shares its name with the JavaScript `DataView` global (typed arrays).
|
|
736
|
+
Linters like Biome's `noShadowRestrictedNames` will flag the import. Suppress it with:
|
|
737
|
+
|
|
738
|
+
```tsx
|
|
739
|
+
// biome-ignore lint/suspicious/noShadowRestrictedNames: component name
|
|
740
|
+
import {DataView} from "@ethanhann/mantine-dataview";
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
Or import with an alias:
|
|
744
|
+
|
|
745
|
+
```tsx
|
|
746
|
+
import {DataView as MantineDataView} from "@ethanhann/mantine-dataview";
|
|
747
|
+
```
|
|
627
748
|
|
|
628
749
|
## Development
|
|
629
750
|
|