@box-kite/react 2.2.4 → 2.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/box-kite-rules.md +11 -1
- package/.claude/skills/box-kite/SKILL.md +11 -1
- package/.claude/skills/box-kite/references/components.md +83 -3
- package/.cursor/rules/box-kite.mdc +11 -1
- package/AGENTS.md +11 -1
- package/BOX_AI_CONTEXT.md +371 -25
- package/BOX_KITE_AI_CONTEXT.md +371 -25
- package/behavior.cjs +1 -1
- package/behavior.mjs +57 -57
- package/box.cjs +1 -1
- package/box.d.ts +5 -0
- package/box.mjs +15 -15
- package/catalog.cjs +1 -1
- package/catalog.mjs +906 -0
- package/client.cjs +1 -1
- package/client.mjs +94 -80
- package/components/calendar.cjs +1 -0
- package/components/calendar.d.ts +77 -0
- package/components/calendar.mjs +483 -0
- package/components/dashboard.cjs +1 -1
- package/components/dashboard.mjs +1 -1
- package/components/dataGrid/components/dataGridBooleanValue.d.ts +11 -0
- package/components/dataGrid/components/dataGridDateParts.d.ts +40 -0
- package/components/dataGrid/components/dataGridDateValue.d.ts +14 -0
- package/components/dataGrid/components/dataGridList.d.ts +12 -0
- package/components/dataGrid/components/dataGridListAggregate.d.ts +7 -0
- package/components/dataGrid/components/dataGridListFilter.d.ts +7 -0
- package/components/dataGrid/components/dataGridListFilterSheet.d.ts +11 -0
- package/components/dataGrid/components/dataGridListFooter.d.ts +13 -0
- package/components/dataGrid/components/dataGridListGroup.d.ts +13 -0
- package/components/dataGrid/components/dataGridListItem.d.ts +21 -0
- package/components/dataGrid/components/dataGridListLayout.d.ts +20 -0
- package/components/dataGrid/components/dataGridListRecord.d.ts +11 -0
- package/components/dataGrid/components/dataGridListSelectionBar.d.ts +11 -0
- package/components/dataGrid/components/dataGridListSheet.d.ts +30 -0
- package/components/dataGrid/components/dataGridListSortSheet.d.ts +10 -0
- package/components/dataGrid/components/dataGridListToolbar.d.ts +10 -0
- package/components/dataGrid/components/dataGridListValue.d.ts +12 -0
- package/components/dataGrid/contracts/dataGridContract.d.ts +70 -11
- package/components/dataGrid/models/columnModel.d.ts +3 -1
- package/components/dataGrid/models/editModel.d.ts +3 -1
- package/components/dataGrid/models/gridModel.d.ts +3 -0
- package/components/dataGrid/models/listModel.d.ts +129 -0
- package/components/dataGrid/models/localeText.d.ts +36 -0
- package/components/dataGrid/models/viewportModel.d.ts +0 -2
- package/components/dataGrid/useDateParts.d.ts +17 -0
- package/components/dataGrid/useListLayout.d.ts +13 -0
- package/components/dataGrid.cjs +3 -3
- package/components/dataGrid.mjs +1251 -854
- package/components/dateField.cjs +1 -0
- package/components/dateField.d.ts +78 -0
- package/components/dateField.mjs +313 -0
- package/components/datePicker.cjs +1 -0
- package/components/datePicker.d.ts +120 -0
- package/components/datePicker.mjs +529 -0
- package/components/dateTime.cjs +1 -0
- package/components/dateTime.d.ts +23 -0
- package/components/dateTime.mjs +34 -0
- package/components/dialog.cjs +1 -1
- package/components/dialog.mjs +29 -29
- package/components/list/listItemParts.d.ts +9 -0
- package/components/list.cjs +1 -0
- package/components/list.d.ts +118 -0
- package/components/list.mjs +391 -0
- package/components/menu.cjs +1 -1
- package/components/menu.mjs +25 -25
- package/components/overlay.cjs +1 -1
- package/components/overlay.mjs +12 -12
- package/components/semantics.d.ts +29 -29
- package/components/slider.cjs +1 -1
- package/components/slider.mjs +1 -1
- package/components/sortableList.cjs +1 -0
- package/components/sortableList.d.ts +49 -0
- package/components/sortableList.mjs +384 -0
- package/components/swipeItem.cjs +1 -0
- package/components/swipeItem.d.ts +52 -0
- package/components/swipeItem.mjs +321 -0
- package/components/tabs.cjs +1 -1
- package/components/tabs.mjs +1 -1
- package/components/timeZoneSelect.cjs +1 -0
- package/components/timeZoneSelect.d.ts +52 -0
- package/components/timeZoneSelect.mjs +74 -0
- package/components/toaster.cjs +1 -1
- package/components/toaster.mjs +24 -24
- package/components/virtualList.cjs +1 -0
- package/components/virtualList.d.ts +32 -0
- package/components/virtualList.mjs +188 -0
- package/dataGridDateParts.cjs +1 -0
- package/dataGridDateParts.mjs +223 -0
- package/dataGridList.cjs +1 -0
- package/dataGridList.mjs +1314 -0
- package/dataGridListLayout.cjs +1 -0
- package/dataGridListLayout.mjs +49 -0
- package/date-environment.cjs +1 -0
- package/date-environment.mjs +29 -0
- package/date-input.cjs +1 -0
- package/date-input.mjs +346 -0
- package/date-math.cjs +1 -0
- package/date-math.mjs +317 -0
- package/date.cjs +1 -0
- package/date.d.ts +40 -0
- package/date.mjs +4 -0
- package/dates.cjs +1 -0
- package/dates.mjs +689 -0
- package/docs/components.md +10 -1
- package/icons/closeIcon.d.ts +2 -0
- package/icons/filterIcon.d.ts +2 -0
- package/layer.cjs +1 -1
- package/layer.mjs +1 -1
- package/mcp/src/data.d.ts +2 -0
- package/package.json +178 -22
- package/placement.cjs +1 -1
- package/placement.mjs +10 -10
- package/platform.cjs +1 -1
- package/platform.mjs +4 -1
- package/react/a11y/useControllableState.d.ts +2 -1
- package/react/boxComponent.d.ts +1 -1
- package/react/componentsContext.d.ts +4 -0
- package/react/componentsProvider.d.ts +10 -0
- package/react/date/dateChange.d.ts +18 -0
- package/react/date/dateEnvironment.d.ts +34 -0
- package/react/resolveStyles.d.ts +2 -1
- package/react/theme/theme.d.ts +6 -1
- package/react-shared.cjs +1 -1
- package/react-shared.mjs +6 -6
- package/registry/blocks/dashboard-shell/dashboard-shell.d.ts +3 -1
- package/registry/designs/store/address-form.d.ts +11 -0
- package/registry/designs/store/cart-items.d.ts +10 -0
- package/registry/designs/store/cart-page.d.ts +4 -0
- package/registry/designs/store/checkout-page.d.ts +7 -0
- package/registry/designs/store/credit-card-form.d.ts +10 -0
- package/registry/designs/store/form-field.d.ts +19 -0
- package/registry/designs/store/order-summary.d.ts +18 -0
- package/registry/designs/store/product-art.d.ts +26 -0
- package/registry/designs/store/product-card.d.ts +11 -0
- package/registry/designs/store/product-gallery.d.ts +11 -0
- package/registry/designs/store/product-grid.d.ts +10 -0
- package/registry/designs/store/product-page.d.ts +5 -0
- package/registry/designs/store/quantity-stepper.d.ts +13 -0
- package/registry/designs/store/shipping-method.d.ts +14 -0
- package/registry/designs/store/store-data.d.ts +53 -0
- package/registry/designs/store/store-design.d.ts +63 -0
- package/registry/designs/store/store-shell.d.ts +12 -0
- package/registry/designs/store/storefront-page.d.ts +4 -0
- package/registry/designs/store/variant-picker.d.ts +15 -0
- package/rsc.cjs +1 -1
- package/rsc.d.ts +2 -0
- package/rsc.mjs +1 -1
- package/scrolling.cjs +1 -0
- package/scrolling.mjs +22 -0
- package/utils/date/calendar.d.ts +44 -0
- package/utils/date/calendarModel.d.ts +46 -0
- package/utils/date/dateTime.d.ts +144 -0
- package/utils/date/fieldModel.d.ts +74 -0
- package/utils/date/format.d.ts +39 -0
- package/utils/date/iso.d.ts +40 -0
- package/utils/date/locale.d.ts +17 -0
- package/utils/date/parse.d.ts +20 -0
- package/utils/date/settings.d.ts +26 -0
- package/utils/date/value.d.ts +43 -0
- package/utils/date/zoneOptions.d.ts +19 -0
- package/utils/date/zones.d.ts +32 -0
- package/utils/scroll/scrollUtils.d.ts +6 -0
- package/utils/sortable/sortableUtils.d.ts +25 -0
- package/utils/swipe/swipeUtils.d.ts +53 -0
- package/utils/virtual/measuredWindow.d.ts +28 -0
- package/utils/virtual/rowSearch.d.ts +11 -0
|
@@ -60,7 +60,7 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
60
60
|
|
|
61
61
|
36. **A message you send rather than render is `<Toaster>` plus `toast()`.** `<Toaster />` once near the root of the app (`components/toaster`), and after that `toast.success('Saved')` from anywhere at all — an event handler, a fetch, a router guard, a module with no React in it. The store behind it is framework-free, so a call made **before** the viewport mounts is queued rather than lost, and the component only draws what is there. The surface is `toast(message, options)` with `success`/`error`/`warning`/`info`/`loading` beside it, `toast.promise(p, { loading, success, error })` (one toast for the whole call, and the promise comes back untouched), `toast.update(id, message, options)` and `toast.dismiss(id?)`; an `options.id` already on screen is an **update**, which is what makes a promise one toast rather than two. A toast takes a `description`, one `action` (`{ label, onClick }`, which dismisses the toast it answered unless `closeOnClick: false`), a `duration` in milliseconds (`Infinity` waits, and `loading` already does), and `onDismiss(reason)` with `timeout`/`close`/`action`/`imperative`. **The limit is a queue, not a cap**: past `limit` (default 3) a toast waits **with its timer unstarted**, so nothing expires that was never on screen, and a counter at the far end of the stack says how many are waiting. **Four things to know, all measured in Chrome 153.** The viewport is a **polite live region that exists before there is anything in it** — a region inserted together with its content is not reliably announced — and an error toast is `role="alert"`, the one announcement pattern every screen reader implements; nothing else carries a region of its own, because the nearest region to a change is the one that speaks. It is in the **top layer** with no portal (`popover="manual"`, shown as it mounts), so it inherits the theme and the direction around it, and it takes **no pointer events at all** while the toasts take them back — a corner-sized fixed strip would otherwise swallow every press in its gaps. **Timers stop on hover, on focus and in a background tab** and pick up where they left off, which is WCAG 2.2.1 and the only reason a toast may carry a control at all; a toast **never takes focus**, so `F6` (or `hotkey`, which takes `'alt+t'` or `false`) is what moves focus to the stack, Tab walks it, and Escape dismisses the toast focus is in and hands focus back once the stack is empty. And the viewport has to answer the UA's own `[popover]` rule: `inset: 0` leaves a corner-pinned stack over-constrained — `top`/`left` win and it sits in the wrong corner — and `overflow: auto` makes it a scroll container, so the style tree declares all four sides `auto` and `overflow: visible` before a `position` variant names the two it wants. `position` is `'bottom-end'` by default and its inline half is logical (`start`/`end`), the newest toast is always nearest the screen edge, and styling is the `toaster`/`toaster.toast`/`toaster.message`/`toaster.description`/`toaster.action`/`toaster.close`/`toaster.overflow` tree, where the kind is a variant on the toast.
|
|
62
62
|
|
|
63
|
-
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox
|
|
63
|
+
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox` (and is drawn as a check or a cross), an ISO date, time or date-time a `DateField` (an edit is written back in the shape the cell held: `…Z` stays UTC, a `Date` stays a `Date`), everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the seven (`text`/`number`/`checkbox`/`select`/`date`/`time`/`datetime`) or configures it (`{ type, options?, placeholder?, step?, min?, max?, zone? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
|
|
64
64
|
|
|
65
65
|
38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
|
|
66
66
|
|
|
@@ -84,4 +84,14 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
84
84
|
|
|
85
85
|
48. **In development the console is part of the answer: read it.** Three mistakes write nothing, or the wrong thing, and say so in one `[box-kite]` warning that names what rendered and the line to write instead. **A value a prop does not take** — `bgColor="bleu-500"` → `wrote no CSS … Did you mean "blue-500"?`, `opacity={50}` → `It takes one of 0, 0.1, …`. **An HTML attribute at the top level** — `href`, `onClick`, `data-*`, `aria-*` → `write props={{ href: ... }}`. **A number in a unit it almost certainly did not mean**, which is valid CSS and so the one mistake nothing else catches: `fontSize={4}` is 4px text (`For 16px write fontSize={16}`), `lineHeight={1.5}` a 1.5px line, `transitionDuration={0.3}` 0.3ms, `brightness={1.1}` 1.1%. Each prints once per value, in any environment whose `NODE_ENV` is not `production` — a test run included, which is the cheapest place to see them — and never in a production build, where a rejected value is still dropped with nothing said. `Box.configure({ warnings: false })` turns them off; `check_styles` in `@box-kite/mcp` gives the same three verdicts before anything renders.
|
|
86
86
|
|
|
87
|
+
49. **A design that stops at a subtree is `Box.createComponents()`, not `Box.components()`.** `Box.components()` is one registry for the whole app: a second call merges into the first, so two designs cannot share a page through it. `const aurora = Box.createComponents({ button: { styles: { borderRadius: 999 } } })` takes the same shape and registers nothing, and `<Box.Components value={aurora}>` — or `<Box.Theme use="local" components={aurora}>`, a design in light and dark in one wrapper — applies it to every Box inside, pre-built components included. `base` is what it merges over: `'global'` (default, the app's own `Box.components()`), `'builtin'` (ignores them) or another registry. Props still beat the design. It is identified by its content, so an inline one costs no cache, but declare it at module scope. The provider renders no element and a Server Component cannot read it. Two designs that both name `button` type as `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}`.
|
|
88
|
+
|
|
89
|
+
50. **A list of things to read and pick from is `<List>`, and one action covers each item.** `<List><List.Item chevron><List.Media variant="avatar">AL</List.Media><List.Content><List.Row><List.Title lines={1}><List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action></List.Title><List.Meta>9:41</List.Meta></List.Row><List.Description lines={2}>…</List.Description></List.Content></List.Item></List>` (`components/list`). **An item is a flex row of three slots** — `List.Media` leading (`variant` `icon`/`avatar`/`image`), `List.Content` taking the room, `List.End` trailing (a value over its label, aligned to the end) — and the content is a column of `List.Row`s, each its parts side by side with the last pushed to the end: a main row (the title and its time) and a `secondary` one under it (smaller, muted). `wrap` on a row or an item lets the parts wrap instead of squeezing; `List.Overline`/`List.Title`/`List.Description` take `lines={1|2|3}` (an ellipsis, then a clamp); `List.Meta` is a small value that never wraps; `List.Fields` + `List.Field label` is a `<dl>` of labelled values; `List.Group label level sticky` is a section with a nested list of its own. **`List.Action` goes in the title and its `::after` covers the item**, so the whole row presses while the name a reader hears is the title alone — an `href` in `props` makes it an `<a>`, an `htmlFor` a `<label>` (a press anywhere ticks that checkbox), neither a `<button>` with a top-level `onClick`; a control of the item's own (a switch, a menu, that checkbox) goes in `List.Actions`, which sits above it. **Four things to know.** It renders on a server — no hook, no context — so the list's look reaches its items as **custom properties**: `variant="plain"|"card"|"separated"` on `List`, and `vars={{ 'list-divider': …, 'list-divider-inset': '4.5rem', 'list-item-radius': …, 'list-sticky-top': '4rem' }}` to tune it. The style tree is **paint only** (`list`, `list.item`, `list.media`, `list.muted`, `list.action`, `list.label`), the hairlines and tints mixed from `currentColor`; the spacing is each part's default props, so `<List.Item py={1}>` wins and `Box.components` cannot move it. Hover, pressed, `aria-current` and disabled are read off the action with `:has()` — no prop — and `selected` on an item is a tint only, the state a reader hears belonging on the control. And it is a `<ul role="list">` (Safari drops the semantics otherwise; `tag="ol"` for an ordered one), **not a composite widget**: every action is a tab stop and the arrows stay the page's — arrow-key selection among options is a listbox, `Combobox` or `Select`.
|
|
90
|
+
|
|
91
|
+
51. **On a phone a DataGrid is a list, and a column says where it goes in an item.** Below `def.list.breakpoint` (576px) of **the grid's own width** — not the viewport's — `layout: 'auto'` (the default) draws each row as a list item: `list: 'title'` is its name and the button that opens it, `'kicker'` a small line above, `'subtitle'` a line under, `'end'` the one value opposite the title with its column's name under it, `'field'` a labelled value, `'detail'` the record view only, `'hidden'` nowhere. Left unset, the first column is the title, the first end-aligned one the end, the next four fields (`def.list.fields`) and the rest detail. A press opens the **record view** — every column of one row in a bottom sheet with a pager (buttons, Left/Right, a swipe) that follows the list's sort and filters, and an **Edit** button on each editable column through the same `onCellEdit`. Sort and filters are bottom sheets behind two pills, the filters in force are removable chips, **Select** is a mode (a press ticks instead of opening, and a bar pinned to the bottom counts the selection), and the list grows twenty a **Show more** — a button, so the totals and the page below stay reachable — or pages when the host paginates. **Four things to know.** It is a `<List>` (rule 50) — each row a `List.Item` whose title is the `List.Action` — not a restyled `role="grid"`, so the `list` tree styles its rows. It is a chunk of its own, loaded the first time a grid is narrow, and the grid draws its table until it arrives — in a test, `await loadListLayout()` first. Unmeasured is a table, so a server prerenders the table and `layout: 'list'` is how to say otherwise. And the selection bar sticks to the viewport, so no ancestor may be `overflow: hidden`/`auto` — `css={{ overflow: 'clip' }}` clips without being a scroll container. `layout: 'table'` keeps the table at every width.
|
|
92
|
+
|
|
93
|
+
52. **Swiping, reordering and windowing a list are three client entries on top of `List`.** `<SwipeItem endActions={<SwipeItem.Action variant="danger" onClick={remove}>Delete</SwipeItem.Action>} fullSwipe>` (`components/swipeItem`) is a `List.Item` — same parts, `size`, `chevron`, `selected` — whose row slides to open a tray; the first action of each tray is the outermost, and `fullSwipe` performs it by clicking it, so its `onClick` is where the command lives. The actions are tab stops and focus opens their tray, but a swipe is a path gesture: put the same commands where a press reaches too. `<SortableList onOrderChange={(order) => setIds(order)}>` (`components/sortableList`) with `<SortableList.Item value={id} label={name}>` and a `<SortableList.Handle />` in each: **the app owns the order** and renders it, `onOrderChange` fires once per drop and per keyboard step, `onOrderCommit` when it settles; the keyboard grabs (Enter/Space, arrows, Escape puts back), and a press on a handle then a press on another item drops it there. `<VirtualList items={rows} itemKey={(row) => row.id}>{(row) => <List.Item>…</List.Item>}</VirtualList>` (`components/virtualList`) renders a window, measuring each item by key; the scroller is the nearest scrolling ancestor or the page, `scrollerRef` when that is wrong, and `onRangeChange` fetches the next page. **Three things to know.** The children function returns exactly one `<li>` per item and nothing around it, since the list measures its children. A windowed list has no `List.Group`s and find-in-page cannot see what is not rendered. And a `SwipeItem` windows like any item, but a `SortableList` holds its whole list: reorder a list short enough to render, window one too long to.
|
|
94
|
+
|
|
95
|
+
53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
|
|
96
|
+
|
|
87
97
|
Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
|
|
@@ -133,7 +133,7 @@ Detect the package manager from the lock file rather than asking:
|
|
|
133
133
|
|
|
134
134
|
36. **A message you send rather than render is `<Toaster>` plus `toast()`.** `<Toaster />` once near the root of the app (`components/toaster`), and after that `toast.success('Saved')` from anywhere at all — an event handler, a fetch, a router guard, a module with no React in it. The store behind it is framework-free, so a call made **before** the viewport mounts is queued rather than lost, and the component only draws what is there. The surface is `toast(message, options)` with `success`/`error`/`warning`/`info`/`loading` beside it, `toast.promise(p, { loading, success, error })` (one toast for the whole call, and the promise comes back untouched), `toast.update(id, message, options)` and `toast.dismiss(id?)`; an `options.id` already on screen is an **update**, which is what makes a promise one toast rather than two. A toast takes a `description`, one `action` (`{ label, onClick }`, which dismisses the toast it answered unless `closeOnClick: false`), a `duration` in milliseconds (`Infinity` waits, and `loading` already does), and `onDismiss(reason)` with `timeout`/`close`/`action`/`imperative`. **The limit is a queue, not a cap**: past `limit` (default 3) a toast waits **with its timer unstarted**, so nothing expires that was never on screen, and a counter at the far end of the stack says how many are waiting. **Four things to know, all measured in Chrome 153.** The viewport is a **polite live region that exists before there is anything in it** — a region inserted together with its content is not reliably announced — and an error toast is `role="alert"`, the one announcement pattern every screen reader implements; nothing else carries a region of its own, because the nearest region to a change is the one that speaks. It is in the **top layer** with no portal (`popover="manual"`, shown as it mounts), so it inherits the theme and the direction around it, and it takes **no pointer events at all** while the toasts take them back — a corner-sized fixed strip would otherwise swallow every press in its gaps. **Timers stop on hover, on focus and in a background tab** and pick up where they left off, which is WCAG 2.2.1 and the only reason a toast may carry a control at all; a toast **never takes focus**, so `F6` (or `hotkey`, which takes `'alt+t'` or `false`) is what moves focus to the stack, Tab walks it, and Escape dismisses the toast focus is in and hands focus back once the stack is empty. And the viewport has to answer the UA's own `[popover]` rule: `inset: 0` leaves a corner-pinned stack over-constrained — `top`/`left` win and it sits in the wrong corner — and `overflow: auto` makes it a scroll container, so the style tree declares all four sides `auto` and `overflow: visible` before a `position` variant names the two it wants. `position` is `'bottom-end'` by default and its inline half is logical (`start`/`end`), the newest toast is always nearest the screen edge, and styling is the `toaster`/`toaster.toast`/`toaster.message`/`toaster.description`/`toaster.action`/`toaster.close`/`toaster.overflow` tree, where the kind is a variant on the toast.
|
|
135
135
|
|
|
136
|
-
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox
|
|
136
|
+
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox` (and is drawn as a check or a cross), an ISO date, time or date-time a `DateField` (an edit is written back in the shape the cell held: `…Z` stays UTC, a `Date` stays a `Date`), everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the seven (`text`/`number`/`checkbox`/`select`/`date`/`time`/`datetime`) or configures it (`{ type, options?, placeholder?, step?, min?, max?, zone? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
|
|
137
137
|
|
|
138
138
|
38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
|
|
139
139
|
|
|
@@ -157,4 +157,14 @@ Detect the package manager from the lock file rather than asking:
|
|
|
157
157
|
|
|
158
158
|
48. **In development the console is part of the answer: read it.** Three mistakes write nothing, or the wrong thing, and say so in one `[box-kite]` warning that names what rendered and the line to write instead. **A value a prop does not take** — `bgColor="bleu-500"` → `wrote no CSS … Did you mean "blue-500"?`, `opacity={50}` → `It takes one of 0, 0.1, …`. **An HTML attribute at the top level** — `href`, `onClick`, `data-*`, `aria-*` → `write props={{ href: ... }}`. **A number in a unit it almost certainly did not mean**, which is valid CSS and so the one mistake nothing else catches: `fontSize={4}` is 4px text (`For 16px write fontSize={16}`), `lineHeight={1.5}` a 1.5px line, `transitionDuration={0.3}` 0.3ms, `brightness={1.1}` 1.1%. Each prints once per value, in any environment whose `NODE_ENV` is not `production` — a test run included, which is the cheapest place to see them — and never in a production build, where a rejected value is still dropped with nothing said. `Box.configure({ warnings: false })` turns them off; `check_styles` in `@box-kite/mcp` gives the same three verdicts before anything renders.
|
|
159
159
|
|
|
160
|
+
49. **A design that stops at a subtree is `Box.createComponents()`, not `Box.components()`.** `Box.components()` is one registry for the whole app: a second call merges into the first, so two designs cannot share a page through it. `const aurora = Box.createComponents({ button: { styles: { borderRadius: 999 } } })` takes the same shape and registers nothing, and `<Box.Components value={aurora}>` — or `<Box.Theme use="local" components={aurora}>`, a design in light and dark in one wrapper — applies it to every Box inside, pre-built components included. `base` is what it merges over: `'global'` (default, the app's own `Box.components()`), `'builtin'` (ignores them) or another registry. Props still beat the design. It is identified by its content, so an inline one costs no cache, but declare it at module scope. The provider renders no element and a Server Component cannot read it. Two designs that both name `button` type as `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}`.
|
|
161
|
+
|
|
162
|
+
50. **A list of things to read and pick from is `<List>`, and one action covers each item.** `<List><List.Item chevron><List.Media variant="avatar">AL</List.Media><List.Content><List.Row><List.Title lines={1}><List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action></List.Title><List.Meta>9:41</List.Meta></List.Row><List.Description lines={2}>…</List.Description></List.Content></List.Item></List>` (`components/list`). **An item is a flex row of three slots** — `List.Media` leading (`variant` `icon`/`avatar`/`image`), `List.Content` taking the room, `List.End` trailing (a value over its label, aligned to the end) — and the content is a column of `List.Row`s, each its parts side by side with the last pushed to the end: a main row (the title and its time) and a `secondary` one under it (smaller, muted). `wrap` on a row or an item lets the parts wrap instead of squeezing; `List.Overline`/`List.Title`/`List.Description` take `lines={1|2|3}` (an ellipsis, then a clamp); `List.Meta` is a small value that never wraps; `List.Fields` + `List.Field label` is a `<dl>` of labelled values; `List.Group label level sticky` is a section with a nested list of its own. **`List.Action` goes in the title and its `::after` covers the item**, so the whole row presses while the name a reader hears is the title alone — an `href` in `props` makes it an `<a>`, an `htmlFor` a `<label>` (a press anywhere ticks that checkbox), neither a `<button>` with a top-level `onClick`; a control of the item's own (a switch, a menu, that checkbox) goes in `List.Actions`, which sits above it. **Four things to know.** It renders on a server — no hook, no context — so the list's look reaches its items as **custom properties**: `variant="plain"|"card"|"separated"` on `List`, and `vars={{ 'list-divider': …, 'list-divider-inset': '4.5rem', 'list-item-radius': …, 'list-sticky-top': '4rem' }}` to tune it. The style tree is **paint only** (`list`, `list.item`, `list.media`, `list.muted`, `list.action`, `list.label`), the hairlines and tints mixed from `currentColor`; the spacing is each part's default props, so `<List.Item py={1}>` wins and `Box.components` cannot move it. Hover, pressed, `aria-current` and disabled are read off the action with `:has()` — no prop — and `selected` on an item is a tint only, the state a reader hears belonging on the control. And it is a `<ul role="list">` (Safari drops the semantics otherwise; `tag="ol"` for an ordered one), **not a composite widget**: every action is a tab stop and the arrows stay the page's — arrow-key selection among options is a listbox, `Combobox` or `Select`.
|
|
163
|
+
|
|
164
|
+
51. **On a phone a DataGrid is a list, and a column says where it goes in an item.** Below `def.list.breakpoint` (576px) of **the grid's own width** — not the viewport's — `layout: 'auto'` (the default) draws each row as a list item: `list: 'title'` is its name and the button that opens it, `'kicker'` a small line above, `'subtitle'` a line under, `'end'` the one value opposite the title with its column's name under it, `'field'` a labelled value, `'detail'` the record view only, `'hidden'` nowhere. Left unset, the first column is the title, the first end-aligned one the end, the next four fields (`def.list.fields`) and the rest detail. A press opens the **record view** — every column of one row in a bottom sheet with a pager (buttons, Left/Right, a swipe) that follows the list's sort and filters, and an **Edit** button on each editable column through the same `onCellEdit`. Sort and filters are bottom sheets behind two pills, the filters in force are removable chips, **Select** is a mode (a press ticks instead of opening, and a bar pinned to the bottom counts the selection), and the list grows twenty a **Show more** — a button, so the totals and the page below stay reachable — or pages when the host paginates. **Four things to know.** It is a `<List>` (rule 50) — each row a `List.Item` whose title is the `List.Action` — not a restyled `role="grid"`, so the `list` tree styles its rows. It is a chunk of its own, loaded the first time a grid is narrow, and the grid draws its table until it arrives — in a test, `await loadListLayout()` first. Unmeasured is a table, so a server prerenders the table and `layout: 'list'` is how to say otherwise. And the selection bar sticks to the viewport, so no ancestor may be `overflow: hidden`/`auto` — `css={{ overflow: 'clip' }}` clips without being a scroll container. `layout: 'table'` keeps the table at every width.
|
|
165
|
+
|
|
166
|
+
52. **Swiping, reordering and windowing a list are three client entries on top of `List`.** `<SwipeItem endActions={<SwipeItem.Action variant="danger" onClick={remove}>Delete</SwipeItem.Action>} fullSwipe>` (`components/swipeItem`) is a `List.Item` — same parts, `size`, `chevron`, `selected` — whose row slides to open a tray; the first action of each tray is the outermost, and `fullSwipe` performs it by clicking it, so its `onClick` is where the command lives. The actions are tab stops and focus opens their tray, but a swipe is a path gesture: put the same commands where a press reaches too. `<SortableList onOrderChange={(order) => setIds(order)}>` (`components/sortableList`) with `<SortableList.Item value={id} label={name}>` and a `<SortableList.Handle />` in each: **the app owns the order** and renders it, `onOrderChange` fires once per drop and per keyboard step, `onOrderCommit` when it settles; the keyboard grabs (Enter/Space, arrows, Escape puts back), and a press on a handle then a press on another item drops it there. `<VirtualList items={rows} itemKey={(row) => row.id}>{(row) => <List.Item>…</List.Item>}</VirtualList>` (`components/virtualList`) renders a window, measuring each item by key; the scroller is the nearest scrolling ancestor or the page, `scrollerRef` when that is wrong, and `onRangeChange` fetches the next page. **Three things to know.** The children function returns exactly one `<li>` per item and nothing around it, since the list measures its children. A windowed list has no `List.Group`s and find-in-page cannot see what is not rendered. And a `SwipeItem` windows like any item, but a `SortableList` holds its whole list: reorder a list short enough to render, window one too long to.
|
|
167
|
+
|
|
168
|
+
53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
|
|
169
|
+
|
|
160
170
|
Full reference: `references/` beside this file, and `docs/props.md` inside the package.
|
|
@@ -18,6 +18,7 @@ Which component replaces which `<Box tag>`, and the three that carry a pattern o
|
|
|
18
18
|
| a menu button and its menu | `<Menu>` | `components/menu` |
|
|
19
19
|
| sections that open one at a time | `<Accordion>` | `components/accordion` |
|
|
20
20
|
| one "show more" disclosure | `<Collapsible>` | `components/accordion` |
|
|
21
|
+
| rows to read and pick from | `<List>` | `components/list` |
|
|
21
22
|
| a value dragged along a track | `<Slider>` | `components/slider` |
|
|
22
23
|
| how far a task has got | `<Progress>` | `components/progress` |
|
|
23
24
|
| a message sent from anywhere | `<Toaster>` + `toast()` | `components/toaster` |
|
|
@@ -67,6 +68,51 @@ own tab stop; Down/Up/Home/End are a shortcut. Style the open state with `ariaAt
|
|
|
67
68
|
never a variant, and leave `accordion.track`/`accordion.clip` alone: padding on a grid item cannot be
|
|
68
69
|
squeezed, so it floors the `0fr` track — which is why your padded panel sits inside the clip (#142).
|
|
69
70
|
|
|
71
|
+
## List
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import List from '@box-kite/react/components/list';
|
|
75
|
+
|
|
76
|
+
<List variant="card">
|
|
77
|
+
<List.Group label="Today" level={3} sticky>
|
|
78
|
+
<List.Item chevron>
|
|
79
|
+
<List.Media variant="avatar">AL</List.Media>
|
|
80
|
+
<List.Content>
|
|
81
|
+
<List.Row>
|
|
82
|
+
<List.Title lines={1}>
|
|
83
|
+
<List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action>
|
|
84
|
+
</List.Title>
|
|
85
|
+
<List.Meta>9:41</List.Meta>
|
|
86
|
+
</List.Row>
|
|
87
|
+
<List.Row secondary>Re: the engine</List.Row>
|
|
88
|
+
<List.Description lines={2}>It might compose elaborate pieces of music.</List.Description>
|
|
89
|
+
</List.Content>
|
|
90
|
+
</List.Item>
|
|
91
|
+
</List.Group>
|
|
92
|
+
</List>;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`List`: `variant` (`plain`, `card`, `separated`; dividers by default), `tag="ol"`. `List.Item`: `size` (`sm` 44px, `md` 56px,
|
|
96
|
+
`lg` 72px), `wrap`, `selected`, `chevron`. `List.Row`: `wrap`, `secondary`. `List.Title`/`Description`/`Overline`: `lines`
|
|
97
|
+
(`1` ellipsis, `2`/`3` clamp). `List.Media`: `variant` `icon`/`avatar`/`image`. `List.Field`: `label`. `List.Group`:
|
|
98
|
+
`label`, `level`, `sticky`. `List.Content`, `List.Meta`, `List.End`, `List.Actions`, `List.Fields`: every Box prop.
|
|
99
|
+
|
|
100
|
+
`List.Action` goes in the title and covers the item: `props={{ href }}` is a link, `props={{ htmlFor }}` the label of a
|
|
101
|
+
checkbox in `List.Actions` (a pick-list), otherwise a button with `onClick`. Anything else interactive goes in
|
|
102
|
+
`List.Actions`, which sits above the action. No hook and no context — it renders on a server — so the look reaches
|
|
103
|
+
the items as `--list-divider`, `--list-divider-inset`, `--list-item-frame`, `--list-item-radius` and
|
|
104
|
+
`--list-sticky-top`, set with `vars`. The tree is paint only (`list`, `list.item`, `list.media`, `list.muted`,
|
|
105
|
+
`list.action`, `list.label`); the spacing is the parts' own default props, which a prop on the part beats.
|
|
106
|
+
|
|
107
|
+
Three client-only entries build on it. `SwipeItem` (`components/swipeItem`) is a `List.Item` whose row slides aside:
|
|
108
|
+
`startActions`/`endActions` of `SwipeItem.Action` (`variant` `accent`/`warning`/`danger`, `icon`), `fullSwipe` to
|
|
109
|
+
perform the outermost by swiping across, `open`/`onOpenChange`; the actions are tab stops and focus opens their tray.
|
|
110
|
+
`SortableList` (`components/sortableList`) reorders `SortableList.Item`s (`value`, `label`) by a `SortableList.Handle`:
|
|
111
|
+
the app owns the order, `onOrderChange(order, { reason, value, from, to })` fires on the drop and on each keyboard step
|
|
112
|
+
(Enter/Space grabs, arrows move, Escape puts back), `onOrderCommit` once it settles. `VirtualList`
|
|
113
|
+
(`components/virtualList`) windows `items` through a `children(item, index)` that returns one `<li>`, measuring each
|
|
114
|
+
item by `itemKey`; `estimateSize`, `overscan`, `onRangeChange`, `scrollerRef`. `List.Chevron` is the chevron as a part.
|
|
115
|
+
|
|
70
116
|
## Slider and Progress
|
|
71
117
|
|
|
72
118
|
```tsx
|
|
@@ -103,6 +149,40 @@ a zero**: an indeterminate bar omits `aria-valuenow` rather than reporting `0`.
|
|
|
103
149
|
`thumbLabels` names the thumbs. The geometry is logical, so a right-to-left page mirrors for free; only
|
|
104
150
|
the sideways arrows swap, and Up/Down never do.
|
|
105
151
|
|
|
152
|
+
## Dates and times
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import DateField from '@box-kite/react/components/dateField';
|
|
156
|
+
import DatePicker, { DateRangePicker } from '@box-kite/react/components/datePicker';
|
|
157
|
+
import DateTime from '@box-kite/react/components/dateTime';
|
|
158
|
+
import { toUtc, format } from '@box-kite/react/date';
|
|
159
|
+
|
|
160
|
+
<DateField label="Due" name="due" />;
|
|
161
|
+
<DatePicker type="datetime" zone="utc" label="Publish at" onValueChange={(value, { utc, local }) => schedule(utc)} />;
|
|
162
|
+
<DateRangePicker label="Stay" startName="from" endName="to" />;
|
|
163
|
+
<DateTime value={order.createdAt} />;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**A value is an ISO string whose shape is its kind**: `2026-10-19` a date, `14:30:00` a time,
|
|
167
|
+
`2026-10-19T14:30:00` a wall clock with no zone, `…+03:00`/`…Z` an instant. Never a `Date`. Every control
|
|
168
|
+
takes and gives that string, and `onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`
|
|
169
|
+
hands the readings over beside it.
|
|
170
|
+
|
|
171
|
+
`DateField`: `type` (`date`/`time`/`datetime`), `zone` (`'local'` default, `'utc'`, IANA, `'none'`),
|
|
172
|
+
`locale`, `hourCycle`, `granularity`, `min`/`max`/`isDateUnavailable` (marked `aria-invalid`), `name`,
|
|
173
|
+
`required`, `label`. Segments in the locale's order; a paste is the only text it reads. `Calendar`:
|
|
174
|
+
`mode="range"` (`{ start, end }`), `months`, `weekNumbers`, `firstDayOfWeek`, `min`/`max`. `DatePicker`:
|
|
175
|
+
the field with a calendar, a list of times (`step`) or both, `presets`, `clearable`; `DateRangePicker`:
|
|
176
|
+
two fields over a range calendar, `startName`/`endName`. `TimeZoneSelect`: a `Combobox` whose value is the
|
|
177
|
+
IANA name. `DateTime`: a `<time datetime>` in the reader's zone; the module also exports `DateProvider`.
|
|
178
|
+
|
|
179
|
+
Four things to know. **`'local'` is the reader** — on a server, `configureDates({ timeZone })` or a
|
|
180
|
+
`<DateProvider timeZone>` fed from the cookie `zoneCookie()` writes, and `systemZone()` is the server's own.
|
|
181
|
+
**No hydration error**: server and hydration render in the fallback (`UTC`/`en-US`), then the reader's.
|
|
182
|
+
**DST** is Temporal's `disambiguation` (`'compatible'` default). And the **conversions name their target**
|
|
183
|
+
(`toUtc`, `toLocal`, `toZone`, `toPlainDate`), the arithmetic moves the wall clock for days and the instant
|
|
184
|
+
for hours, and `format` takes `Intl` options, never tokens.
|
|
185
|
+
|
|
106
186
|
## Toaster
|
|
107
187
|
|
|
108
188
|
```tsx
|
|
@@ -229,12 +309,12 @@ import DataGrid from '@box-kite/react/components/dataGrid';
|
|
|
229
309
|
|
|
230
310
|
**DataGridProps**: `data`, `def`, `component` (default `'datagrid'`), `loading`, `filters` (predicate[]), `page`/`onPageChange`, `onSortChange`, `onServerStateChange` (`{ page, pageSize, sortColumn, sortDirection, columnFilters, globalFilterValue }`), `onSelectionChange` (`{ action, selectedRowKeys, affectedRowKeys, isAllSelected }`), `expandedRowKeys`/`onExpandedRowKeysChange`, `globalFilterValue`/`onGlobalFilterChange`, `columnFilters`/`onColumnFiltersChange`, `onCellEditsChange` (`edit`/`paste`/`clear`), `onRangeChange` (`(range, { reason })` with `select`/`extend`/`clear`; `range` is `{ startRow, endRow, columns, values() }` or `undefined`), `onPaste` (`{ range, applied, rejected, skipped }`, once per Ctrl+V).
|
|
231
311
|
|
|
232
|
-
**GridDefinition**: `columns` (required), `rowKey`, `rowHeight` (px, default 48), `visibleRowsCount` (number/`'all'`), `showRowNumber` (bool/`{ pinned?, width? }`), `rowSelection` (bool/`{ pinned? }` — the header checkbox acts on the rows the filters leave on screen, and the bottom bar counts selected rows the filters hide, with Show selected and Clear selection), `rowDetail` (`{ content, height?, expandOnRowClick?, pinned?, expandColumnWidth?, expandColumnHeader? }`), `pagination` (`{ totalCount, pageSize? }`), `topBar`/`bottomBar`, `title`/`topBarContent`, `globalFilter`, `globalFilterKeys`, `sortable`/`resizable` (default true), `contextMenu` (bool/`{ sort?, pin?, group? }`, default true), `resizerStyle` (`'visible'`/`'hover'`/`'hidden'`), `headerHover` (`'static'` — an inset pill per sortable cell — or `'sliding'`, one pill that flows between cells), `localeText` (any subset of `DataGridLocaleText` — every word the grid writes, for a translation; values with a number or a name in them are functions, and `DATA_GRID_LOCALE_TEXT` is the English to spread over), `noDataComponent`, `editable`/`onCellEdit`, `tabNavigation` (`'none'` default — APG's single tab stop — or `'cells'`, the spreadsheet reading), `rangeSelection` (a drag or Shift+arrow marks a block of cells; `Ctrl+C` copies it as TSV and `Ctrl+V` fills it back through `onCellEdit`, one judgement per cell — both need no opt-in for the current cell alone).
|
|
312
|
+
**GridDefinition**: `columns` (required), `rowKey`, `rowHeight` (px, default 48), `visibleRowsCount` (number/`'all'`), `showRowNumber` (bool/`{ pinned?, width? }`), `rowSelection` (bool/`{ pinned? }` — the header checkbox acts on the rows the filters leave on screen, and the bottom bar counts selected rows the filters hide, with Show selected and Clear selection), `rowDetail` (`{ content, height?, expandOnRowClick?, pinned?, expandColumnWidth?, expandColumnHeader? }`), `pagination` (`{ totalCount, pageSize? }`), `topBar`/`bottomBar`, `title`/`topBarContent`, `globalFilter`, `globalFilterKeys`, `sortable`/`resizable` (default true), `contextMenu` (bool/`{ sort?, pin?, group? }`, default true), `resizerStyle` (`'visible'`/`'hover'`/`'hidden'`), `headerHover` (`'static'` — an inset pill per sortable cell — or `'sliding'`, one pill that flows between cells), `localeText` (any subset of `DataGridLocaleText` — every word the grid writes, for a translation; values with a number or a name in them are functions, and `DATA_GRID_LOCALE_TEXT` is the English to spread over), `noDataComponent`, `editable`/`onCellEdit`, `tabNavigation` (`'none'` default — APG's single tab stop — or `'cells'`, the spreadsheet reading), `layout` (`'auto'` default — a list item per row once the grid's own width is under `list.breakpoint`, 576px; `'table'`/`'list'` choose for good — rule 51), `list` (`{ breakpoint?, fields?, batchSize?, recordView? }`), `rangeSelection` (a drag or Shift+arrow marks a block of cells; `Ctrl+C` copies it as TSV and `Ctrl+V` fills it back through `onCellEdit`, one judgement per cell — both need no opt-in for the current cell alone).
|
|
233
313
|
|
|
234
|
-
**ColumnType**: `key`, `header`, `width` (px, default 200), `align` (`'start'`/`'end'`/`'center'`, or the physical `'left'`/`'right'`), `pin` (`'START'`/`'END'` — an edge of the **inline axis**, so a pinned column holds under either `dir`; `'LEFT'`/`'RIGHT'` are the older spelling of the same two), `columns` (grouped headers), `Cell` (`({ cell }) => ReactNode`), `sortable`/`resizable` (override grid), `flexible`, `filterable` (`true`=text, `{ type: 'number' }`, `{ type: 'multiselect' }`), `contextMenu` (override grid).
|
|
314
|
+
**ColumnType**: `key`, `header`, `width` (px, default 200), `align` (`'start'`/`'end'`/`'center'`, or the physical `'left'`/`'right'`), `pin` (`'START'`/`'END'` — an edge of the **inline axis**, so a pinned column holds under either `dir`; `'LEFT'`/`'RIGHT'` are the older spelling of the same two), `columns` (grouped headers), `Cell` (`({ cell }) => ReactNode`), `sortable`/`resizable` (override grid), `flexible`, `filterable` (`true`=text, `{ type: 'number' }`, `{ type: 'multiselect' }`), `contextMenu` (override grid), `list` (`'title'`/`'kicker'`/`'subtitle'`/`'end'`/`'field'`/`'detail'`/`'hidden'` — where it goes in a list item).
|
|
235
315
|
|
|
236
316
|
**Server-side**: `def={{ pagination: { totalCount }, bottomBar: true }}` + `page={page}` + `onServerStateChange={fetchData}`.
|
|
237
317
|
|
|
238
318
|
**Accessibility (A7, A10)**: the APG grid pattern, over a virtualized body — supplied whole, so add no roles and no `tabIndex`. The scrolling element is `role="grid"` (header and body are `rowgroup`s, rows `role="row"`, cells `role="gridcell"`/`columnheader`, scroll spacers `presentation`). `aria-rowcount`/`-colcount` describe the _whole_ grid and `aria-rowindex`/`-colindex` place each rendered cell in it, header rows first. Also `aria-sort` on sortable headers, `aria-selected` on rows only with `rowSelection`, `aria-busy` while `loading`, `aria-expanded` on group and expanded rows, and a live region announcing the selection count. Keyboard: one cell in the tab order; arrows move it, Home/End along the row, Ctrl+Home/End to the corners (scrolling to unrendered rows), PageUp/Down by a screenful, Enter/Space sorts a header or steps into the cell’s control, F2 always steps in, Escape steps back out. Down/Up keep the _column_ rather than the cell ordinal, so they land under where they started even through a grouped header. The column resizer is APG's window splitter — `role="separator"` with `aria-valuenow`/`-valuemin`/`-valuemax` in pixels; Tab or F2 reaches it, the arrows move it 16px, Home/End go to the narrowest the grid allows and to the grid's own width. Space on any cell selects its row, and on the header cell the select-all box sits in it toggles every row the filters leave on screen. Every cell carries the **current-cell mark**, which survives the grid losing focus so `Ctrl+C` has something to act on; with `def.rangeSelection` the cells of a marked block carry `aria-selected` and the grid is `aria-multiselectable`, while a lone current cell carries neither. Every control the grid draws names itself after what it acts on ("Select row 4", "Column options for Age"); the column menu is the APG menu button. **Give it `def.title`** — a grid is not named by its rows. Every widget a cell holds is out of the page tab sequence, so the grid is one tab stop: Enter or F2 steps in, Tab walks the widgets **within that cell** and wraps, and Escape is the way out.
|
|
239
319
|
|
|
240
|
-
**Style tree**: `datagrid` > `content`, `topBar` > (`globalFilter` > `stats`, `columnGroups` > `icon`|`separator`|`item` > `icon`, `columnVisibility` > `badge`|`panel`|`search`|`searchIcon`|`searchInput`|`toolbar`|`count`|`action`|`list`|`section`|`group`|`item`|`empty`), `filter` > `cell` > `input`, `header` > `cell` > (`contextMenu` > `icon`|`tooltip` > `item` > `icon`|`separator`, `resizer`), `body` > (`cell` > `text`|`rowDetail`, `row`, `groupRow` > `expandButton`, `detailRow`, `empty`), `emptyColumns`, `bottomBar` > (`info`, `clearFilters`, `pagination` > `button`|`info`).
|
|
320
|
+
**Style tree**: `datagrid` > `content`, `topBar` > (`globalFilter` > `stats`, `columnGroups` > `icon`|`separator`|`item` > `icon`, `columnVisibility` > `badge`|`panel`|`search`|`searchIcon`|`searchInput`|`toolbar`|`count`|`action`|`list`|`section`|`group`|`item`|`empty`), `filter` > `cell` > `input`, `header` > `cell` > (`contextMenu` > `icon`|`tooltip` > `item` > `icon`|`separator`, `resizer`), `body` > (`cell` > `text`|`rowDetail`, `row`, `groupRow` > `expandButton`, `detailRow`, `empty`), `emptyColumns`, `list` > (`toolbar`, `toggle`, `detail`, `button`, `selectionBar`, `sheet`) — its rows are `List.Item`s, styled by the `list` tree, `bottomBar` > (`info`, `clearFilters`, `pagination` > `button`|`info`).
|
|
@@ -68,7 +68,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
68
68
|
|
|
69
69
|
36. **A message you send rather than render is `<Toaster>` plus `toast()`.** `<Toaster />` once near the root of the app (`components/toaster`), and after that `toast.success('Saved')` from anywhere at all — an event handler, a fetch, a router guard, a module with no React in it. The store behind it is framework-free, so a call made **before** the viewport mounts is queued rather than lost, and the component only draws what is there. The surface is `toast(message, options)` with `success`/`error`/`warning`/`info`/`loading` beside it, `toast.promise(p, { loading, success, error })` (one toast for the whole call, and the promise comes back untouched), `toast.update(id, message, options)` and `toast.dismiss(id?)`; an `options.id` already on screen is an **update**, which is what makes a promise one toast rather than two. A toast takes a `description`, one `action` (`{ label, onClick }`, which dismisses the toast it answered unless `closeOnClick: false`), a `duration` in milliseconds (`Infinity` waits, and `loading` already does), and `onDismiss(reason)` with `timeout`/`close`/`action`/`imperative`. **The limit is a queue, not a cap**: past `limit` (default 3) a toast waits **with its timer unstarted**, so nothing expires that was never on screen, and a counter at the far end of the stack says how many are waiting. **Four things to know, all measured in Chrome 153.** The viewport is a **polite live region that exists before there is anything in it** — a region inserted together with its content is not reliably announced — and an error toast is `role="alert"`, the one announcement pattern every screen reader implements; nothing else carries a region of its own, because the nearest region to a change is the one that speaks. It is in the **top layer** with no portal (`popover="manual"`, shown as it mounts), so it inherits the theme and the direction around it, and it takes **no pointer events at all** while the toasts take them back — a corner-sized fixed strip would otherwise swallow every press in its gaps. **Timers stop on hover, on focus and in a background tab** and pick up where they left off, which is WCAG 2.2.1 and the only reason a toast may carry a control at all; a toast **never takes focus**, so `F6` (or `hotkey`, which takes `'alt+t'` or `false`) is what moves focus to the stack, Tab walks it, and Escape dismisses the toast focus is in and hands focus back once the stack is empty. And the viewport has to answer the UA's own `[popover]` rule: `inset: 0` leaves a corner-pinned stack over-constrained — `top`/`left` win and it sits in the wrong corner — and `overflow: auto` makes it a scroll container, so the style tree declares all four sides `auto` and `overflow: visible` before a `position` variant names the two it wants. `position` is `'bottom-end'` by default and its inline half is logical (`start`/`end`), the newest toast is always nearest the screen edge, and styling is the `toaster`/`toaster.toast`/`toaster.message`/`toaster.description`/`toaster.action`/`toaster.close`/`toaster.overflow` tree, where the kind is a variant on the toast.
|
|
70
70
|
|
|
71
|
-
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox
|
|
71
|
+
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox` (and is drawn as a check or a cross), an ISO date, time or date-time a `DateField` (an edit is written back in the shape the cell held: `…Z` stays UTC, a `Date` stays a `Date`), everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the seven (`text`/`number`/`checkbox`/`select`/`date`/`time`/`datetime`) or configures it (`{ type, options?, placeholder?, step?, min?, max?, zone? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
|
|
72
72
|
|
|
73
73
|
38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
|
|
74
74
|
|
|
@@ -92,4 +92,14 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
92
92
|
|
|
93
93
|
48. **In development the console is part of the answer: read it.** Three mistakes write nothing, or the wrong thing, and say so in one `[box-kite]` warning that names what rendered and the line to write instead. **A value a prop does not take** — `bgColor="bleu-500"` → `wrote no CSS … Did you mean "blue-500"?`, `opacity={50}` → `It takes one of 0, 0.1, …`. **An HTML attribute at the top level** — `href`, `onClick`, `data-*`, `aria-*` → `write props={{ href: ... }}`. **A number in a unit it almost certainly did not mean**, which is valid CSS and so the one mistake nothing else catches: `fontSize={4}` is 4px text (`For 16px write fontSize={16}`), `lineHeight={1.5}` a 1.5px line, `transitionDuration={0.3}` 0.3ms, `brightness={1.1}` 1.1%. Each prints once per value, in any environment whose `NODE_ENV` is not `production` — a test run included, which is the cheapest place to see them — and never in a production build, where a rejected value is still dropped with nothing said. `Box.configure({ warnings: false })` turns them off; `check_styles` in `@box-kite/mcp` gives the same three verdicts before anything renders.
|
|
94
94
|
|
|
95
|
+
49. **A design that stops at a subtree is `Box.createComponents()`, not `Box.components()`.** `Box.components()` is one registry for the whole app: a second call merges into the first, so two designs cannot share a page through it. `const aurora = Box.createComponents({ button: { styles: { borderRadius: 999 } } })` takes the same shape and registers nothing, and `<Box.Components value={aurora}>` — or `<Box.Theme use="local" components={aurora}>`, a design in light and dark in one wrapper — applies it to every Box inside, pre-built components included. `base` is what it merges over: `'global'` (default, the app's own `Box.components()`), `'builtin'` (ignores them) or another registry. Props still beat the design. It is identified by its content, so an inline one costs no cache, but declare it at module scope. The provider renders no element and a Server Component cannot read it. Two designs that both name `button` type as `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}`.
|
|
96
|
+
|
|
97
|
+
50. **A list of things to read and pick from is `<List>`, and one action covers each item.** `<List><List.Item chevron><List.Media variant="avatar">AL</List.Media><List.Content><List.Row><List.Title lines={1}><List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action></List.Title><List.Meta>9:41</List.Meta></List.Row><List.Description lines={2}>…</List.Description></List.Content></List.Item></List>` (`components/list`). **An item is a flex row of three slots** — `List.Media` leading (`variant` `icon`/`avatar`/`image`), `List.Content` taking the room, `List.End` trailing (a value over its label, aligned to the end) — and the content is a column of `List.Row`s, each its parts side by side with the last pushed to the end: a main row (the title and its time) and a `secondary` one under it (smaller, muted). `wrap` on a row or an item lets the parts wrap instead of squeezing; `List.Overline`/`List.Title`/`List.Description` take `lines={1|2|3}` (an ellipsis, then a clamp); `List.Meta` is a small value that never wraps; `List.Fields` + `List.Field label` is a `<dl>` of labelled values; `List.Group label level sticky` is a section with a nested list of its own. **`List.Action` goes in the title and its `::after` covers the item**, so the whole row presses while the name a reader hears is the title alone — an `href` in `props` makes it an `<a>`, an `htmlFor` a `<label>` (a press anywhere ticks that checkbox), neither a `<button>` with a top-level `onClick`; a control of the item's own (a switch, a menu, that checkbox) goes in `List.Actions`, which sits above it. **Four things to know.** It renders on a server — no hook, no context — so the list's look reaches its items as **custom properties**: `variant="plain"|"card"|"separated"` on `List`, and `vars={{ 'list-divider': …, 'list-divider-inset': '4.5rem', 'list-item-radius': …, 'list-sticky-top': '4rem' }}` to tune it. The style tree is **paint only** (`list`, `list.item`, `list.media`, `list.muted`, `list.action`, `list.label`), the hairlines and tints mixed from `currentColor`; the spacing is each part's default props, so `<List.Item py={1}>` wins and `Box.components` cannot move it. Hover, pressed, `aria-current` and disabled are read off the action with `:has()` — no prop — and `selected` on an item is a tint only, the state a reader hears belonging on the control. And it is a `<ul role="list">` (Safari drops the semantics otherwise; `tag="ol"` for an ordered one), **not a composite widget**: every action is a tab stop and the arrows stay the page's — arrow-key selection among options is a listbox, `Combobox` or `Select`.
|
|
98
|
+
|
|
99
|
+
51. **On a phone a DataGrid is a list, and a column says where it goes in an item.** Below `def.list.breakpoint` (576px) of **the grid's own width** — not the viewport's — `layout: 'auto'` (the default) draws each row as a list item: `list: 'title'` is its name and the button that opens it, `'kicker'` a small line above, `'subtitle'` a line under, `'end'` the one value opposite the title with its column's name under it, `'field'` a labelled value, `'detail'` the record view only, `'hidden'` nowhere. Left unset, the first column is the title, the first end-aligned one the end, the next four fields (`def.list.fields`) and the rest detail. A press opens the **record view** — every column of one row in a bottom sheet with a pager (buttons, Left/Right, a swipe) that follows the list's sort and filters, and an **Edit** button on each editable column through the same `onCellEdit`. Sort and filters are bottom sheets behind two pills, the filters in force are removable chips, **Select** is a mode (a press ticks instead of opening, and a bar pinned to the bottom counts the selection), and the list grows twenty a **Show more** — a button, so the totals and the page below stay reachable — or pages when the host paginates. **Four things to know.** It is a `<List>` (rule 50) — each row a `List.Item` whose title is the `List.Action` — not a restyled `role="grid"`, so the `list` tree styles its rows. It is a chunk of its own, loaded the first time a grid is narrow, and the grid draws its table until it arrives — in a test, `await loadListLayout()` first. Unmeasured is a table, so a server prerenders the table and `layout: 'list'` is how to say otherwise. And the selection bar sticks to the viewport, so no ancestor may be `overflow: hidden`/`auto` — `css={{ overflow: 'clip' }}` clips without being a scroll container. `layout: 'table'` keeps the table at every width.
|
|
100
|
+
|
|
101
|
+
52. **Swiping, reordering and windowing a list are three client entries on top of `List`.** `<SwipeItem endActions={<SwipeItem.Action variant="danger" onClick={remove}>Delete</SwipeItem.Action>} fullSwipe>` (`components/swipeItem`) is a `List.Item` — same parts, `size`, `chevron`, `selected` — whose row slides to open a tray; the first action of each tray is the outermost, and `fullSwipe` performs it by clicking it, so its `onClick` is where the command lives. The actions are tab stops and focus opens their tray, but a swipe is a path gesture: put the same commands where a press reaches too. `<SortableList onOrderChange={(order) => setIds(order)}>` (`components/sortableList`) with `<SortableList.Item value={id} label={name}>` and a `<SortableList.Handle />` in each: **the app owns the order** and renders it, `onOrderChange` fires once per drop and per keyboard step, `onOrderCommit` when it settles; the keyboard grabs (Enter/Space, arrows, Escape puts back), and a press on a handle then a press on another item drops it there. `<VirtualList items={rows} itemKey={(row) => row.id}>{(row) => <List.Item>…</List.Item>}</VirtualList>` (`components/virtualList`) renders a window, measuring each item by key; the scroller is the nearest scrolling ancestor or the page, `scrollerRef` when that is wrong, and `onRangeChange` fetches the next page. **Three things to know.** The children function returns exactly one `<li>` per item and nothing around it, since the list measures its children. A windowed list has no `List.Group`s and find-in-page cannot see what is not rendered. And a `SwipeItem` windows like any item, but a `SortableList` holds its whole list: reorder a list short enough to render, window one too long to.
|
|
102
|
+
|
|
103
|
+
53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
|
|
104
|
+
|
|
95
105
|
Full reference: `node_modules/@box-kite/react/docs/props.md` in the project, or https://www.box-kite.dev/props.md — every prop, the CSS it writes and one measured example.
|
package/AGENTS.md
CHANGED
|
@@ -100,7 +100,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
100
100
|
|
|
101
101
|
36. **A message you send rather than render is `<Toaster>` plus `toast()`.** `<Toaster />` once near the root of the app (`components/toaster`), and after that `toast.success('Saved')` from anywhere at all — an event handler, a fetch, a router guard, a module with no React in it. The store behind it is framework-free, so a call made **before** the viewport mounts is queued rather than lost, and the component only draws what is there. The surface is `toast(message, options)` with `success`/`error`/`warning`/`info`/`loading` beside it, `toast.promise(p, { loading, success, error })` (one toast for the whole call, and the promise comes back untouched), `toast.update(id, message, options)` and `toast.dismiss(id?)`; an `options.id` already on screen is an **update**, which is what makes a promise one toast rather than two. A toast takes a `description`, one `action` (`{ label, onClick }`, which dismisses the toast it answered unless `closeOnClick: false`), a `duration` in milliseconds (`Infinity` waits, and `loading` already does), and `onDismiss(reason)` with `timeout`/`close`/`action`/`imperative`. **The limit is a queue, not a cap**: past `limit` (default 3) a toast waits **with its timer unstarted**, so nothing expires that was never on screen, and a counter at the far end of the stack says how many are waiting. **Four things to know, all measured in Chrome 153.** The viewport is a **polite live region that exists before there is anything in it** — a region inserted together with its content is not reliably announced — and an error toast is `role="alert"`, the one announcement pattern every screen reader implements; nothing else carries a region of its own, because the nearest region to a change is the one that speaks. It is in the **top layer** with no portal (`popover="manual"`, shown as it mounts), so it inherits the theme and the direction around it, and it takes **no pointer events at all** while the toasts take them back — a corner-sized fixed strip would otherwise swallow every press in its gaps. **Timers stop on hover, on focus and in a background tab** and pick up where they left off, which is WCAG 2.2.1 and the only reason a toast may carry a control at all; a toast **never takes focus**, so `F6` (or `hotkey`, which takes `'alt+t'` or `false`) is what moves focus to the stack, Tab walks it, and Escape dismisses the toast focus is in and hands focus back once the stack is empty. And the viewport has to answer the UA's own `[popover]` rule: `inset: 0` leaves a corner-pinned stack over-constrained — `top`/`left` win and it sits in the wrong corner — and `overflow: auto` makes it a scroll container, so the style tree declares all four sides `auto` and `overflow: visible` before a `position` variant names the two it wants. `position` is `'bottom-end'` by default and its inline half is logical (`start`/`end`), the newest toast is always nearest the screen edge, and styling is the `toaster`/`toaster.toast`/`toaster.message`/`toaster.description`/`toaster.action`/`toaster.close`/`toaster.overflow` tree, where the kind is a variant on the toast.
|
|
102
102
|
|
|
103
|
-
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox
|
|
103
|
+
37. **A DataGrid cell is edited by two props, and one of them judges the edit.** `column.editable` (`true`, or a predicate over the row) is the whole opt-in — `def.editable` is the grid-wide default a column falls back to — and **`def.onCellEdit({ rowKey, columnKey, row, value, oldValue })` both validates the edit and is told about it**: answer nothing to accept the value, a string to refuse it with that message, `false` to refuse it with the grid's own. It may return a **promise**, so a uniqueness check against a server is the same function; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now, and a rejected promise is its message. **Which editor a cell opens is read off the value in it** — a number gets `number`, a boolean `checkbox` (and is drawn as a check or a cross), an ISO date, time or date-time a `DateField` (an edit is written back in the shape the cell held: `…Z` stays UTC, a `Date` stays a `Date`), everything else `text` — so a column of numbers has a numeric keypad without being told; `column.editor` names one of the seven (`text`/`number`/`checkbox`/`select`/`date`/`time`/`datetime`) or configures it (`{ type, options?, placeholder?, step?, min?, max?, zone? }`, where `options` may be a function of the row), and `column.EditCell` is a control of your own bound to `cell.draft`, with `cell.setDraft(v)`, `cell.commitEdit()` and `cell.cancelEdit()` beside it — the same three calls the built-in four make, so a date picker is a component rather than a second copy of the commit rules. **A press chooses and a double press opens**: clicking a cell makes it the current one — the cell the arrows carry on from, marked whether the pointer or the keyboard put it there — and double-clicking an editable one opens its editor, the way a spreadsheet reads a double press; a cell that cannot be edited takes both as focus and nothing else, and a double press landing on a widget inside a cell (a tree chevron, a link in a `Cell` of your own) belongs to the widget rather than the editor. **The keyboard is APG's**: Enter or F2 opens the editor, any printable character opens it on that character (replacing the value, the way a spreadsheet reads a keystroke), Escape throws the draft away, Enter commits and hands the keyboard back to the cell, a press elsewhere commits, and **Tab commits and opens the next editable cell** — along the row and on into the rows after it, so a row of corrections is one gesture; `checkbox` and `select` commit on the change instead, since one interaction is the whole value. **Four things to know.** A **refused value keeps the editor open and the caret in it**, with the message in a `role="alert"` **in the top layer** — a bubble inside the scroller would be clipped away on the last row, which is the row a long grid is most often edited on — plus `aria-invalid` and `aria-describedby` on the field; typing clears it. **The grid does not own `data`, so an accepted value is kept as an edit _over_ the rows it was given** and is what every cell, every `Cell` renderer and every export reads from then on — which is what lets a `def.dataSource` grid be edited at all, since a value that was only reported would be gone on the next block. **`onCellEditsChange(edits, { reason })` reports the whole list, oldest first** — the stream an undo is built out of — and **`clearEdits()` on the grid's ref** is how a host says its own data has caught up; a datasource `refresh()` drops them with the blocks, because what the server says next is the newer answer. And **an edit never re-sorts or re-filters the grid**: sorting and filtering read the row's own values and only what is _displayed_ reads the edit, so a row cannot jump out from under the person typing into it. Styling is `datagrid.body.cell`'s `isEditing` and `isInvalid` variants — one ring, red when the value was refused, because two rings on the same rectangle means the editing one paints over the other — plus `datagrid.body.cell.editor` (`isPending`) and `datagrid.body.cell.error`.
|
|
104
104
|
|
|
105
105
|
38. **A DataGrid marks the cell it is on, and `def.rangeSelection` marks a block of them.** Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there — and the mark **survives the grid losing focus**, which is the whole point of it: a `:focus-visible` ring never matches a pointer, so a _clicked_ cell drew nothing (bug #64), and a ring made of focus leaves a copy with nothing to act on. **`Ctrl+C` needs no opt-in**: on the current cell it copies that one cell, which is the degenerate range. `def.rangeSelection: true` is the rest — **drag across cells, or hold Shift with the arrow keys** (Home/End, Ctrl+Home/End and PageUp/Down extend too), and a Shift+press extends from wherever the block was anchored; a move with no modifier collapses it onto the cell it landed on, and after a drag the ring, the tab stop and the next Shift+arrow are all the cell the drag ended on. What `Ctrl+C` writes is **tab-separated text with CRLF between the rows**, which is what Excel, Sheets and Numbers paste as columns; a field carrying a tab, a newline or a quote is quoted and its quotes doubled. **Four things to know.** The block is what reports itself: its cells carry `aria-selected` and the grid is `aria-multiselectable`, while a **lone current cell reports neither** — it has chosen nothing, and announcing "selected" on every arrow key is an announcement about nothing. The block is tinted _around_ the current cell, which keeps its ring, the way a sheet says where typing would land. **A copied figure is the one a file would carry**: the clipboard is an export, so a column's `exportValue` wins where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it; the copy rides the browser's own copy event, so text selected on the page still belongs to the page. And **marking cells is a drag, so text selection is not** — a grid with `rangeSelection` on gives text selection up (an open editor hands it back), a press that lands on a widget belongs to the widget, and a touch is left alone entirely, since that press is how the grid scrolls. `onRangeChange(range, { reason })` reports `{ startRow, endRow, columns, values() }` with `select`/`extend`/`clear` — indices rather than row keys, because a block may reach rows nobody has fetched, and `values()` is a call for the same reason. Styling is `datagrid.body.cell`'s `isCurrentCell` and `isInRange` variants.
|
|
106
106
|
|
|
@@ -124,4 +124,14 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
124
124
|
|
|
125
125
|
48. **In development the console is part of the answer: read it.** Three mistakes write nothing, or the wrong thing, and say so in one `[box-kite]` warning that names what rendered and the line to write instead. **A value a prop does not take** — `bgColor="bleu-500"` → `wrote no CSS … Did you mean "blue-500"?`, `opacity={50}` → `It takes one of 0, 0.1, …`. **An HTML attribute at the top level** — `href`, `onClick`, `data-*`, `aria-*` → `write props={{ href: ... }}`. **A number in a unit it almost certainly did not mean**, which is valid CSS and so the one mistake nothing else catches: `fontSize={4}` is 4px text (`For 16px write fontSize={16}`), `lineHeight={1.5}` a 1.5px line, `transitionDuration={0.3}` 0.3ms, `brightness={1.1}` 1.1%. Each prints once per value, in any environment whose `NODE_ENV` is not `production` — a test run included, which is the cheapest place to see them — and never in a production build, where a rejected value is still dropped with nothing said. `Box.configure({ warnings: false })` turns them off; `check_styles` in `@box-kite/mcp` gives the same three verdicts before anything renders.
|
|
126
126
|
|
|
127
|
+
49. **A design that stops at a subtree is `Box.createComponents()`, not `Box.components()`.** `Box.components()` is one registry for the whole app: a second call merges into the first, so two designs cannot share a page through it. `const aurora = Box.createComponents({ button: { styles: { borderRadius: 999 } } })` takes the same shape and registers nothing, and `<Box.Components value={aurora}>` — or `<Box.Theme use="local" components={aurora}>`, a design in light and dark in one wrapper — applies it to every Box inside, pre-built components included. `base` is what it merges over: `'global'` (default, the app's own `Box.components()`), `'builtin'` (ignores them) or another registry. Props still beat the design. It is identified by its content, so an inline one costs no cache, but declare it at module scope. The provider renders no element and a Server Component cannot read it. Two designs that both name `button` type as `interface ComponentsTypes extends MergeComponentsAndVariants<[typeof aurora, typeof harbor]> {}`.
|
|
128
|
+
|
|
129
|
+
50. **A list of things to read and pick from is `<List>`, and one action covers each item.** `<List><List.Item chevron><List.Media variant="avatar">AL</List.Media><List.Content><List.Row><List.Title lines={1}><List.Action props={{ href: '/inbox/42' }}>Ada Lovelace</List.Action></List.Title><List.Meta>9:41</List.Meta></List.Row><List.Description lines={2}>…</List.Description></List.Content></List.Item></List>` (`components/list`). **An item is a flex row of three slots** — `List.Media` leading (`variant` `icon`/`avatar`/`image`), `List.Content` taking the room, `List.End` trailing (a value over its label, aligned to the end) — and the content is a column of `List.Row`s, each its parts side by side with the last pushed to the end: a main row (the title and its time) and a `secondary` one under it (smaller, muted). `wrap` on a row or an item lets the parts wrap instead of squeezing; `List.Overline`/`List.Title`/`List.Description` take `lines={1|2|3}` (an ellipsis, then a clamp); `List.Meta` is a small value that never wraps; `List.Fields` + `List.Field label` is a `<dl>` of labelled values; `List.Group label level sticky` is a section with a nested list of its own. **`List.Action` goes in the title and its `::after` covers the item**, so the whole row presses while the name a reader hears is the title alone — an `href` in `props` makes it an `<a>`, an `htmlFor` a `<label>` (a press anywhere ticks that checkbox), neither a `<button>` with a top-level `onClick`; a control of the item's own (a switch, a menu, that checkbox) goes in `List.Actions`, which sits above it. **Four things to know.** It renders on a server — no hook, no context — so the list's look reaches its items as **custom properties**: `variant="plain"|"card"|"separated"` on `List`, and `vars={{ 'list-divider': …, 'list-divider-inset': '4.5rem', 'list-item-radius': …, 'list-sticky-top': '4rem' }}` to tune it. The style tree is **paint only** (`list`, `list.item`, `list.media`, `list.muted`, `list.action`, `list.label`), the hairlines and tints mixed from `currentColor`; the spacing is each part's default props, so `<List.Item py={1}>` wins and `Box.components` cannot move it. Hover, pressed, `aria-current` and disabled are read off the action with `:has()` — no prop — and `selected` on an item is a tint only, the state a reader hears belonging on the control. And it is a `<ul role="list">` (Safari drops the semantics otherwise; `tag="ol"` for an ordered one), **not a composite widget**: every action is a tab stop and the arrows stay the page's — arrow-key selection among options is a listbox, `Combobox` or `Select`.
|
|
130
|
+
|
|
131
|
+
51. **On a phone a DataGrid is a list, and a column says where it goes in an item.** Below `def.list.breakpoint` (576px) of **the grid's own width** — not the viewport's — `layout: 'auto'` (the default) draws each row as a list item: `list: 'title'` is its name and the button that opens it, `'kicker'` a small line above, `'subtitle'` a line under, `'end'` the one value opposite the title with its column's name under it, `'field'` a labelled value, `'detail'` the record view only, `'hidden'` nowhere. Left unset, the first column is the title, the first end-aligned one the end, the next four fields (`def.list.fields`) and the rest detail. A press opens the **record view** — every column of one row in a bottom sheet with a pager (buttons, Left/Right, a swipe) that follows the list's sort and filters, and an **Edit** button on each editable column through the same `onCellEdit`. Sort and filters are bottom sheets behind two pills, the filters in force are removable chips, **Select** is a mode (a press ticks instead of opening, and a bar pinned to the bottom counts the selection), and the list grows twenty a **Show more** — a button, so the totals and the page below stay reachable — or pages when the host paginates. **Four things to know.** It is a `<List>` (rule 50) — each row a `List.Item` whose title is the `List.Action` — not a restyled `role="grid"`, so the `list` tree styles its rows. It is a chunk of its own, loaded the first time a grid is narrow, and the grid draws its table until it arrives — in a test, `await loadListLayout()` first. Unmeasured is a table, so a server prerenders the table and `layout: 'list'` is how to say otherwise. And the selection bar sticks to the viewport, so no ancestor may be `overflow: hidden`/`auto` — `css={{ overflow: 'clip' }}` clips without being a scroll container. `layout: 'table'` keeps the table at every width.
|
|
132
|
+
|
|
133
|
+
52. **Swiping, reordering and windowing a list are three client entries on top of `List`.** `<SwipeItem endActions={<SwipeItem.Action variant="danger" onClick={remove}>Delete</SwipeItem.Action>} fullSwipe>` (`components/swipeItem`) is a `List.Item` — same parts, `size`, `chevron`, `selected` — whose row slides to open a tray; the first action of each tray is the outermost, and `fullSwipe` performs it by clicking it, so its `onClick` is where the command lives. The actions are tab stops and focus opens their tray, but a swipe is a path gesture: put the same commands where a press reaches too. `<SortableList onOrderChange={(order) => setIds(order)}>` (`components/sortableList`) with `<SortableList.Item value={id} label={name}>` and a `<SortableList.Handle />` in each: **the app owns the order** and renders it, `onOrderChange` fires once per drop and per keyboard step, `onOrderCommit` when it settles; the keyboard grabs (Enter/Space, arrows, Escape puts back), and a press on a handle then a press on another item drops it there. `<VirtualList items={rows} itemKey={(row) => row.id}>{(row) => <List.Item>…</List.Item>}</VirtualList>` (`components/virtualList`) renders a window, measuring each item by key; the scroller is the nearest scrolling ancestor or the page, `scrollerRef` when that is wrong, and `onRangeChange` fetches the next page. **Three things to know.** The children function returns exactly one `<li>` per item and nothing around it, since the list measures its children. A windowed list has no `List.Group`s and find-in-page cannot see what is not rendered. And a `SwipeItem` windows like any item, but a `SortableList` holds its whole list: reorder a list short enough to render, window one too long to.
|
|
134
|
+
|
|
135
|
+
53. **A date, a time or a moment is an ISO string, and its shape is its kind.** `2026-10-19` is a date (no time, no zone — the same day everywhere), `14:30:00` a time, `2026-10-19T14:30:00` a wall clock with no zone, and anything with an offset (`…+03:00`, `…Z`) an instant. **Never hold a `Date`**: it is a moment in the runtime's zone, which is how a birthday becomes the day before in another country. `@box-kite/react/date` (no React, no engine, under 8 KB gz, every locale and zone from `Intl`) converts by naming the target — `toUtc(value, { from: 'Europe/Chisinau' })`, `toLocal(value)`, `toZone(value, 'America/New_York')`, `toPlainDate(value, { zone })` — and `format(value, { locale, zone, dateStyle })` writes one in the reader's words (presets, never format tokens); `parse(text, { locale, kind })` reads ISO, a `Date`, epoch ms, an HTTP date or text in the locale's order (`03/04/2026` is March in `en-US` and April in `en-GB`). `add`/`subtract` move the wall clock for days and up and the instant for hours and down, so a day later is the same time tomorrow across DST; `startOf(…, 'week')` follows the locale. **`'local'` is the reader**: the browser's zone in a browser, and on a server `configureDates({ timeZone })` or a `<DateProvider timeZone>` — read off the cookie `zoneCookie()` writes, with `readZoneCookie(header)` — while `systemZone()` is always the server's own. **The controls** — `<DateField>` (`components/dateField`: `type="date"|"time"|"datetime"`, segments in the locale's order, nothing typed is ever parsed), `<Calendar>` (`components/calendar`, `mode="range"`), `<DatePicker>`/`<DateRangePicker>` (`components/datePicker`, the top layer, a list of times every `step` minutes for `time`/`datetime`, `presets`), `<TimeZoneSelect>` (`components/timeZoneSelect`, the IANA name as its value) and `<DateTime value>` (`components/dateTime`, a `<time datetime>` in the reader's zone) — take and give that string, and **`onValueChange(value, { reason, utc, local, zoned, date, epoch, offset })`** hands the readings over beside it. A date-time's `zone` is `'local'` (default), `'utc'`, an IANA name or `'none'` (a wall clock that belongs to nobody); the value is the instant on that zone's clock (`…Z` for UTC). **They cannot cause a hydration error**: a server and a hydrating browser both render in the fallback (`UTC`, `en-US`, or what a `DateProvider` says) and the render after switches to the reader. `name` posts the canonical string and `required` stops the form; a DataGrid column takes `editor: 'date'|'time'|'datetime'`, `dateFormat` and `filterable: { type: 'date' }`, from a chunk fetched only when a grid shows a date. `<Textbox type="date">` is still the native input, whose language is the browser's rather than the page's.
|
|
136
|
+
|
|
127
137
|
Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
|