# Dashboard

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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.

Edit mode

### 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.

```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](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | The widgets. Each `<Widget id>` is placed by the layout item of that id; one with no item is left to flow. |
| `layout` | `DashboardLayout` | — | Controlled layout. Leave it out and the grid owns it. |
| `defaultLayout` | `DashboardLayout` | — | What it starts as. The layout is normalized on the way in, so a generated one need not be tidy. |
| `onLayoutChange` | `ChangeHandler<DashboardLayout, DashboardReason>` | — | Fires on every cell a drag crosses and every arrow key, with the whole normalized layout. |
| `onLayoutCommit` | `ChangeHandler<DashboardLayout, DashboardReason>` | — | Fires once when the interaction ends — the place for the write a per-cell callback is too noisy for. |
| `columns` | `DashboardColumns` | `{ 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. |
| `rowHeight` | `number` | `24` | The height of one row, on the ÷4 spacing scale. Default `24` — 6rem. |
| `editable` | `boolean` | `false` | Whether widgets can be moved and resized. The handles exist only in edit mode, and only where the layout's own columns are being rendered. |
| `label` | `string` | — | What the set of widgets is called. A list needs a name where a page holds more than one. |
| `labelledBy` | `string` | — | The same, naming an element that already says it. |

## DashboardGrid keyboard

| Key | Result |
| --- | --- |
| `Enter, Space` | Picks a widget up from either handle, and drops it again. |
| `Arrows` | One 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. |
| `Escape` | Puts 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.

`dashboard`variants: 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](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `id` | `string` | — | Which layout item this widget is. Left out, it is chrome with no place — a card. |
| `children` | `ReactNode` | — | What it shows. |
| `title` | `ReactNode` | — | The title bar's heading. |
| `name` | `string` | — | 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`. |
| `description` | `ReactNode` | — | A line under the title, for the question the widget answers rather than the noun it is named after. |
| `actions` | `ReactNode` | — | The end of the title bar: a menu, a filter, a link out. |
| `level` | `2 \| 3 \| 4 \| 5 \| 6` | `3` | The heading level the title renders at. Default `3`, since a dashboard is a section of a page. |
| `loading` | `boolean` | `false` | Bars where the content will be, and `aria-busy` while they are showing. |
| `error` | `ReactNode` | — | What went wrong, shown in place of the content. With `onRefresh` it comes with a retry. |
| `empty` | `boolean \| 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. |
| `refreshLabel` | `string` | — | 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.

`widget`variants: dragging, grabbed

`widget.header`

`widget.label`

`widget.title`

`widget.description`

`widget.actions`

`widget.handle`variants: editable, grabbed, corner

`widget.body`

`widget.message`variants: error

`widget.retry`

`widget.skeleton`

`widget.skeleton.bar`

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

