Skip to content
garn

Collection View

A view-switcher engine — one record collection rendered as Table, Board, or a custom view from a single typed field schema.

Betav0.4.2added in v0.3.0@garn/ui/collection-view
On 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.

terminal
npx garn-ui add collection-view
First time? Set up garn in your project
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

SelectorState
[data-slot=collection-view][data-status=idle]idle
[data-slot=collection-view][data-status=loading]loading
[data-slot=collection-view][data-status=empty]empty
[data-slot=collection-view][data-status=error]error
[data-slot=collection-view-view][aria-busy=true]busy
PropertyTokenTier
groupHeaderTint--garn-mutedbrandablesemantic
mutedText--garn-muted-foregroundsemantic
errorText--garn-danger-foregroundsemantic

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 table directly), a single Kanban (use board), or a single reorderable list (use list / sortable). There is no switching, no shared schema across views, and no write-back to orchestrate. A read-only chronological feed (use timeline)
  • work against a time axis (use gantt or scheduler). Filtering, multi-sort and a built-in view switcher are not in this phase — if you need those today, compose filters and a toggle-group switcher 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

Do
  • 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 options on a select group-by field to fix group order and keep empty groups (Board columns) alive.
  • Own the write-back: apply onFieldValueChange to your records, and move the record in your array to honour onReorder (the engine buckets, it doesn't order).
  • Keep views defined outside render so each renderer's identity is stable.
  • Compose CollectionView.Loading / .Empty / .Error for rich states; they self-gate on status.
Don't
  • Don't read or mutate raw records from inside a renderer — consume ViewData and write back through ViewApi (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.render callback — it runs during CollectionView.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 / list directly.

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

error

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

warn

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

error

`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

warn

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

warn

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

error

Register at least one renderer in `views` — an empty array renders nothing and dev-warns. `tableView()` and `boardView()` are built in.

when always

warn

`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

TabMove 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.
A1.3.1Info and RelationshipsA4.1.2Name, Role, ValueAA4.1.3Status Messages
  • 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.