@ethanhann/mantine-dataview 0.1.2 → 0.3.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,26 +7,27 @@
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.
23
- - Custom filter components — bring your own UI per column.
19
+ - Seven filter variants with smart controls: `SegmentedControl` for booleans, `RangeSlider` for bounded numbers,
20
+ `DatePickerInput` for dates.
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.
26
24
  - Router-agnostic URL state sync with a default History-API adapter.
27
25
  - Cross-page row selection + a shared bulk-action bar.
28
26
  - Column-meta card composition (`title`/`subtitle`/`media`/`badge`/`meta`) + a `renderCard` escape hatch.
29
27
  - Responsive: force-to-cards below a breakpoint, filters collapse to a bottom drawer on mobile.
28
+ - Faceted search with server-provided counts on filter options and range buckets with dynamic totals.
29
+ - External parameters (`params`) for scope selectors, toggles, and other non-column filters.
30
+ - Controls automatically disabled while data is loading (opt-out with `disableWhileLoading`).
30
31
  - Loading / empty / filtered-empty / error states, consistent across both views.
31
32
  - Dark mode support via Mantine's color scheme system.
32
33
  - Strongly typed end to end; ships its own `.d.ts`. No icon dependency.
@@ -49,7 +50,7 @@ tree in a provider:
49
50
  ```tsx
50
51
  import "@mantine/core/styles.css";
51
52
  import "@mantine/dates/styles.css";
52
- import { MantineProvider } from "@mantine/core";
53
+ import {MantineProvider} from "@mantine/core";
53
54
 
54
55
  <MantineProvider>{/* ... */}</MantineProvider>;
55
56
  ```
@@ -60,166 +61,307 @@ The easiest path is `useDataViewFetcher`, which owns the fetch lifecycle for you
60
61
 
61
62
  ```tsx
62
63
  import {
63
- DataView,
64
- useDataViewFetcher,
65
- createColumnHelper,
66
- type DataColumnDef,
64
+ DataView,
65
+ useDataViewFetcher,
66
+ createColumnHelper,
67
+ type DataColumnDef,
67
68
  } from "@ethanhann/mantine-dataview";
68
69
 
69
70
  interface User {
70
- id: string;
71
- name: string;
72
- email: string;
73
- status: "active" | "invited";
71
+ id: string;
72
+ name: string;
73
+ email: string;
74
+ status: "active" | "invited";
74
75
  }
75
76
 
76
77
  const col = createColumnHelper<User>();
77
78
  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
- }),
79
+ col.accessor("name", {header: "Name", meta: {card: {role: "title"}}}),
80
+ col.accessor("email", {header: "Email", meta: {card: {role: "subtitle"}}}),
81
+ col.accessor("status", {
82
+ header: "Status",
83
+ meta: {
84
+ card: {role: "badge"},
85
+ filter: {
86
+ variant: "select",
87
+ options: [
88
+ {value: "active", label: "Active"},
89
+ {value: "invited", label: "Invited"},
90
+ ],
91
+ },
92
+ },
93
+ }),
93
94
  ] satisfies DataColumnDef<User>[];
94
95
 
95
96
  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} />;
97
+ const view = useDataViewFetcher<User>({
98
+ columns,
99
+ getRowId: (u) => u.id,
100
+ fetcher: async (request) => {
101
+ const res = await fetch(`/api/users?${toParams(request)}`);
102
+ const json = await res.json();
103
+ return {rows: json.items, rowCount: json.total};
104
+ },
105
+ });
106
+
107
+ return <DataView view={view}/>;
107
108
  }
108
109
  ```
109
110
 
110
111
  `<DataView view={view} />` renders the toolbar, the active presentation, and pagination.
111
112
 
113
+ ## Column builder
114
+
115
+ The fluent `col<T>()` builder reduces column definition verbosity. Each method sets
116
+ `dataType`, `filter`, `align`, and `meta.label` with sensible defaults for the type:
117
+
118
+ ```tsx
119
+ import {col} from "@ethanhann/mantine-dataview";
120
+
121
+ const columns = col<User>()
122
+ .text("name", {card: "title"})
123
+ .text("email", {card: "subtitle", filter: false})
124
+ .currency("salary", {card: "meta"})
125
+ .number("age", {card: "meta", filter: {min: 18, max: 100}})
126
+ .boolean("active", {card: "badge"})
127
+ .date("createdAt", {card: "meta"})
128
+ .select("role", {
129
+ options: [{value: "admin", label: "Admin"}, {value: "user", label: "User"}],
130
+ card: "badge",
131
+ })
132
+ .build();
133
+ ```
134
+
135
+ ### Available presets
136
+
137
+ | Method | `dataType` | `filter` | `align` |
138
+ |------------------------------------|------------|---------------|---------|
139
+ | `.text(field)` | `text` | `text` | left |
140
+ | `.number(field)` | `number` | `numberRange` | right |
141
+ | `.currency(field)` | `currency` | `numberRange` | right |
142
+ | `.date(field)` | `date` | `dateRange` | left |
143
+ | `.boolean(field)` | `boolean` | `boolean` | left |
144
+ | `.select(field, { options })` | — | `select` | left |
145
+ | `.multiselect(field, { options })` | — | `multiselect` | left |
146
+ | `.custom(colDef)` | — | — | — |
147
+
148
+ Headers are auto-humanized from field names (`createdAt` → `"Created At"`,
149
+ `first_name` → `"First Name"`). Override with `{ header: "Custom Label" }`.
150
+
151
+ Options: `header`, `card` (role shorthand), `cardOrder`, `filter` (`false` to disable,
152
+ or object to merge), `format`, `align`, `cell`, `enableSorting`.
153
+
112
154
  ## Custom layout
113
155
 
114
156
  Compose your own layout by passing children:
115
157
 
116
158
  ```tsx
117
159
  <DataView view={view}>
118
- <DataView.Toolbar />
119
- <DataView.BulkActions />
120
- <DataView.Body />
121
- <DataView.Pagination />
160
+ <DataView.Toolbar/>
161
+ <DataView.BulkActions/>
162
+ <DataView.Body/>
163
+ <DataView.Pagination/>
122
164
  </DataView>
123
165
  ```
124
166
 
125
167
  Or use the standalone components directly for full control:
126
168
 
127
169
  ```tsx
128
- <DataToolbar view={view} showSearch showFilters />
129
- <DataTable view={view} striped highlightOnHover />
130
- <DataPagination view={view} />
170
+ <DataToolbar view={view} showSearch showFilters/>
171
+ <DataTable view={view} striped highlightOnHover/>
172
+ <DataPagination view={view}/>
173
+ ```
174
+
175
+ ### Toolbar sections
176
+
177
+ Inject controls into the toolbar without rebuilding it from scratch using `leftSection`
178
+ and `rightSection`:
179
+
180
+ ```tsx
181
+ <DataView.Toolbar
182
+ leftSection={<Text fw={600}>Users</Text>}
183
+ rightSection={
184
+ <Group gap="xs">
185
+ <Button size="xs" onClick={() => view.exportCsv()}>Export</Button>
186
+ <Button size="xs" onClick={() => view.refetch()}>Refresh</Button>
187
+ </Group>
188
+ }
189
+ />
190
+ ```
191
+
192
+ - `leftSection` renders before the search input (start of the left group).
193
+ - `rightSection` renders after the view switcher (end of the right group).
194
+
195
+ Both sections are disabled during loading along with the other toolbar controls.
196
+
197
+ ### View switcher
198
+
199
+ The `ViewSwitcher` is exported for standalone use with customizable labels:
200
+
201
+ ```tsx
202
+ import { ViewSwitcher } from "@ethanhann/mantine-dataview";
203
+
204
+ // Default
205
+ <ViewSwitcher view={view} />
206
+
207
+ // Custom labels (text or icons)
208
+ <ViewSwitcher view={view} tableLabel="List" cardsLabel="Grid" />
209
+ <ViewSwitcher view={view} tableLabel={<IconList />} cardsLabel={<IconGrid />} />
210
+ ```
211
+
212
+ Or drive the view programmatically:
213
+
214
+ ```tsx
215
+ view.setView("cards"); // switch to cards
216
+ view.view; // current view: "table" | "cards"
131
217
  ```
132
218
 
133
219
  ## Controlled (bring your own data layer)
134
220
 
135
- `useDataViewFetcher` is a thin convenience wrapper. The core, `useDataView`, is fully controlled
136
- you supply `rows`/`rowCount`/`status` and respond to `onRequestChange`:
221
+ `useDataViewFetcher` is a thin convenience wrapper. The core, `useDataView`, is fully controlled.
222
+ You supply `rows`/`rowCount`/`status` and respond to `onRequestChange`:
137
223
 
138
224
  ```tsx
139
- const [resp, setResp] = useState({ rows: [], rowCount: 0 });
225
+ const [resp, setResp] = useState({rows: [], rowCount: 0});
140
226
  const [status, setStatus] = useState<Status>("idle");
141
227
 
142
228
  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
- },
229
+ columns,
230
+ getRowId: (u) => u.id,
231
+ rows: resp.rows,
232
+ rowCount: resp.rowCount,
233
+ status,
234
+ onRequestChange: async (request) => {
235
+ setStatus("loading");
236
+ try {
237
+ setResp(await myApi.list(request));
238
+ setStatus("success");
239
+ } catch {
240
+ setStatus("error");
241
+ }
242
+ },
157
243
  });
158
244
  ```
159
245
 
160
246
  The request is emitted immediately for pagination/sorting and debounced for search/filters.
161
247
 
248
+ ### External parameters
249
+
250
+ Pass arbitrary parameters that aren't tied to a column. They're included in every
251
+ `DataViewRequest`, trigger a refetch when they change, and reset pagination to page 1:
252
+
253
+ ```tsx
254
+ const [tenantId, setTenantId] = useState("acme");
255
+ const [showArchived, setShowArchived] = useState(false);
256
+
257
+ const view = useDataViewFetcher<User>({
258
+ columns,
259
+ getRowId,
260
+ fetcher: async (request) => {
261
+ // request.params = { tenantId: "acme", showArchived: false }
262
+ const res = await api.list(request);
263
+ return {rows: res.items, rowCount: res.total};
264
+ },
265
+ params: {tenantId, showArchived},
266
+ });
267
+
268
+ // Render your own controls...
269
+ <Select data={tenants} value={tenantId} onChange={setTenantId}/>
270
+ <Switch checked={showArchived} onChange={(e) => setShowArchived(e.currentTarget.checked)}/>
271
+ ```
272
+
273
+ Values are typed as `FilterParam` (`string | number | boolean | null | string[] | number[]`).
274
+
275
+ ### Refetching on external changes
276
+
277
+ For cases where external state affects the fetcher but isn't a named parameter (e.g. it's
278
+ baked into the closure), use `deps` to trigger a refetch:
279
+
280
+ ```tsx
281
+ const view = useDataViewFetcher<User>({
282
+ columns,
283
+ getRowId,
284
+ fetcher,
285
+ deps: [selectedTenantId],
286
+ });
287
+ ```
288
+
289
+ When any value in `deps` changes, the current request is re-emitted to the fetcher.
290
+ Prefer `params` when the server needs to see the values; use `deps` when they're already
291
+ in the fetcher closure.
292
+
293
+ ### Manual refresh
294
+
295
+ Re-fetch the current data without changing any state:
296
+
297
+ ```tsx
298
+ <Button onClick={() => view.refetch()}>Refresh</Button>
299
+ ```
300
+
301
+ This re-emits the current request to the fetcher. It's the same mechanism the built-in
302
+ error retry button uses.
303
+
162
304
  ## Column data types and formatting
163
305
 
164
306
  Set `dataType` on a column's meta to enable automatic value formatting. When no explicit
165
307
  `cell` renderer is provided, the library formats values using `Intl.NumberFormat` or
166
308
  `Intl.DateTimeFormat` based on the data type. Raw values are preserved for server requests,
167
- sorting, and filtering formatting is display-only.
309
+ sorting, and filtering. Formatting is display-only.
168
310
 
169
311
  ```tsx
170
312
  col.accessor("price", {
171
- header: "Price",
172
- meta: { dataType: "currency", align: "right" },
313
+ header: "Price",
314
+ meta: {dataType: "currency", align: "right"},
173
315
  });
174
316
 
175
317
  col.accessor("createdAt", {
176
- header: "Created",
177
- meta: { dataType: "date" },
318
+ header: "Created",
319
+ meta: {dataType: "date"},
178
320
  });
179
321
  ```
180
322
 
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` |
323
+ | Data type | Default format | Example |
324
+ |------------|-----------------------------------|---------------|
325
+ | `text` | `String(value)` | `"hello"` |
326
+ | `number` | `Intl.NumberFormat` | `1,234` |
327
+ | `currency` | `Intl.NumberFormat` with currency | `$1,234.56` |
328
+ | `date` | `Intl.DateTimeFormat` | `Jun 2, 2026` |
329
+ | `boolean` | `"Yes"` / `"No"` | `Yes` |
188
330
 
189
331
  ### Format overrides (three levels)
190
332
 
191
- 1. **Library defaults** built-in formatters per data type (above).
192
- 2. **Table-scoped** `formatDefaults` on the hook options, keyed by data type.
193
- 3. **Column-scoped** `format` on `ColumnMeta`, overrides everything for that column.
333
+ 1. **Library defaults**, the built-in formatters per data type (above).
334
+ 2. **Table-scoped**, `formatDefaults` on the hook options, keyed by data type.
335
+ 3. **Column-scoped**, `format` on `ColumnMeta`, overrides everything for that column.
194
336
 
195
337
  ```tsx
196
338
  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
- },
339
+ columns,
340
+ getRowId,
341
+ fetcher,
342
+ // All dates in this table use short format, currency is EUR
343
+ formatDefaults: {
344
+ date: {dateStyle: "short"},
345
+ currency: {currency: "EUR"},
346
+ },
205
347
  });
206
348
 
207
349
  // This column overrides the table default
208
350
  col.accessor("createdAt", {
209
- header: "Created",
210
- meta: {
211
- dataType: "date",
212
- format: { dateStyle: "long" },
213
- },
351
+ header: "Created",
352
+ meta: {
353
+ dataType: "date",
354
+ format: {dateStyle: "long"},
355
+ },
214
356
  });
215
357
 
216
358
  // Or use a function for full control
217
359
  col.accessor("revenue", {
218
- header: "Revenue",
219
- meta: {
220
- dataType: "currency",
221
- format: (v) => `€${(v as number).toFixed(0)}`,
222
- },
360
+ header: "Revenue",
361
+ meta: {
362
+ dataType: "currency",
363
+ format: (v) => `€${(v as number).toFixed(0)}`,
364
+ },
223
365
  });
224
366
  ```
225
367
 
@@ -244,23 +386,23 @@ headers only.
244
386
  Disable sorting on a specific column:
245
387
 
246
388
  ```tsx
247
- col.accessor("avatar", { header: "Avatar", enableSorting: false });
389
+ col.accessor("avatar", {header: "Avatar", enableSorting: false});
248
390
  ```
249
391
 
250
392
  ## Custom headers
251
393
 
252
- Column headers support the same render function pattern as cells pass a component or
394
+ Column headers support the same render function pattern as cells. Pass a component or
253
395
  function to the `header` property:
254
396
 
255
397
  ```tsx
256
398
  col.accessor("revenue", {
257
- header: () => (
258
- <Group gap={4}>
259
- <IconCurrencyDollar size={14} />
260
- <span>Revenue</span>
261
- </Group>
262
- ),
263
- meta: { align: "right" },
399
+ header: () => (
400
+ <Group gap={4}>
401
+ <IconCurrencyDollar size={14}/>
402
+ <span>Revenue</span>
403
+ </Group>
404
+ ),
405
+ meta: {align: "right"},
264
406
  });
265
407
  ```
266
408
 
@@ -272,18 +414,18 @@ Export the current page's visible columns as a CSV file:
272
414
  <Button onClick={() => view.exportCsv()}>Export CSV</Button>
273
415
 
274
416
  // With options
275
- view.exportCsv({ filename: "users.csv", separator: ";" });
417
+ view.exportCsv({filename: "users.csv", separator: ";"});
276
418
 
277
419
  // Export formatted values instead of raw data
278
- view.exportCsv({ formatted: true });
420
+ view.exportCsv({formatted: true});
279
421
  ```
280
422
 
281
423
  The `exportCsv` function is also available as a standalone utility:
282
424
 
283
425
  ```tsx
284
- import { exportCsv } from "@ethanhann/mantine-dataview";
426
+ import {exportCsv} from "@ethanhann/mantine-dataview";
285
427
 
286
- exportCsv(view.table, { filename: "report.csv" });
428
+ exportCsv(view.table, {filename: "report.csv"});
287
429
  ```
288
430
 
289
431
  ## Column pinning
@@ -294,12 +436,12 @@ Pin columns to the left or right edge so they stay visible while scrolling horiz
294
436
 
295
437
  ```tsx
296
438
  const view = useDataViewFetcher<User>({
297
- columns,
298
- getRowId,
299
- fetcher,
300
- initialState: {
301
- columnPinning: { left: ["name"], right: ["actions"] },
302
- },
439
+ columns,
440
+ getRowId,
441
+ fetcher,
442
+ initialState: {
443
+ columnPinning: {left: ["name"], right: ["actions"]},
444
+ },
303
445
  });
304
446
  ```
305
447
 
@@ -322,28 +464,50 @@ view.table.getColumn("name")?.pin(false); // unpin
322
464
 
323
465
  Define filters declaratively on column meta. Seven variants are built in:
324
466
 
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 |
467
+ | Variant | Control | Notes |
468
+ |---------------|-------------------------------------|---------------------------------|
469
+ | `text` | `TextInput` | Free-text search |
470
+ | `select` | `Select` (dropdown) | Single choice, clearable |
471
+ | `multiselect` | `MultiSelect` | Multiple choices |
472
+ | `boolean` | `SegmentedControl` (All/Yes/No) | One-click toggle |
331
473
  | `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 |
474
+ | `date` | `DatePickerInput` | Calendar picker |
475
+ | `dateRange` | `DatePickerInput` (range) | Two-date calendar picker |
334
476
 
335
477
  ```tsx
336
- // Boolean renders as a segmented control
337
- meta: { filter: { variant: "boolean" } }
478
+ // Boolean, renders as a segmented control
479
+ meta: {
480
+ filter: {
481
+ variant: "boolean"
482
+ }
483
+ }
338
484
 
339
485
  // Number range with slider
340
- meta: { filter: { variant: "numberRange", min: 0, max: 1000, step: 10 } }
486
+ meta: {
487
+ filter: {
488
+ variant: "numberRange", min
489
+ :
490
+ 0, max
491
+ :
492
+ 1000, step
493
+ :
494
+ 10
495
+ }
496
+ }
341
497
 
342
498
  // Number range without bounds (falls back to two number inputs)
343
- meta: { filter: { variant: "numberRange" } }
499
+ meta: {
500
+ filter: {
501
+ variant: "numberRange"
502
+ }
503
+ }
344
504
 
345
505
  // Date range
346
- meta: { filter: { variant: "dateRange" } }
506
+ meta: {
507
+ filter: {
508
+ variant: "dateRange"
509
+ }
510
+ }
347
511
  ```
348
512
 
349
513
  ### Custom filter component
@@ -351,22 +515,22 @@ meta: { filter: { variant: "dateRange" } }
351
515
  For filters that don't fit the built-in variants, provide a `component` instead:
352
516
 
353
517
  ```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
- );
518
+ import type {CustomFilterComponentProps} from "@ethanhann/mantine-dataview";
519
+
520
+ function LocationFilter({value, onChange}: CustomFilterComponentProps) {
521
+ return (
522
+ <Chip.Group value={(value as string) ?? ""} onChange={(v) => onChange(v || undefined)}>
523
+ <Group gap={4}>
524
+ <Chip value="london" size="xs">London</Chip>
525
+ <Chip value="berlin" size="xs">Berlin</Chip>
526
+ </Group>
527
+ </Chip.Group>
528
+ );
365
529
  }
366
530
 
367
531
  col.accessor("location", {
368
- header: "Location",
369
- meta: { filter: { component: LocationFilter } },
532
+ header: "Location",
533
+ meta: {filter: {component: LocationFilter}},
370
534
  });
371
535
  ```
372
536
 
@@ -375,18 +539,30 @@ col.accessor("location", {
375
539
  `FilterControl` is exported so you can place individual filters anywhere in your layout:
376
540
 
377
541
  ```tsx
378
- import { FilterControl } from "@ethanhann/mantine-dataview";
542
+ import {FilterControl} from "@ethanhann/mantine-dataview";
379
543
 
380
544
  <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 />
545
+ {view.table.getColumn("inStock") && (
546
+ <FilterControl column={view.table.getColumn("inStock")!}/>
547
+ )}
548
+ <DataView.Toolbar/>
549
+ <DataView.Body/>
550
+ <DataView.Pagination/>
387
551
  </DataView>
388
552
  ```
389
553
 
554
+ ### Programmatic filter control
555
+
556
+ Reset all filters or clear a specific column from anywhere, no need to be inside the toolbar:
557
+
558
+ ```tsx
559
+ // Reset all filters
560
+ <Button onClick={() => view.resetAllFilters()}>Reset all filters</Button>
561
+
562
+ // Clear a single column's filter
563
+ <Button onClick={() => view.resetFilter("status")}>Clear status filter</Button>
564
+ ```
565
+
390
566
  ### Filter display behavior
391
567
 
392
568
  - **Desktop, few filters** (at or below `filterInlineThreshold`, default 3): rendered inline in the toolbar.
@@ -394,12 +570,79 @@ import { FilterControl } from "@ethanhann/mantine-dataview";
394
570
  - **Mobile** (below `sm` breakpoint): always collapsed into a bottom drawer.
395
571
  - A "Reset filters" button appears automatically when any filter is active.
396
572
 
573
+ ## Faceted search
574
+
575
+ When the server returns `facets` in the response, filter controls automatically adapt to show
576
+ dynamic counts, disable zero-result options, and render clickable range buckets.
577
+
578
+ ### Server response with facets
579
+
580
+ ```tsx
581
+ fetcher: async (request) => {
582
+ const res = await api.list(request);
583
+ return {
584
+ rows: res.items,
585
+ rowCount: res.total,
586
+ facets: {
587
+ size: {
588
+ type: "values",
589
+ values: [
590
+ { value: "S", label: "Small", count: 12 },
591
+ { value: "M", label: "Medium", count: 34 },
592
+ { value: "L", label: "Large", count: 0 },
593
+ ],
594
+ },
595
+ price: {
596
+ type: "ranges",
597
+ ranges: [
598
+ { label: "Under $25", from: 0, to: 25, count: 15 },
599
+ { label: "$25-$50", from: 25, to: 50, count: 28 },
600
+ { label: "$50+", from: 50, to: 999, count: 7 },
601
+ ],
602
+ min: 5,
603
+ max: 249,
604
+ },
605
+ },
606
+ };
607
+ };
608
+ ```
609
+
610
+ ### How controls adapt
611
+
612
+ | Filter type | Without facets | With value facets | With range facets |
613
+ |------------|---------------|-------------------|-------------------|
614
+ | Select | Static options | Options with counts, zero-count dimmed | - |
615
+ | Boolean | All / Yes / No | All / Yes (12) / No (3) | - |
616
+ | Number range | Slider or inputs | Slider (bounds from facet) | Clickable range buckets + slider |
617
+ | Date range | Date picker | Date picker | Clickable range buckets + picker |
618
+
619
+ Facets are optional and backward compatible. Facet data updates on every fetch, creating the
620
+ classic faceted search loop where filtering one dimension updates counts on all others.
621
+
622
+ ### Facet types
623
+
624
+ ```ts
625
+ // Discrete values - for select, multiselect, boolean filters
626
+ type ValueFacet = {
627
+ type: "values";
628
+ values: { value: string; label?: string; count: number }[];
629
+ };
630
+
631
+ // Bucketed ranges - for numberRange, dateRange filters
632
+ type RangeFacet = {
633
+ type: "ranges";
634
+ ranges: { label: string; from: number | string; to: number | string; count: number }[];
635
+ min?: number | string;
636
+ max?: number | string;
637
+ };
638
+ ```
639
+
397
640
  ## Card composition
398
641
 
399
642
  In card view, each visible column is placed by its `meta.card.role`:
400
643
 
401
644
  | role | rendered as |
402
- | ---------- | --------------------------- |
645
+ |------------|-----------------------------|
403
646
  | `title` | card heading |
404
647
  | `subtitle` | dimmed line under the title |
405
648
  | `media` | full-bleed top section |
@@ -416,14 +659,14 @@ For full control over card content, use `renderCard`:
416
659
 
417
660
  ```tsx
418
661
  <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
- )}
662
+ view={view}
663
+ renderCard={({data, selected, toggleSelected}) => (
664
+ <Card withBorder padding="md" onClick={toggleSelected}>
665
+ <Text fw={700}>{data.name}</Text>
666
+ <Text size="sm" c="dimmed">{data.email}</Text>
667
+ {selected && <Badge>Selected</Badge>}
668
+ </Card>
669
+ )}
427
670
  />
428
671
  ```
429
672
 
@@ -431,18 +674,18 @@ To keep the default composition but wrap it in a custom card shell, use the `Car
431
674
 
432
675
  ```tsx
433
676
  <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
- }}
677
+ view={view}
678
+ slots={{
679
+ Card: ({data, selected, children}) => (
680
+ <Card
681
+ withBorder
682
+ padding="lg"
683
+ style={{background: selected ? "var(--mantine-color-blue-light)" : undefined}}
684
+ >
685
+ {children}
686
+ </Card>
687
+ ),
688
+ }}
446
689
  />
447
690
  ```
448
691
 
@@ -452,21 +695,21 @@ Provide a `BulkActions` slot to add actions when rows are selected:
452
695
 
453
696
  ```tsx
454
697
  <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
- }}
698
+ view={view}
699
+ slots={{
700
+ BulkActions: (selection) => (
701
+ <Button
702
+ color="red"
703
+ variant="light"
704
+ onClick={() => {
705
+ deleteUsers(selection.ids);
706
+ selection.clear();
707
+ }}
708
+ >
709
+ Delete {selection.count}
710
+ </Button>
711
+ ),
712
+ }}
470
713
  />
471
714
  ```
472
715
 
@@ -479,22 +722,22 @@ Override loading, empty, and error states:
479
722
 
480
723
  ```tsx
481
724
  <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
- }}
725
+ view={view}
726
+ slots={{
727
+ Empty: () => <Text>No users found.</Text>,
728
+ ErrorState: ({retry}) => (
729
+ <Stack align="center">
730
+ <Text c="red">Something went wrong.</Text>
731
+ <Button onClick={retry}>Retry</Button>
732
+ </Stack>
733
+ ),
734
+ LoadingTable: () => <MySkeleton/>,
735
+ LoadingCards: () => <MyCardSkeleton/>,
736
+ }}
494
737
  />
495
738
  ```
496
739
 
497
- A filtered-empty state is handled automatically it shows a "clear filters" action so
740
+ A filtered-empty state is handled automatically. It shows a "clear filters" action so
498
741
  users can reset without manually removing each filter.
499
742
 
500
743
  ## URL state sync
@@ -502,14 +745,14 @@ users can reset without manually removing each filter.
502
745
  Router-agnostic. The default adapter uses the History API; memoize it once:
503
746
 
504
747
  ```tsx
505
- import { windowHistoryAdapter } from "@ethanhann/mantine-dataview/url";
748
+ import {windowHistoryAdapter} from "@ethanhann/mantine-dataview/url";
506
749
 
507
750
  const adapter = useMemo(() => windowHistoryAdapter(), []);
508
751
  const view = useDataViewFetcher<User>({
509
- columns,
510
- getRowId,
511
- fetcher,
512
- urlSync: { adapter },
752
+ columns,
753
+ getRowId,
754
+ fetcher,
755
+ urlSync: {adapter},
513
756
  });
514
757
  ```
515
758
 
@@ -523,12 +766,14 @@ To integrate with a router, implement these three methods:
523
766
 
524
767
  ```ts
525
768
  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;
769
+ /** Current query params as a flat record. */
770
+ read(): Record<string, string>;
771
+
772
+ /** Write the next params; `replace` controls history entry vs push. */
773
+ write(next: Record<string, string>, opts?: { replace?: boolean }): void;
774
+
775
+ /** Optional: notify on external nav (back/forward). Returns an unsubscribe fn. */
776
+ subscribe?(onChange: () => void): () => void;
532
777
  }
533
778
  ```
534
779
 
@@ -537,36 +782,36 @@ interface UrlStateAdapter {
537
782
  ### React Router
538
783
 
539
784
  ```tsx
540
- import { useSearchParams } from "react-router-dom";
541
- import type { UrlStateAdapter } from "@ethanhann/mantine-dataview/url";
785
+ import {useSearchParams} from "react-router-dom";
786
+ import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
542
787
 
543
788
  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
- );
789
+ const [searchParams, setSearchParams] = useSearchParams();
790
+ return useMemo<UrlStateAdapter>(
791
+ () => ({
792
+ read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
793
+ write: (next, opts) => setSearchParams(next, {replace: opts?.replace}),
794
+ }),
795
+ [searchParams, setSearchParams],
796
+ );
552
797
  }
553
798
  ```
554
799
 
555
800
  ### TanStack Router
556
801
 
557
802
  ```tsx
558
- import { useNavigate } from "@tanstack/react-router";
559
- import type { UrlStateAdapter } from "@ethanhann/mantine-dataview/url";
803
+ import {useNavigate} from "@tanstack/react-router";
804
+ import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
560
805
 
561
806
  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
- );
807
+ const navigate = useNavigate();
808
+ return useMemo<UrlStateAdapter>(
809
+ () => ({
810
+ read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
811
+ write: (next, opts) => navigate({search: () => next, replace: opts?.replace}),
812
+ }),
813
+ [navigate],
814
+ );
570
815
  }
571
816
  ```
572
817
 
@@ -582,32 +827,66 @@ Force the card view below a breakpoint:
582
827
 
583
828
  ```tsx
584
829
  const view = useDataViewFetcher<User>({
585
- columns,
586
- getRowId,
587
- fetcher,
588
- responsive: { forceCardsBelow: "sm", lockSwitcherOnMobile: true },
830
+ columns,
831
+ getRowId,
832
+ fetcher,
833
+ responsive: {forceCardsBelow: "sm", lockSwitcherOnMobile: true},
589
834
  });
590
835
 
591
- <DataView view={view} lockSwitcherOnMobile />;
836
+ <DataView view={view} lockSwitcherOnMobile/>;
592
837
  ```
593
838
 
594
839
  When `forceCardsBelow` is set and the viewport is below that breakpoint:
840
+
595
841
  - The view is forced to cards regardless of the user's choice.
596
842
  - The user's explicit choice is preserved and restored above the breakpoint.
597
843
  - The view switcher is disabled (or hidden entirely with `lockSwitcherOnMobile`).
598
844
  - Filters always open in a bottom drawer on mobile.
599
845
 
846
+ ## Loading behavior
847
+
848
+ By default, filter controls, sort controls, and column visibility/pinning menus are disabled
849
+ while data is loading. Sort headers in the table also become non-interactive, with a dimmed
850
+ appearance showing the current sort state. The search input stays enabled so users can keep
851
+ typing during debounced search.
852
+
853
+ Opt out per component:
854
+
855
+ ```tsx
856
+ <DataTable view={view} disableWhileLoading={false} />
857
+ <DataToolbar view={view} disableWhileLoading={false} />
858
+ ```
859
+
860
+ ### Animated row transitions
861
+
862
+ Instead of skeleton loading, rows can animate in and out with CSS transitions. New rows
863
+ fade and slide in, removed rows fade out, and unchanged rows stay in place:
864
+
865
+ ```tsx
866
+ <DataView view={view} animateRows />
867
+ ```
868
+
869
+ When `animateRows` is enabled:
870
+ - Previous rows stay visible while new data loads (no skeleton flash).
871
+ - New rows enter with a slide-down fade-in animation (200ms).
872
+ - Removed rows fade out (150ms) before being removed from the DOM.
873
+ - Works in both table and card views.
874
+
875
+ This is opt-in. The default behavior (skeleton loading) is unchanged.
876
+
600
877
  ## API overview
601
878
 
602
- | Export | Purpose |
603
- | --------------------------------------------------------------------- | --------------------------------------------- |
604
- | `useDataView` | Headless core owns all feature state |
605
- | `useDataViewFetcher` | Convenience wrapper that manages the fetch |
879
+ | Export | Purpose |
880
+ |----------------------------------------------------------------------|-----------------------------------------------|
881
+ | `useDataView` | Headless core, owns all feature state |
882
+ | `useDataViewFetcher` | Convenience wrapper that manages the fetch |
606
883
  | `DataView` (+ `.Toolbar` / `.BulkActions` / `.Body` / `.Pagination`) | Orchestrator + compound parts |
607
- | `DataTable`, `DataCards` | The two presentations (usable standalone) |
884
+ | `DataTable`, `DataCards` | The two presentations (usable standalone) |
608
885
  | `DataToolbar`, `DataPagination`, `DataBulkActions` | Standalone affordances |
609
- | `FilterControl` | Individual filter control (place anywhere) |
610
- | `exportCsv` | Standalone CSV export utility |
886
+ | `FilterControl` | Individual filter control (place anywhere) |
887
+ | `ViewSwitcher` | Table/Cards toggle (customizable labels) |
888
+ | `exportCsv` | Standalone CSV export utility |
889
+ | `col` | Fluent column builder factory |
611
890
  | `createColumnHelper`, `composeCardLayout`, `resolveColumnLabel` | Column helpers |
612
891
  | `@ethanhann/mantine-dataview/url` | `windowHistoryAdapter` + serializer utilities |
613
892
 
@@ -615,15 +894,33 @@ When `forceCardsBelow` is set and the viewport is below that breakpoint:
615
894
 
616
895
  Passed via the `slots` prop on `DataView` or the presentation components:
617
896
 
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 |
897
+ | Slot | Receives | Purpose |
898
+ |----------------|-------------------------------------|----------------------------|
899
+ | `Empty` | `{ view }` | No data state |
900
+ | `ErrorState` | `{ retry }` | Error with retry action |
901
+ | `LoadingTable` | — | Table skeleton replacement |
902
+ | `LoadingCards` | — | Card skeleton replacement |
903
+ | `Row` | `{ row, children }` | Wrap each table row |
904
+ | `Card` | `{ row, data, selected, children }` | Wrap each card |
905
+ | `BulkActions` | `{ count, ids, rows, clear }` | Bulk action bar content |
906
+
907
+ ## Known issues
908
+
909
+ ### `DataView` name shadows the JS global
910
+
911
+ The `DataView` component shares its name with the JavaScript `DataView` global (typed arrays).
912
+ Linters like Biome's `noShadowRestrictedNames` will flag the import. Suppress it with:
913
+
914
+ ```tsx
915
+ // biome-ignore lint/suspicious/noShadowRestrictedNames: component name
916
+ import {DataView} from "@ethanhann/mantine-dataview";
917
+ ```
918
+
919
+ Or import with an alias:
920
+
921
+ ```tsx
922
+ import {DataView as MantineDataView} from "@ethanhann/mantine-dataview";
923
+ ```
627
924
 
628
925
  ## Development
629
926