@ethanhann/mantine-dataview 0.1.2 → 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 CHANGED
@@ -7,19 +7,17 @@
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./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
- > A table and a card grid are not two components — they are two _projections_ of one shared,
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, `DatePickerInput` for dates.
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 { MantineProvider } from "@mantine/core";
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
- DataView,
64
- useDataViewFetcher,
65
- createColumnHelper,
66
- type DataColumnDef,
61
+ DataView,
62
+ useDataViewFetcher,
63
+ createColumnHelper,
64
+ type DataColumnDef,
67
65
  } from "@ethanhann/mantine-dataview";
68
66
 
69
67
  interface User {
70
- id: string;
71
- name: string;
72
- email: string;
73
- status: "active" | "invited";
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
- col.accessor("name", { header: "Name", meta: { card: { role: "title" } } }),
79
- col.accessor("email", { header: "Email", meta: { card: { role: "subtitle" } } }),
80
- col.accessor("status", {
81
- header: "Status",
82
- meta: {
83
- card: { role: "badge" },
84
- filter: {
85
- variant: "select",
86
- options: [
87
- { value: "active", label: "Active" },
88
- { value: "invited", label: "Invited" },
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
- const view = useDataViewFetcher<User>({
97
- columns,
98
- getRowId: (u) => u.id,
99
- fetcher: async (request) => {
100
- const res = await fetch(`/api/users?${toParams(request)}`);
101
- const json = await res.json();
102
- return { rows: json.items, rowCount: json.total };
103
- },
104
- });
105
-
106
- return <DataView view={view} />;
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
- <DataView.Toolbar />
119
- <DataView.BulkActions />
120
- <DataView.Body />
121
- <DataView.Pagination />
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({ rows: [], rowCount: 0 });
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
- columns,
144
- getRowId: (u) => u.id,
145
- rows: resp.rows,
146
- rowCount: resp.rowCount,
147
- status,
148
- onRequestChange: async (request) => {
149
- setStatus("loading");
150
- try {
151
- setResp(await myApi.list(request));
152
- setStatus("success");
153
- } catch {
154
- setStatus("error");
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
- header: "Price",
172
- meta: { dataType: "currency", align: "right" },
237
+ header: "Price",
238
+ meta: {dataType: "currency", align: "right"},
173
239
  });
174
240
 
175
241
  col.accessor("createdAt", {
176
- header: "Created",
177
- meta: { dataType: "date" },
242
+ header: "Created",
243
+ meta: {dataType: "date"},
178
244
  });
179
245
  ```
180
246
 
181
- | Data type | Default format | Example |
182
- |------------|---------------|---------|
183
- | `text` | `String(value)` | `"hello"` |
184
- | `number` | `Intl.NumberFormat` | `1,234` |
185
- | `currency` | `Intl.NumberFormat` with currency | `$1,234.56` |
186
- | `date` | `Intl.DateTimeFormat` | `Jun 2, 2026` |
187
- | `boolean` | `"Yes"` / `"No"` | `Yes` |
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
- columns,
198
- getRowId,
199
- fetcher,
200
- // All dates in this table use short format, currency is EUR
201
- formatDefaults: {
202
- date: { dateStyle: "short" },
203
- currency: { currency: "EUR" },
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
- header: "Created",
210
- meta: {
211
- dataType: "date",
212
- format: { dateStyle: "long" },
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
- header: "Revenue",
219
- meta: {
220
- dataType: "currency",
221
- format: (v) => `€${(v as number).toFixed(0)}`,
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", { header: "Avatar", enableSorting: false });
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
- header: () => (
258
- <Group gap={4}>
259
- <IconCurrencyDollar size={14} />
260
- <span>Revenue</span>
261
- </Group>
262
- ),
263
- meta: { align: "right" },
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({ filename: "users.csv", separator: ";" });
341
+ view.exportCsv({filename: "users.csv", separator: ";"});
276
342
 
277
343
  // Export formatted values instead of raw data
278
- view.exportCsv({ formatted: true });
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 { exportCsv } from "@ethanhann/mantine-dataview";
350
+ import {exportCsv} from "@ethanhann/mantine-dataview";
285
351
 
286
- exportCsv(view.table, { filename: "report.csv" });
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
- columns,
298
- getRowId,
299
- fetcher,
300
- initialState: {
301
- columnPinning: { left: ["name"], right: ["actions"] },
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 | Control | Notes |
326
- |---------|---------|-------|
327
- | `text` | `TextInput` | Free-text search |
328
- | `select` | `Select` (dropdown) | Single choice, clearable |
329
- | `multiselect` | `MultiSelect` | Multiple choices |
330
- | `boolean` | `SegmentedControl` (All/Yes/No) | One-click toggle |
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` | `DatePickerInput` | Calendar picker |
333
- | `dateRange` | `DatePickerInput` (range) | Two-date calendar picker |
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: { filter: { variant: "boolean" } }
403
+ meta: {
404
+ filter: {
405
+ variant: "boolean"
406
+ }
407
+ }
338
408
 
339
409
  // Number range with slider
340
- meta: { filter: { variant: "numberRange", min: 0, max: 1000, step: 10 } }
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: { filter: { variant: "numberRange" } }
423
+ meta: {
424
+ filter: {
425
+ variant: "numberRange"
426
+ }
427
+ }
344
428
 
345
429
  // Date range
346
- meta: { filter: { variant: "dateRange" } }
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 { CustomFilterComponentProps } from "@ethanhann/mantine-dataview";
355
-
356
- function LocationFilter({ value, onChange }: CustomFilterComponentProps) {
357
- return (
358
- <Chip.Group value={(value as string) ?? ""} onChange={(v) => onChange(v || undefined)}>
359
- <Group gap={4}>
360
- <Chip value="london" size="xs">London</Chip>
361
- <Chip value="berlin" size="xs">Berlin</Chip>
362
- </Group>
363
- </Chip.Group>
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
- header: "Location",
369
- meta: { filter: { component: LocationFilter } },
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 { FilterControl } from "@ethanhann/mantine-dataview";
466
+ import {FilterControl} from "@ethanhann/mantine-dataview";
379
467
 
380
468
  <DataView view={view}>
381
- {view.table.getColumn("inStock") && (
382
- <FilterControl column={view.table.getColumn("inStock")!} />
383
- )}
384
- <DataView.Toolbar />
385
- <DataView.Body />
386
- <DataView.Pagination />
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
- view={view}
420
- renderCard={({ data, selected, toggleSelected }) => (
421
- <Card withBorder padding="md" onClick={toggleSelected}>
422
- <Text fw={700}>{data.name}</Text>
423
- <Text size="sm" c="dimmed">{data.email}</Text>
424
- {selected && <Badge>Selected</Badge>}
425
- </Card>
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
- view={view}
435
- slots={{
436
- Card: ({ data, selected, children }) => (
437
- <Card
438
- withBorder
439
- padding="lg"
440
- style={{ background: selected ? "var(--mantine-color-blue-light)" : undefined }}
441
- >
442
- {children}
443
- </Card>
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
- view={view}
456
- slots={{
457
- BulkActions: (selection) => (
458
- <Button
459
- color="red"
460
- variant="light"
461
- onClick={() => {
462
- deleteUsers(selection.ids);
463
- selection.clear();
464
- }}
465
- >
466
- Delete {selection.count}
467
- </Button>
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
- view={view}
483
- slots={{
484
- Empty: () => <Text>No users found.</Text>,
485
- ErrorState: ({ retry }) => (
486
- <Stack align="center">
487
- <Text c="red">Something went wrong.</Text>
488
- <Button onClick={retry}>Retry</Button>
489
- </Stack>
490
- ),
491
- LoadingTable: () => <MySkeleton />,
492
- LoadingCards: () => <MyCardSkeleton />,
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 { windowHistoryAdapter } from "@ethanhann/mantine-dataview/url";
605
+ import {windowHistoryAdapter} from "@ethanhann/mantine-dataview/url";
506
606
 
507
607
  const adapter = useMemo(() => windowHistoryAdapter(), []);
508
608
  const view = useDataViewFetcher<User>({
509
- columns,
510
- getRowId,
511
- fetcher,
512
- urlSync: { adapter },
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
- /** Current query params as a flat record. */
527
- read(): Record<string, string>;
528
- /** Write the next params; `replace` controls history entry vs push. */
529
- write(next: Record<string, string>, opts?: { replace?: boolean }): void;
530
- /** Optional: notify on external nav (back/forward). Returns an unsubscribe fn. */
531
- subscribe?(onChange: () => void): () => void;
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 { useSearchParams } from "react-router-dom";
541
- import type { UrlStateAdapter } from "@ethanhann/mantine-dataview/url";
642
+ import {useSearchParams} from "react-router-dom";
643
+ import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
542
644
 
543
645
  function useReactRouterAdapter(): UrlStateAdapter {
544
- const [searchParams, setSearchParams] = useSearchParams();
545
- return useMemo<UrlStateAdapter>(
546
- () => ({
547
- read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
548
- write: (next, opts) => setSearchParams(next, { replace: opts?.replace }),
549
- }),
550
- [searchParams, setSearchParams],
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 { useNavigate } from "@tanstack/react-router";
559
- import type { UrlStateAdapter } from "@ethanhann/mantine-dataview/url";
660
+ import {useNavigate} from "@tanstack/react-router";
661
+ import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
560
662
 
561
663
  function useTanStackRouterAdapter(): UrlStateAdapter {
562
- const navigate = useNavigate();
563
- return useMemo<UrlStateAdapter>(
564
- () => ({
565
- read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
566
- write: (next, opts) => navigate({ search: () => next, replace: opts?.replace }),
567
- }),
568
- [navigate],
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
- columns,
586
- getRowId,
587
- fetcher,
588
- responsive: { forceCardsBelow: "sm", lockSwitcherOnMobile: true },
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 | Purpose |
603
- | --------------------------------------------------------------------- | --------------------------------------------- |
604
- | `useDataView` | Headless core — owns all feature state |
605
- | `useDataViewFetcher` | Convenience wrapper that manages the fetch |
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` | The two presentations (usable standalone) |
710
+ | `DataTable`, `DataCards` | The two presentations (usable standalone) |
608
711
  | `DataToolbar`, `DataPagination`, `DataBulkActions` | Standalone affordances |
609
- | `FilterControl` | Individual filter control (place anywhere) |
610
- | `exportCsv` | Standalone CSV export utility |
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 | Purpose |
619
- | -------------- | ------------------------------------------ | --------------------------------- |
620
- | `Empty` | `{ view }` | No data state |
621
- | `ErrorState` | `{ retry }` | Error with retry action |
622
- | `LoadingTable` | — | Table skeleton replacement |
623
- | `LoadingCards` | — | Card skeleton replacement |
624
- | `Row` | `{ row, children }` | Wrap each table row |
625
- | `Card` | `{ row, data, selected, children }` | Wrap each card |
626
- | `BulkActions` | `{ count, ids, rows, clear }` | Bulk action bar content |
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