Skip to content
garn

Data Toolbar

The layout-only strip above a collection — it splits Scope (search, filters) from Display (view switcher, display options) and enforces that split as anatomy.

Betav0.4.2added in v0.3.0@garn/ui/data-toolbar
On this page

Default

The whole thesis: inside a CollectionView every control wires itself — search, view switcher and DisplayMenu with no props at all.

Overview

Data Toolbar

The whole thesis: inside a CollectionView every control wires itself — search, view switcher and DisplayMenu with no props at all.

Data Toolbar Plain Table

The same strip over a plain Table through explicit props — no engine underneath, so search and display config are the consumer's own state and DisplayMenu drives the table's density.

Data Toolbar Mixed

Explicitly-wired and context-wired controls on one strip — legal and silent: the search drives your own query while the switcher reads the view engine.

Saved Views

Data Toolbar Two Level

Two bands with combined dirty state — change the grouping and the view band offers Save and Reset while the Display trigger carries a dot for its own half.

Data Toolbar Saved View

One band is simply one Row — the flat strip with a saved-view picker, switcher and DisplayMenu.

Responsive

Data Toolbar Narrow

The strip collapses against its own width, not the viewport's — in a narrow pane the search shrinks to its icon (focus re-expands it) while the switcher and Display go icon-only.

Data Toolbar Sizes

The `size` axis drives every control in the strip at once.

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 data-toolbar
First time? Set up garn in your project
Registry
data-toolbar
Deps
lucide-reactclass-variance-authority
Registry deps
utilsuse-announceuse-controllable-stateuse-debounced-callbackbuttoninputpopoverselectseparatortoggle-groupcollection-viewfilters

Import

import { DataToolbar.Row, DataToolbar.Scope, DataToolbar.Display, DataToolbar.Actions, DataToolbar.Separator, DataToolbar.Search, DataToolbar.ViewSwitcher, DataToolbar.SavedView, DataToolbar.DisplayMenu, DisplayMenu.Section, DisplayMenu.Layout, DisplayMenu.Grouping, DisplayMenu.RowHeight } from "@garn/ui/data-toolbar";

Anatomy

The parts this component is built from — compose them to assemble it.

DataToolbar.Row[data-slot="data-toolbar-row"]

One band. The container-query owner.

DataToolbar.Scope[data-slot="data-toolbar-scope"]

Leading zone; conditional role=search landmark.

DataToolbar.Display[data-slot="data-toolbar-display"]

Trailing named role=group of display options.

DataToolbar.Actions[data-slot="data-toolbar-actions"]

Trailing zone for the band's own actions; role only when named.

DataToolbar.Separator[data-slot="data-toolbar-separator"]

Decorative vertical hairline.

DataToolbar.Search[data-slot="data-toolbar-search"]

Dual-mode debounced search field with icon collapse.

DataToolbar.ViewSwitcher[data-slot="data-toolbar-view-switcher"]

Dual-mode segmented view switcher; hidden below two views.

DataToolbar.SavedView[data-slot="data-toolbar-saved-view"]

View picker + combined dirty state over an injectable store.

DataToolbar.DisplayMenu[data-slot="display-menu"]

The sectioned display popover. Also exported bare as DisplayMenu.

DisplayMenu.Section[data-slot="display-menu-section"]

A named, capability-gated section wrapper.

DisplayMenu.Layout

The layout picker section; stands down when a ViewSwitcher is on the strip.

DisplayMenu.Grouping

Single-level grouping over the schema's groupable fields.

DisplayMenu.RowHeight

Per-view row density (never the global theme).

Props

The component's public props and their types.

size
Description
Control sizing for every part in the strip.
Type
"md" | "sm"
Default
"md"

Plus 280 inherited native <div> attributes.

DataToolbar.Scope

label
Description
Names the `search` landmark. Only used when the zone holds a search field.
Type
string
Default
"Search and filter"

Plus 280 inherited native HTML attributes.

DataToolbar.Display

label
Description
Names the trailing `group` of display options.
Type
string
Default
"Display options"

Plus 280 inherited native HTML attributes.

DataToolbar.Actions

label
Description
Names the zone as a group. Omit for bare buttons, which name themselves.
Type
string

Plus 280 inherited native HTML attributes.

DataToolbar.Search

className
Description
Class for the sizing wrapper (which owns the collapse); `inputClassName` styles the field itself.
Type
string
debounce
Description
Milliseconds of quiet before the value is committed outward. Typing itself is never debounced — only the commit is.
Type
number
Default
200
defaultValue
Description
Initial query for the uncontrolled field; committed once on mount.
Type
string
inputClassName
Description
Class for the composed `Input` itself, inside the sizing wrapper.
Type
string
label
Description
Accessible name for the field.
Type
string
Default
"Search"
onValueChange
Description
Fires with the committed query after the debounce, not on every keystroke.
Type
(value: string) => void
value
Description
The committed query, controlled. Omit to read a surrounding `CollectionView`.
Type
string

Plus 318 inherited native HTML attributes.

DataToolbar.ViewSwitcher

defaultValue
Description
The initially active view id for the uncontrolled switcher.
Type
string
label
Description
Accessible name for the segmented control.
Type
string
Default
"View"
onValueChange
Description
Fires with the newly selected view id.
Type
(id: string) => void
value
Description
The active view id, controlled. Omit to read the CollectionView's active view.
Type
string
views
Description
Explicit view list. Omit to read the surrounding CollectionView's registry.
Type
DataToolbarView[]

Plus 277 inherited native HTML attributes.

DataToolbar.SavedView

config
Description
The live config. Omit to read the surrounding CollectionView's.
Type
CollectionViewConfig
defaultViewId
Description
The saved view applied initially when uncontrolled.
Type
string
Default
null
filters
Description
The live filter model. Omit for a config-only strip.
Type
FilterGroup
generateId
Description
Generates ids for newly saved views.
Type
() => string
onConfigChange
Description
Fires when Reset or an applied view restores the config.
Type
(config: CollectionViewConfig) => void
onFiltersChange
Description
Fires when Reset or an applied view restores the filter model.
Type
(model: FilterModel) => void
onViewIdChange
Description
Fires with the newly applied view id, or `null` when cleared.
Type
(id: string | null) => void
placeholder
Description
What the trigger reads when no saved view is applied.
Type
string
Default
"All"
storeRequired
Description
The view store. Typed to `CollectionViewConfig` because that is what this part actually reads and writes; `config` is optional on a saved view, so a filter-only strip simply never fills it.
Type
FilterViewStore<CollectionViewConfig>
viewId
Description
The saved view currently applied, or `null` for none. Controlled.
Type
string

Plus 278 inherited native HTML attributes.

DataToolbar.DisplayMenu

activeView
Description
The active view id for the Layout section, controlled. Omit to read context.
Type
string
align
Description
Which edge of the trigger the popover aligns to.
Type
"center" | "end" | "start"
Default
"end"
baseline
Description
The config the active saved view was stored with — the trigger marks itself dirty against it. Inside a `DataToolbar`, `SavedView` supplies this.
Type
CollectionViewConfig
children
Description
The sections. Defaults to Layout · Grouping · Row height.
Type
React.ReactNode
className
Description
Class for the popover content surface.
Type
string
config
Description
Explicit config. Omit to read/write the surrounding CollectionView's.
Type
CollectionViewConfig
contentProps
Description
Anything else lands on the popover content — `id`, `data-*`, analytics hooks.
Type
Omit<PopoverContentProps & React.RefAttributes<HTMLDivElement> & { appearance?: "solid" | "glass"; }, "className" | "children" | "align">
defaultOpen
Description
Whether the popover starts open when uncontrolled.
Type
boolean
Default
false
fields
Description
Groupable fields. Defaults to the CollectionView schema's groupable fields.
Type
DataToolbarField[]
label
Description
Accessible name for the popover and its default trigger.
Type
string
Default
"Display"
loading
Description
Render sections disabled while the schema is still resolving.
Type
boolean
Default
false
onActiveViewChange
Description
Fires with the newly selected view id from the Layout section.
Type
(id: string) => void
onConfigChange
Description
Fires with the next config on any section change.
Type
(config: CollectionViewConfig) => void
onOpenChange
Description
Fires when the popover opens or closes.
Type
(open: boolean) => void
open
Description
Open state of the popover, controlled.
Type
boolean
renderer
Description
The renderer whose capabilities gate the sections. Falls back to the surrounding CollectionView's active renderer. Supply it when using `DisplayMenu` standalone — capability gating is the point of the sections, and without a renderer there is nothing to gate against.
Type
ViewRenderer<unknown>
trigger
Description
Replace the default trigger entirely.
Type
React.ReactNode
triggerProps
Description
Anything else for the default trigger. Ignored when `trigger` is supplied.
Type
Omit<ButtonProps, "children">
views
Description
Views for the Layout section. Defaults to the CollectionView registry.
Type
DataToolbarView[]

DisplayMenu.Section

name
Description
Reflected to `data-section`.
Type
string
requires
Description
Hide this section when the active renderer declares the capability unsupported. A view that can't sort shouldn't show an Ordering section at all — an inert control is worse than an absent one when the reason is structural rather than temporary.
Type
"filter" | "multiSelect" | "reorder" | "sort"
titleRequired
Description
The section's visible heading; also names its `group`.
Type
React.ReactNode

Plus 279 inherited native HTML attributes.

DisplayMenu.Layout

title
Description
Overrides the section's default heading (each section supplies its own).
Type
React.ReactNode

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="data-toolbar"][data-slot="data-toolbar-row"][data-slot="data-toolbar-scope"][data-slot="data-toolbar-display"][data-slot="data-toolbar-actions"][data-slot="data-toolbar-separator"][data-slot="data-toolbar-search"][data-slot="data-toolbar-view-switcher"][data-slot="data-toolbar-saved-view"][data-slot="display-menu-trigger"][data-slot="display-menu"][data-slot="display-menu-section"]

States

SelectorState
[data-slot=data-toolbar-saved-view][data-dirty]dirty
[data-slot=display-menu-trigger][data-dirty]config-dirty
[data-slot=data-toolbar-view-switcher][data-view]active-view
[data-slot=data-toolbar-search] :where(@container/data-toolbar (max-width))collapsed
PropertyTokenTier
dirtyIndicator--garn-brand-solidbrandablesemantic
mutedText--garn-muted-foregroundsemantic
sectionBorder--garn-bordersemantic
errorText--garn-dangersemantic
collapsedFieldWidth--garn-control-h-mdsemantic · air
collapseMotion--garn-motion-fastsemantic

When to use

Above a data collection that needs a management strip — search, filters, a view switcher, display options, saved views — over a CollectionView, a plain Table, or a server-side query. Reach for it when you want the Scope/Display split as real anatomy, controls that wire themselves from a CollectionView (or from your own state) without a mode prop, container-query collapse in a narrow pane, or the sectioned DisplayMenu for grouping / row-height / layout. Take DisplayMenu alone (it's a separate export) when you want the display popover over your own layout, or DataToolbar.SavedView alone for a { filters, config } view picker with combined dirty state.

Reach for something else when

  • A general application action bar (formatting controls, a media player's transport, a rich-text toolbar) — that is toolbar, which turns on role=toolbar + roving at three controls
  • DataToolbar deliberately does not. A page header with a title, breadcrumb and page-level actions but no collection beneath it (compose breadcrumb + your own header row). Just a filter chip row with no search / display / view concerns (use filters / FilterBar directly — DataToolbar slots it in but doesn't replace it). A single fixed view with no search, no display options and no saved views — a bare table needs no strip. This version ships no Ordering (multi-sort) or Properties (column-visibility) section.

Overview

DataToolbar is the strip above a collection, and it works whether or not there's an engine underneath it. It draws the line every modern data product draws — Scope on the leading side (search, filters: which records am I looking at) and Display / Actions on the trailing side (view switcher, display options, 'New record': how am I looking at them) — and enforces that split as anatomy rather than convention. The root is a stack of Row bands, because dense collections put view selection on a level above search and filters; a flat strip is simply one Row.

The strip is deliberately layout-only: no role=toolbar, no roving tabindex, no measured overflow. That is a forced choice, not a lazy one — a roving toolbar nested over FilterBar's role=grid chips would put two roving models on the same arrow keys and break both. The intelligence lives instead in the engines the strip fronts and in DisplayMenu, the sectioned popover that carries the actual value.

Every control is dual-mode through one three-step ladder: an explicit value/onChange wins (drive a plain Table, a server query, your own store); else a surrounding CollectionView auto-wires it from context with no props at all; else it renders inert and dev-warns once. Mixing the two on one strip — a search box on explicit props beside a context-wired switcher — is legal and silent. This is the direct inversion of a bar-wide mode enum that forces every control into one state.

DisplayMenu ships standalone as well as inside the strip: sections are children, so you choose which appear and in what order, and toggling a row never closes the popover (configuring a view is a run of small changes). It is the first surface to honestly reflect a renderer's requires/supports back to the user — a Board with no group-by field disables 'No grouping' and says why; a view that can't sort hides Ordering entirely. Row height drives the active view's density, never the global theme.

Guidelines

Pick your wiring, don't declare a mode. Inside a CollectionView, drop the parts in bare and they auto-wire. Over a plain Table or a server query, pass explicit value/onValueChange (Search), config/onConfigChange (DisplayMenu), views+value (ViewSwitcher). You can mix the two on one strip — nothing warns.

Zones live inside a Row. The root stacks bands vertically; a zone placed directly in it lays out as its own row and dev-warns. One band is one Row; two bands put the view picker and its actions above search and filters.

Search is transient; a saved view is { filters, config }. The search query is excluded from a view's definition and its dirty diff — typing never marks a view dirty, and saving never bakes today's query into a preset. SavedView owns the combined dirtiness across the FilterModel and the config; the DisplayMenu trigger's dot tracks only the config half.

Let capabilities gate the sections. Supply a renderer (or mount inside a CollectionView) so DisplayMenu can honour requires/supports — a disabled control with a stated reason beats one that silently does nothing.

Row height is per-view, not global density. It maps to the active view's own density (Table) or card size (Board); it never writes data-density on <html>. Reach for the global theme's density control if you actually want to resize the whole app.

Retire the older filter-only saved-view UI. SavedView supersedes FilterBar.SavedViews, which saves no config half — prefer DataToolbar.SavedView when a view spans both filters and display.

Best practices

Do
  • Put zones (Scope / Display / Actions) inside a DataToolbar.Row — the Row lays them out and owns the container query.
  • Inside a CollectionView, drop the parts in with no props and let them auto-wire; over a plain Table, pass explicit value/onChange per part.
  • Supply a renderer to a standalone DisplayMenu so capability gating (the point of the sections) actually works.
  • Use DataToolbar.SavedView for views that span filters and display; it owns the combined dirty state and the { filters, config } round-trip.
  • Keep views / renderer lists referentially stable (module scope) so the context identity doesn't churn every render.
  • Let Row height drive the view's own density; use the global theme density control if you mean to resize the page.
Don't
  • Don't reach for it as a general action toolbar — use toolbar (roving, role=toolbar) for formatting/transport bars.
  • Don't nest a roving toolbar inside the strip over FilterBar chips — two roving models fight for the arrow keys; slot FilterBar in as-is.
  • Don't place a zone directly in the root — it lays out as its own band and dev-warns; wrap it in a Row.
  • Don't expect a bare DisplayMenu (no renderer, no CollectionView) to gate sections — without a renderer there is nothing to gate against.
  • Don't treat search as part of a saved view — it's transient and excluded from the dirty diff and the write on purpose.
  • Don't use Row height to change global density — it's per-view; it never touches <html>'s data-density.

Troubleshooting

A DataToolbar.Search / ViewSwitcher / DisplayMenu renders but does nothing, and a console warning fires.

Cause. The control has neither explicit props nor a surrounding CollectionView — the ladder's third rung is inert-plus-warn.

Fix. Either pass explicit props (value/onValueChange on Search, config/onConfigChange on DisplayMenu, views+value on ViewSwitcher) or mount the strip inside a <CollectionView> so it auto-wires from context.

A zone lays out as its own row instead of sitting beside the trailing zone, and a warning mentions DataToolbar.Row.

Cause. Scope / Display / Actions were placed directly in the root, which stacks its children vertically as bands.

Fix. Wrap the zones in a <DataToolbar.Row> — the Row is the horizontal band that lays zones out and owns the container query. A one-band strip is one Row.

A standalone DisplayMenu never disables or hides any section — capability gating seems dead.

Cause. No renderer was supplied and it's not inside a CollectionView, so there is nothing whose requires/supports to gate against.

Fix. Pass renderer={...} (the same ViewRenderer you register with CollectionView), or mount the menu inside a <CollectionView> so it reads the active renderer.

Both a ViewSwitcher on the strip and the DisplayMenu Layout section show the layout choices — the same control twice.

Cause. A visible ViewSwitcher registers on the root; the Layout section is designed to stand down when it does, but a custom section order re-added Layout unconditionally.

Fix. Leave DisplayMenu.Layout out when a ViewSwitcher is on the strip (the default section set already yields), or drop the ViewSwitcher and let Layout be the layout control.

Typing in the search box marks the saved view dirty and offers to Save.

Cause. Search was treated as part of the view config; it is transient and must be excluded from the dirty diff and the write.

Fix. Nothing to do in current code — viewConfig() already drops search. If you built a custom saved-view diff, exclude the search key the same way.

Returning Row height or Grouping to its default leaves the view permanently dirty against a state it visibly matches.

Cause. The controls write rowHeight: 'default' / groupBy: null where a saved view simply omits the key, so a literal compare never matches.

Fix. Compare through viewConfig() (drops defaults and canonicalizes key order) before diffing — the built-in dirty state already does; mirror it in any custom comparison.

Escape or Reset clears the search box, then ~200ms later the old query snaps back.

Cause. A debounced commit already in flight lands after the clear and overwrites it.

Fix. Cancel the pending debounce before committing the new value (the built-in commitNow calls commitDebounced.cancel() first) — order matters, or the superseded keystroke wins.

Row height quietly resizes every control on the whole page, not just the collection.

Cause. It was wired to the global theme density (useDensity / data-density on <html>) instead of the view's own density.

Fix. Row height maps to the active view's density (Table) / card size (Board) via the config — leave document.documentElement alone. Use the theme's density control for a page-wide change.

Accessibility

ARIA APG
None (layout container; delegates the keyboard model to each slotted control)
Focus
No focus management on the root — focus lives in the slotted controls. The two popovers (DisplayMenu, the view picker) trap and restore focus via `popover`; a saved-view delete restores focus to a neighbour before the row unmounts.

Accessibility requirements

error

The root exposes no `role=toolbar`; arrow keys do not move focus between its children, and every interactive child is reachable by Tab. A slotted `FilterBar.Chips` keeps its own `role=grid` and roving tabindex.

when always

error

`DataToolbar.Scope` exposes `role=search` with a non-empty accessible name while (and only while) it contains a registered search field; it carries no landmark role otherwise.

when the Scope zone contains a search field

warn

Zones must be placed inside a `DataToolbar.Row` (which lays them out and owns the container query). A zone placed directly in the root lays out as its own vertical band and dev-warns.

when a Scope / Display / Actions zone is rendered

warn

`Search` must sit inside a `DataToolbar.Scope`; the zone becomes a `search` landmark only when a field registers with it, so a field outside one leaves the strip with no landmark and dev-warns.

when a `DataToolbar.Search` is rendered

error

An explicit `value`/`onChange` (or `activeView`/`config`) wins and the control ignores CollectionView context — the view LIST and the active SELECTION resolve independently, so answering one explicitly does not silently take over the other.

when a control has both explicit `value`/`onChange` and a surrounding CollectionView

warn

The control renders, does nothing, and dev-warns exactly once (a `ViewSwitcher` with nothing to switch renders nothing but still warns).

when a control has neither explicit props nor a surrounding CollectionView

warn

A strip with some controls on explicit props and others reading context produces no warning — mixed mode is a supported arrangement.

when a strip mixes explicitly-wired and context-wired controls

error

Toggling any row leaves the popover open; only Escape and an outside press close it, and closing returns focus to the trigger.

when the DisplayMenu popover is open

warn

A renderer whose `requires.groupBy === 'required'` disables the 'No grouping' option and states the reason (`aria-describedby`); a renderer declaring `supports[cap] === false` removes that section entirely.

when the active renderer declares `requires` / `supports`

warn

`ViewSwitcher` renders nothing when one view (or none) is registered — a one-item segmented control offers a choice that isn't one, and `DisplayMenu.Layout` takes over as the layout control.

when exactly one view is registered

warn

Changing only the FilterModel marks `SavedView` dirty (surfacing Reset / Save) without opening `DisplayMenu`; the DisplayMenu trigger's dot tracks only the config half. `search` is transient and excluded from both the dirty diff and the saved write.

when a SavedView has an applied view

warn

Reset restores both the FilterModel and the CollectionViewConfig to the saved view in one commit; a saved view round-trips `{ filters, config }` through the `FilterViewStore` and re-applies both.

when Reset is pressed on a dirty view

error

A failed store write surfaces a `role=alert` error state rather than reporting a save that didn't happen; a Save-as that fails does not burn an id.

when a store write (save / remove) throws or rejects

warn

Row height changes the active view's own density (Table's `density`, a Board card size) and leaves `document.documentElement`'s `data-density` untouched — a per-view popover must not resize every control on the page.

when Row height is changed

warn

`CollectionViewConfig` stays JSON-serializable after every mutation, so a view is safe to put in a URL or a store (the 'no grouping' sentinel never leaves the component — `groupBy` is written as `null`).

when any DisplayMenu mutation

Keyboard

TabMove through the strip's interactive children in DOM order. The root adds NO roving tabindex and NO arrow-key handling of its own — a slotted FilterBar keeps its own `role=grid` roving, a toggle-group keeps its single-select arrows.
EscapeIn `Search`, clears the field first when non-empty (stopping propagation), so inside a dialog the first press empties the search and a second closes the dialog. In `DisplayMenu` / the view picker, closes the popover and returns focus to its trigger.
EnterIn the view picker's name field, commits a new saved view (Save as).
A1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA4.1.2Name, Role, ValueAA4.1.3Status Messages
  • `DataToolbar.Scope` becomes a `role=search` landmark with a non-empty accessible name ONLY while it contains a registered search field; role and name travel as one object, so a lone filter row never announces a phantom search landmark.
  • `DataToolbar.Display` is a `role=group` named 'Display options'; `DataToolbar.Actions` takes a `role=group` only when given a `label` — an unnamed group wrapping self-describing buttons is noise in the tree.
  • The strip carries no `role=toolbar` and no roving tabindex, so a slotted `FilterBar` keeps its own `role=grid` chip model and its own arrow keys — nesting two roving systems would break both.
  • `DisplayMenu` sections are `role=group`s named by their own heading (`aria-labelledby`); the inner toggle-group / select is NOT re-labelled, so a screen reader announces the section name once. The Group-by combobox names itself ('Group by') because a combobox must say what it sets.
  • A capability-gated 'No grouping' option is `disabled` with a stated reason wired via `aria-describedby`; a section whose `requires` capability is unsupported is removed entirely, not left inert.
  • The view switch and every apply / save / delete are announced through a shared live region (`useAnnounce`) — the surface below the strip swaps with no focus move, so nothing else would tell a screen-reader user what changed.
  • Deleting a saved view moves focus to a neighbouring delete button (or the name field when the last row goes) before the row unmounts, the same focus contract the filter chips publish.
  • Empty zones and empty bands collapse (`empty:hidden` + a has-content guard), so the a11y tree carries no empty named group and the layout reserves no phantom row.