Dashboard

A dashboard people rearrange and a model can write: the layout is JSON in cells, every place is a class, and the only thing measured is the pointer.
Import
JSX
import DashboardGrid, { Widget } from '@box-kite/react/components/dashboard';

A dashboard, and the layout behind it

A DashboardGrid takes a layout and some widgets; each Widget fills the layout item with its id. Turn edit mode on below and drag a title bar — or Tab to a handle and press Enter, which does the same thing without a mouse.

Revenue

Last 12 weeks

Conversion

Load

Orders

One bar a week
Press Enter or Space to pick the widget up, the arrow keys to move or resize it, Enter to drop it and Escape to put it back.
JSX
<DashboardGrid layout={layout} onLayoutChange={setLayout} editable columns={12}>
  <Widget id="revenue" title="Revenue" description="Last 12 weeks">
    <Sparkline data={revenue} variant="area" width="100%" height="100%" />
  </Widget>
  <Widget id="orders" title="Orders" onRefresh={reload}>
    <Sparkline data={orders} width="100%" height="100%" />
  </Widget>
</DashboardGrid>

The layout is the artifact

One version, one column count and one place per widget, in cells — plain JSON with no component and no measurement in it. It is what a model emits, what a drag reports back and what an app stores.
JSX
{
  "version": 1,
  "columns": 12,
  "items": [
    { "id": "revenue", "x": 0, "y": 0, "w": 6, "h": 2 },
    { "id": "orders",  "x": 6, "y": 0, "w": 6, "h": 2, "minW": 3 }
  ]
}
The layout is compacted upward: a widget cannot be parked in mid-air, so a drop below its neighbours rises to meet them and two dashboards holding the same widgets in the same places compare equal. A widget dropped on a neighbour takes the cell, and the neighbour is handed one of its own — the row above where there is room for it, the row below otherwise. An item may carry its own minW/minH/maxW/maxH, and fixed pins it: it is never moved, never resized, and has no handles rather than handles that refuse.

Every place is a class

A widget's cell is grid-column and grid-row — props, so they are shared classes. There is no transform per item, no ResizeObserver and no measured pixel in a resting dashboard, which is what makes the same layout render on a server. The one inline style in the component is the translate that keeps a dragged widget under the pointer: a value per frame, which a class would make a rule per frame that is never freed.
JSX
<Box gridColumnStart={7} gridColumnEnd={13} gridRowStart={1} gridRowEnd={3} />

Narrower is a projection, not a second layout

columns takes a count per container size, and the default is { xs: 1, md: 6, xxl: 12 }: one column until the dashboard is 28rem wide, six from there and twelve from 42rem — the space the layout is written in. Each narrower arrangement is the same layout projected: arithmetic done at render, written as a container query, so the browser picks between classes and nothing measures a width. The smallest count is the plain one, which is also what a container narrower than every size named gets.
JSX
<DashboardGrid columns={{ sm: 2, md: 6, xxl: 12 }} layout={layout} />
It answers to its own width rather than the page's, so a dashboard in a sidebar stacks while the same layout in the main column stays arranged. Every projection is drawn on the widest arrangement's tracks, because a grid cannot container-query itself — a track count per size resolves against an ancestor container and silently does nothing, which is a thing to know before writing cq on the element carrying container. The other consequence worth knowing: an arrangement can only be edited in the space it is written in. Where the grid is showing a projection the handles are not there at all, because an edit made in six columns is not a layout in twelve, and writing one back would quietly replace the other.

Edit mode, and the grab

editable is the whole split between looking at a dashboard and changing it: without it there are no handles and nothing in the tab order. With it, each widget has two — a grip in the title bar and a corner — and both are real buttons.
Dragging is not a keyboard gesture, so the keyboard gets a grab
Enter or Space picks the widget up, the arrows move it a cell at a time, Enter drops it and Escape puts it back — the layout with it. Every step is announced in a live region that was there before there was anything to say, and the arrows follow the reading order, so in a right-to-left page ArrowLeft moves a widget to the right. Focus never leaves the handle, so there is nothing to hand back; a grab that loses focus is cancelled rather than dropped somewhere nobody looked at.
onLayoutChange fires on every cell a drag crosses — the live value — and onLayoutCommit once when the interaction ends, which is the one to write to a server. Both carry a reason, 'move' or 'resize'; which device did it is in details.event.

A widget is chrome, and four states

loading draws bars where the content will be and reports aria-busy, error replaces the content with the message and — where there is an onRefresh — a retry, and empty says so in words rather than leaving a panel that looks broken. Anything else renders the children.

Revenue

Last 30 days
The report timed out.

Orders

Refunds

Nothing to show yet.
JSX
<Widget title="Revenue" description="Last 30 days" onRefresh={() => {}} error="The report timed out." />
The title is a real heading at level (default 3), so a dashboard has an outline rather than a page of anonymous boxes — and outside a DashboardGrid a Widget is simply a card with the same chrome.

Where a dashboard is kept is the app's decision

The grid holds no storage of its own: only the app knows whether a dashboard belongs to a person, a team or a URL. The demo above keeps its layout in localStorage, which is the whole of it — DashboardUtils.parse reads whatever comes back and reports what it could not use rather than throwing, so a layout written by an older version, or by hand, still renders.
JSX
import DashboardGrid, { DashboardUtils, Widget } from '@box-kite/react/components/dashboard';

const stored = localStorage.getItem('dashboard');
const { layout, issues } = DashboardUtils.parse(stored ? JSON.parse(stored) : null);

<DashboardGrid
  layout={layout}
  onLayoutChange={setLayout}
  onLayoutCommit={(next) => localStorage.setItem('dashboard', JSON.stringify(next))}
  editable
/>;

A layout a model writes

DashboardUtils.SCHEMA is the layout as JSON Schema — the constraint a model generates a dashboard under, in the same subset catalog() emits and <SpecRenderer> validates. The schema says the shape and parse says the sense: a generated w: 0 or a column count of 400 is clamped rather than refused, and two items claiming one id become one.
The pair this is built for
catalog() says which components a generated UI may name and what their props may be; this says where they go. Hand a model both, render what comes back with <SpecRenderer> inside the widgets, and the arrangement is still something a person can drag afterwards — which is the point of keeping the layout an artifact rather than a render.
JSX
import { DashboardUtils } from '@box-kite/react/components/dashboard';

const layout = await generate({ schema: DashboardUtils.SCHEMA, prompt });
const { layout: safe, issues } = DashboardUtils.parse(layout);

Styling

The tree is dashboard and dashboard.placeholder for the grid, and widget with header, label, title, description, actions, handle, body, message, retry and skeleton under it. Both handles are the one handle node — the corner is its corner variant — and the row height is a variable rather than a prop, so it can be set per breakpoint or per theme like any other value.
JSX
Box.components({
  widget: { styles: { borderRadius: 4, shadow: 'medium' } },
  dashboard: { styles: { gap: 6, vars: { 'dashboard-row': '8rem' } } },
});

DashboardGrid props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
childrenReactNode—The widgets. Each <Widget id> is placed by the layout item of that id; one with no item is left to flow.
layoutDashboardLayout—Controlled layout. Leave it out and the grid owns it.
defaultLayoutDashboardLayout—What it starts as. The layout is normalized on the way in, so a generated one need not be tidy.
onLayoutChangeChangeHandler<DashboardLayout, DashboardReason>—Fires on every cell a drag crosses and every arrow key, with the whole normalized layout.
onLayoutCommitChangeHandler<DashboardLayout, DashboardReason>—Fires once when the interaction ends — the place for the write a per-cell callback is too noisy for.
columnsDashboardColumns{ xs: 1, md: 6, xxl: 12 }How many columns, at one width or at several: 12, or { xs: 1, md: 6, xxl: 12 } — the default — which is one column until the dashboard is 28rem wide, six from there and twelve from 42rem. Each is a projection of the one layout, computed rather than measured, so the browser picks between classes; the smallest count is also what a container narrower than every size named here gets.
rowHeightnumber24The height of one row, on the ÷4 spacing scale. Default 24 — 6rem.
editablebooleanfalseWhether widgets can be moved and resized. The handles exist only in edit mode, and only where the layout's own columns are being rendered.
labelstring—What the set of widgets is called. A list needs a name where a page holds more than one.
labelledBystring—The same, naming an element that already says it.

DashboardGrid keyboard

KeyResult
Enter, SpacePicks a widget up from either handle, and drops it again.
ArrowsOne cell, along the reading axis: in a right-to-left page ArrowLeft moves a widget to the *right*. On the resize handle they add and remove a column or a row.
EscapePuts the widget back where it was picked up from, and the layout with it.

DashboardGrid accessibility

  • The grid is a role="list" named by label, and each placed widget a role="listitem" — so a reader is told how many widgets there are and which one this is, which a set of region landmarks would drown out.
  • Moving and resizing are a **grab**: Enter or Space on a handle picks the widget up, the arrows move it, Enter drops it and Escape puts it back. Every step is announced in a polite live region that is there before there is anything to say.
  • The handles are real buttons with aria-pressed for the grabbed state and an aria-describedby naming the instructions, rather than an HTML5 drag, which no screen reader drives.
Swept with axe on every release, in this state: DashboardGrid (editable). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

DashboardGrid style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
dashboardvariants: dragging
dashboard.placeholder

Widget props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
idstring—Which layout item this widget is. Left out, it is chrome with no place — a card.
childrenReactNode—What it shows.
titleReactNode—The title bar's heading.
namestring—What the widget is called where a control has to say so — the handles' labels and the announcements. Defaults to a string title, then to id.
descriptionReactNode—A line under the title, for the question the widget answers rather than the noun it is named after.
actionsReactNode—The end of the title bar: a menu, a filter, a link out.
level2 | 3 | 4 | 5 | 63The heading level the title renders at. Default 3, since a dashboard is a section of a page.
loadingbooleanfalseBars where the content will be, and aria-busy while they are showing.
errorReactNode—What went wrong, shown in place of the content. With onRefresh it comes with a retry.
emptyboolean | ReactNode—There is nothing to show: true for the default line, or the words to use instead.
onRefresh() => void—Fetch it again. A button in the title bar, and the retry an error state offers.
refreshLabelstring—The refresh button's accessible name. Default Refresh <name>.

Widget accessibility

  • The title is a real heading at level (default 3), so a dashboard has an outline rather than a page of anonymous boxes, and the widget is named by it.
  • aria-busy while it is loading, so a reader is told the panel is not finished rather than reading the skeleton out as content.
  • An error is a role="status": it arrives after the page settled, and interrupting is for something the person did rather than something that failed on their behalf.
Swept with axe on every release, in this state: Widget (error and empty). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

Widget style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
widgetvariants: dragging, grabbed
widget.header
widget.label
widget.title
widget.description
widget.actions
widget.handlevariants: editable, grabbed, corner
widget.body
widget.messagevariants: error
widget.retry
widget.skeleton
widget.skeleton.bar