Collection View
A view-switcher engine — one record collection rendered as Table, Board, or a custom view from a single typed field schema.
@garn/ui/collection-viewOn this page
Default
The whole thesis: Table and Board read one engine, and a card dragged to another column re-buckets in both — because the drag wrote the `status` field back rather than reordering. A toggle-group switches the active view.
Overview
Basic
The whole thesis: Table and Board read one engine, and a card dragged to another column re-buckets in both — because the drag wrote the `status` field back rather than reordering. A toggle-group switches the active view.
Group By
One schema, two group-by fields — the grouping means the same thing in every view because it is derived from the schema, not the renderer.
States
States
Loading, empty and error as first-class slots — each self-gates on `status`; a spinner in Loading, a rich `Empty`, an alert in Error.
Selection
Selection
Selection is a set of record ids, not a renderer concept — a row ticked in the table is still ticked on the board, and each checkbox is named after its row.
Extensibility
Custom Renderer
A ~15-line consumer renderer registered as data — it gets group-by, selection and write-back with no core change, and names its region via `view.labelProps`.
Headless
The engine on its own — `useCollectionView` derives `ViewData` with no garn markup, feeding a `stat` KPI row as easily as a table.
Recipes
Formatted Fields
Recipe — presentation lives on the schema: a field's `format` renders its cell, so a status reads as the same tone-coded chip in every renderer.
Bulk Actions
Recipe — a bulk-action bar over the view-agnostic selection: a real Toolbar applies a field change across every ticked row.
Date Grouping
Recipe — grouping by a `date` field buckets by local calendar day, so three different times on one day are one group, not three.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add collection-view- Registry
collection-view- Deps
- None
- Registry deps
utilsuse-controllable-statetableboardbadgecheckbox
Import
import { CollectionView.View, CollectionView.Empty, CollectionView.Loading, CollectionView.Error } from "@garn/ui/collection-view";Anatomy
The parts this component is built from — compose them to assemble it.
CollectionView.View[data-slot="collection-view-view"]The active renderer surface. The implicit child — omit all children and the root renders <CollectionView.View /> on its own; pass children to compose the state slots around it.
CollectionView.Empty[data-slot="collection-view-empty"]Self-gating empty slot (renders only when the pipeline status is empty).
CollectionView.Loading[data-slot="collection-view-loading"]Self-gating loading status message (role=status). The adapter draws the skeleton; this speaks the state.
CollectionView.Error[data-slot="collection-view-error"]Self-gating error slot (role=alert). Children win; otherwise it shows the root's error node.
Props
The component's public props and their types.
activeView- Description
- Id of the active view (controlled).
- Type
string
children- Description
- Omit to render the active view directly — `<CollectionView.View />` is the implicit child. Pass children to compose the state slots around it.
- Type
React.ReactNode
config- Description
- Grouping, search, sort, and row-height for the active view (controlled) — JSON-serializable, so it round-trips through a URL or storage.
- Type
CollectionViewConfig
defaultActiveView- Description
- Id of the initially active view (uncontrolled); defaults to the first registered view.
- Type
string
defaultConfig- Description
- Initial view config (uncontrolled).
- Type
CollectionViewConfig
defaultSelection- Description
- Initially selected row ids (uncontrolled).
- Type
string[]
error- Description
- Error content to surface instead of the view; `null` clears it.
- Type
React.ReactNode- Default
null
getRowIdRequired- Description
- Stable id per record. Selection and every renderer key off this — it must be idempotent and unique, never an array index. Keep it referentially stable (module scope or `useCallback`). The derivation is memoized on `records`, `schema`, `getRowId` and `config.groupBy`, and the assembled view additionally on `labelProps` and `error` — hand any of those a fresh object each render and the memo never hits.
- Type
(record: T) => string
loading- Description
- Show the loading state instead of content (e.g. while records are being fetched).
- Type
boolean- Default
false
onActiveViewChange- Description
- Fired when the active view changes.
- Type
(id: string) => void
onConfigChange- Description
- Fired when grouping, search, sort, or row-height changes.
- Type
(config: CollectionViewConfig) => void
onCreate- Description
- Supply this to opt into renderers' create affordances.
- Type
(meta: { groupId: string; value?: unknown; text?: string; }) => void | Promise<void>
onFieldValueChange- Description
- Apply a field write-back. Until your `records` reflect it, the view will snap back — that is the controlled contract working as intended.
- Type
(rowId: string, fieldId: string, value: unknown) => void | Promise<void>
onReorder- Description
- A record moved to a new position within a group. Row order comes from your `records` array, so move the record there to make the new position stick.
- Type
(rowId: string, meta: { groupId: string; index: number; }) => void | Promise<void>
onSelectionChange- Description
- Fired when the selection changes.
- Type
(selection: string[]) => void
recordsRequired- Description
- The records to display — the single source of order and content across every view.
- Type
T[]
schemaRequired- Description
- Field definitions shared by all views — what each view groups, sorts, searches, and renders.
- Type
CollectionFieldSchema<T>
searchableFields- Description
- Field ids `config.search` reads. Defaults to every text / select / user field. Keep it referentially stable — the derivation memoizes on it.
- Type
string[]
selection- Description
- Selected row ids (controlled).
- Type
string[]
viewsRequired- Description
- The views to offer — each a renderer (Table, Board, a custom gallery). The first is the default.
- Type
ViewRenderer<T>[]
Plus 279 inherited native <div> attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="collection-view"][data-slot="collection-view-view"][data-slot="collection-view-empty"][data-slot="collection-view-loading"][data-slot="collection-view-error"]States
| Selector | State | Description |
|---|---|---|
[data-slot=collection-view][data-status=idle] | idle | Records present, nothing pending — the renderer draws its content. |
[data-slot=collection-view][data-status=loading] | loading | `loading` is set — the adapter draws a shape-aware skeleton and `CollectionView.Loading` speaks the status. |
[data-slot=collection-view][data-status=empty] | empty | Zero rows and not loading/error — `CollectionView.Empty` mounts. |
[data-slot=collection-view][data-status=error] | error | A truthy `error` — `CollectionView.Error` mounts (role=alert). |
[data-slot=collection-view-view][aria-busy=true] | busy | The view surface is busy while loading. Scoped to the surface, not the root, so the sibling status region can still announce. |
| Property | Token | Tier |
|---|---|---|
groupHeaderTint | --garn-mutedbrandable | semantic |
mutedText | --garn-muted-foreground | semantic |
errorText | --garn-danger-foreground | semantic |
When to use
A collection the user can look at more than one way from a single schema — Table today, Board tomorrow, a custom gallery next — where grouping, selection and write-back must mean the same thing in every view (a task tracker with a list and a Kanban, a records surface with a spreadsheet-style grid and a status board, an admin resource with a table and a status board). Reach for it when the switch, the shared group-by, or the drag-is-a-mutation write-back is the point. Use useCollectionView alone when you want the derived data without garn's markup.
Reach for something else when
- You only ever show ONE shape — a single data grid (use
tabledirectly), a single Kanban (useboard), or a single reorderable list (uselist/sortable). There is no switching, no shared schema across views, and no write-back to orchestrate. A read-only chronological feed (usetimeline) - work against a time axis (use
ganttorscheduler). Filtering, multi-sort and a built-in view switcher are not in this phase — if you need those today, composefiltersand atoggle-groupswitcher around it (the examples show the switcher).
Overview
CollectionView is one data source, many looks — the meta-component that makes a table, the sortable Board, and views you write yourself feel like one product: switch from Table to Board and it's the same records, the same grouping, just a different shape. The intelligence lives below a seam. A typed field schema feeds a pure, memoized filter → sort → group → ViewData pipeline (useCollectionView); each renderer is a thin adapter that consumes the normalized ViewData and writes back only through ViewApi. That one rule — renderers never touch raw records, they mutate through the api — is what lets Table and Board share a single group-by, keeps a view-agnostic selection (a Set of record ids) alive across a switch, and makes a consumer-defined renderer a first-class citizen: a renderer is data ({ id, kind, requires, supports, render }), not a hardcoded branch, so you add a view without forking the core. It is generic over the record type, controlled or uncontrolled per axis (activeView / config / selection), and treats loading / empty / error as first-class pipeline states rather than afterthoughts.
The defining move is the write-back contract: on Board a drag across columns is a field mutation — the card's group-by value becomes the target column's value (onFieldValueChange), not a reorder. Row order is deliberately outside the engine: the pipeline buckets, it does not order, so onReorder reports a group-relative index and your records array owns the resulting order. Today it covers the schema + group-by pipeline proven through Table AND Board, plus the registry, the write-back, and the async-state slots. Filter, multi-sort, search, a built-in Switcher shell, and the List/Calendar adapters come later.
Guidelines
Author the schema first. CollectionField<T> (id, type, label, accessor, groupable?, options?, format?) is the linchpin — one schema drives the config UI and the pipeline. Declare options on a select group-by field so group order is fixed and an empty group stays alive (without it, dragging the last card out of a Board column deletes the column). format renders a field's cell in every renderer, so a status reads as the same chip in Table and Board.
Own the write-back. onFieldValueChange applies a mutation to your source of truth; onReorder reports a position (row order lives in your records array — move the record there or the drop snaps back). Both are typed void | Promise<void> so an optimistic layer can land later without a signature change.
Give it a stable identity and a name. A required getRowId (idempotent, never an index) keys selection, drag and React. Set aria-label / aria-labelledby on the root — it is handed to the active renderer, not the wrapper.
Compose the state slots when you want rich ones. Omit children and the root renders the active view directly; pass CollectionView.Loading / .Empty / .Error (with .View) to compose an empty message or a spinner. The slots self-gate on status — you don't branch on it yourself.
Reach past the markup with the hook. useCollectionView returns the derived ViewData with no garn markup — drive your own switcher, a KPI row, or a server render from the same engine.
Best practices
- Give the collection an accessible name (
aria-label/aria-labelledby) — it's handed to the renderer and is a required contract. - Provide a stable, unique
getRowId(module scope / useCallback), never an array index. - Declare
optionson aselectgroup-by field to fix group order and keep empty groups (Board columns) alive. - Own the write-back: apply
onFieldValueChangeto your records, and move the record in your array to honouronReorder(the engine buckets, it doesn't order). - Keep
viewsdefined outside render so each renderer's identity is stable. - Compose
CollectionView.Loading/.Empty/.Errorfor rich states; they self-gate on status.
- Don't read or mutate raw records from inside a renderer — consume
ViewDataand write back throughViewApi(that is what keeps every view swappable). - Don't put the accessible name on the root wrapper — it belongs on the renderer's semantic element; naming both double-announces.
- Don't call hooks inside a
ViewRenderer.rendercallback — it runs duringCollectionView.View's render; return a component and let it own its state. - Don't expect a drag to persist by itself — Board reports the move as a field mutation; nothing sticks until your records reflect it.
- Don't group by an object field — expose a primitive id from the
accessor, or the values fold into 'No value'. - Don't reach for it for a single fixed view — use
table/board/listdirectly.
Troubleshooting
A Board card dragged to another column snaps back — nothing persists.
Cause. A drag is a mutation, but CollectionView doesn't change your data — it reports the write-back through onFieldValueChange.
Fix. Apply onFieldValueChange(rowId, fieldId, value) to your records (set the field). Until the records reflect it the card snaps home — that's the controlled contract working.
A Board column vanishes when its last card is dragged away.
Cause. A group with no records ceases to exist, so the column disappears.
Fix. Declare options: { value, label }[] on the group-by field. Declared options seed the group set (in order) and keep an empty group — and column — alive.
Reordering cards within a column does nothing / the order resets.
Cause. The engine BUCKETS, it does not ORDER — row order lives in your records array. onReorder only reports a group-relative index.
Fix. In onReorder, splice the moved record into that position in your records array. (Manual order as a first-class sort mode is a later phase.)
A screen reader announces the collection twice, or the axe check flags a name on a role-less element.
Cause. The accessible name was put on the root <div> as well as (or instead of) the renderer's semantic element.
Fix. Set aria-label / aria-labelledby on <CollectionView> — it hands the name to the renderer, which applies it to the <table> / board region. In a custom renderer, spread view.labelProps onto a real semantic element (a named <section>, table, or role=group).
A custom renderer that uses useState warns about hook order or resets on a view switch.
Cause. ViewRenderer.render runs inside CollectionView.View's own render, so hooks there bind to the View and churn across renderer switches.
Fix. Make render hook-free — return a component (render: (view, api) => <MyGrid view={view} api={api} />) and let that component own its state.
Grouping by a field puts every record in its own group, or everything folds into 'No value'.
Cause. The group-by accessor returns a non-primitive (an object) or a raw Date instant — objects have no stable key, and a date-typed field is what buckets by calendar day.
Fix. Return a primitive (string/number) id from the accessor, and set the field type: 'date' so a date buckets by local day rather than by millisecond.
The loading spinner shows but the 'Loading…' message is never announced.
Cause. aria-busy was placed on a container that wraps the status region — a busy container withholds its live-region announcements.
Fix. That's why CollectionView scopes aria-busy to the view surface and keeps CollectionView.Loading (role=status) a sibling. Don't hoist aria-busy onto the root or wrap the Loading slot inside a busy node.
Accessibility
- ARIA APG
- Composite (delegated per renderer)
- Focus
- CollectionView adds no focus management of its own — focus lives inside the active renderer (Table's rows, Board's roving drag activators). Don't strip the adapters' focus rings.
Accessibility requirements
The collection has an accessible name via `aria-label` or `aria-labelledby`; the active renderer applies it to its own semantic element (a dev warning fires otherwise).
when always
The accessible name lands on the renderer's semantic element (the `<table>`, the board region), never on the root `<div>` wrapper — naming both double-announces. A custom renderer must spread `view.labelProps` onto a real semantic element.
when always
`getRowId` returns a unique, idempotent id per record (never an array index); selection, drag, and React keys all rely on it. Duplicate ids dev-warn.
when always
Group only by a field that exists in the schema and is marked `groupable: true`; an unknown or non-groupable field is refused and dev-warns.
when a `groupBy` is set in config
A group-by field must yield a stable string/number key from its `accessor`; values with no safe key fold into the 'No value' group and dev-warn (expose a primitive id instead of an object).
when grouping by a field whose values are non-primitive
Register at least one renderer in `views` — an empty array renders nothing and dev-warns. `tableView()` and `boardView()` are built in.
when always
`aria-busy` is scoped to the view surface, not the root, so the sibling Loading status region stays announceable.
when the loading status is active
Keyboard
| Tab | Move into the active renderer; the keyboard model is the renderer's own (Table's cell/row model, Board's sortable drag). CollectionView adds no keys of its own. |
- The accessible name (`aria-label` / `aria-labelledby`) is NOT applied to the root wrapper — it is handed to the active renderer, which places it on whatever element carries the semantics (the `<table>`, the board's role=group). Naming both would announce the collection twice. A dev-only warning fires if neither is set.
- `aria-busy` sits on the view surface (`collection-view-view`) while loading, not on the root and not on the mount-only Loading slot: a busy container withholds announcements from live regions inside it, so the status message must be a sibling, not a child.
- `CollectionView.Loading` is a role=status region (the spoken 'loading' message); the adapter draws the shape-aware skeleton beside it. `CollectionView.Error` is a role=alert. Both self-gate on the pipeline status.
- Selection is a set of record ids, not a renderer concept, so it is preserved across a view switch and round-trips as controlled state; each adapter names its per-row control after the row (e.g. 'Select {title}') rather than N identical 'Select row' controls.
- A custom renderer must apply `view.labelProps` to a real semantic element (a named `<section>`, a `<table>`, a role=group) — an aria-label on a role-less `<div>` is ignored.