@box-kite/react 2.2.17 → 2.3.0
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 +6 -4
- package/.claude/skills/box-kite/SKILL.md +6 -4
- package/.claude/skills/box-kite/references/components.md +1 -1
- package/.claude/skills/box-kite/references/patterns.md +1 -1
- package/.claude/skills/box-kite/references/styling.md +2 -2
- package/.cursor/rules/box-kite.mdc +6 -4
- package/AGENTS.md +6 -4
- package/BOX_AI_CONTEXT.md +40 -3
- package/BOX_KITE_AI_CONTEXT.md +40 -3
- package/behavior.cjs +1 -1
- package/behavior.mjs +62 -62
- package/box.cjs +1 -1
- package/box.d.ts +1 -0
- package/box.mjs +1 -1
- package/catalog.cjs +1 -1
- package/catalog.mjs +4 -4
- package/client.cjs +1 -1
- package/client.mjs +11 -11
- package/components/accordion.cjs +1 -1
- package/components/accordion.mjs +1 -1
- package/components/calendar.cjs +1 -1
- package/components/calendar.mjs +21 -20
- package/components/combobox.cjs +1 -1
- package/components/combobox.mjs +1 -1
- package/components/dashboard.cjs +1 -1
- package/components/dashboard.mjs +12 -12
- package/components/dataGrid.cjs +2 -2
- package/components/dataGrid.mjs +5 -5
- package/components/dateField.cjs +1 -1
- package/components/dateField.mjs +6 -6
- package/components/datePicker.cjs +1 -1
- package/components/datePicker.mjs +118 -116
- package/components/dialog.cjs +1 -1
- package/components/dialog.d.ts +1 -1
- package/components/dialog.mjs +108 -101
- package/components/dropdown.cjs +1 -1
- package/components/dropdown.mjs +8 -3
- package/components/form.cjs +1 -1
- package/components/form.mjs +1 -1
- package/components/menu.cjs +1 -1
- package/components/menu.mjs +10 -10
- package/components/overlay.cjs +1 -1
- package/components/overlay.mjs +5 -5
- package/components/popover.cjs +1 -1
- package/components/popover.d.ts +8 -2
- package/components/popover.mjs +58 -55
- package/components/radioGroup.cjs +1 -1
- package/components/radioGroup.mjs +1 -1
- package/components/slider.cjs +1 -1
- package/components/slider.mjs +8 -8
- package/components/sortableList.cjs +1 -1
- package/components/sortableList.mjs +1 -1
- package/components/swipeItem.cjs +1 -1
- package/components/swipeItem.mjs +10 -10
- package/components/tabs.cjs +1 -1
- package/components/tabs.mjs +9 -9
- package/components/toaster.cjs +1 -1
- package/components/toaster.mjs +133 -115
- package/components/tooltip.cjs +1 -1
- package/components/tooltip.mjs +1 -1
- package/components/virtualList.cjs +1 -1
- package/components/virtualList.mjs +1 -1
- package/docs/props.md +6 -6
- package/interactionListening.cjs +1 -1
- package/interactionListening.mjs +1 -1
- package/layer.cjs +1 -1
- package/layer.mjs +17 -14
- package/package.json +2 -2
- package/placement.cjs +1 -1
- package/placement.mjs +10 -10
- package/platform.cjs +1 -1
- package/platform.mjs +23 -11
- package/react-shared.cjs +1 -1
- package/react-shared.mjs +1 -1
- package/rsc.cjs +1 -1
- package/rsc.d.ts +1 -0
- package/rsc.mjs +1 -1
- package/scrolling.cjs +1 -1
- package/scrolling.mjs +1 -1
- package/utils/dom/domUtils.d.ts +9 -0
|
@@ -28,7 +28,7 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
28
28
|
|
|
29
29
|
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. **A token carries its own value as the fallback** — `bgColor="blue-500"` is `var(--blue-500,oklch(62.3% .214 259.8))`, and `var(--blue-500)` in these rules is that, shortened — so nothing is declared in `:root`: your own stylesheet reads a token through a variable of yours (`vars={{ brand: 'violet-500' }}` on an ancestor), while a hand-written `var(--violet-500)` inside a Box value (`fill`, `vars`, `css`, a `Box.extend()` variable) gets the fallback too. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
30
30
|
|
|
31
|
-
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
31
|
+
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, a `Box.extend()` colour, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
32
32
|
|
|
33
33
|
22. **Nine filters stack too, and a mask is a gradient.** `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia` and `dropShadow` each set their own layer of one composed `filter`, so a blur and a desaturation coexist. **A number is the function's own unit** — `brightness={110}` is a percentage, `hueRotate={90}` degrees, `blur={3}` pixels — and `blur` takes Tailwind's scale as well (`xs` 4px … `xxxl` 64px). `dropShadow` (`xs`…`xxl`, with `dropShadowColor`) is cast by the **shape**, where `shadow` draws a rectangle; `none` clears one function. The nine `backdrop*` props are the same functions behind the element — `backdropBlur="sm" backdropSaturate={180}` over something translucent is glassmorphism — and they and the older `backdropFilter` are the same property, so use one. **`maskImage` takes the `bgGradient` record** and reads its alpha: `maskImage={{ linear: 'b', colors: ['black', 'transparent'] }}` fades an edge, `url(#id)` masks by a shape. **`bgClip="text"` needs `color="transparent"`** beside it, and together they are how a gradient becomes type
|
|
34
34
|
|
|
@@ -44,11 +44,11 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
44
44
|
|
|
45
45
|
28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
|
|
46
46
|
|
|
47
|
-
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus
|
|
47
|
+
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
|
|
48
48
|
|
|
49
49
|
30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
|
|
50
50
|
|
|
51
|
-
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
51
|
+
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. **Everything outside an open modal is inert**, a top-layer layer included: a `<Toaster>` moves its stack inside the dialog for that reason, but a `Tooltip` or `Dropdown` declared outside it and left open as it opens stays inert until it closes. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
52
52
|
|
|
53
53
|
32. **A menu button is `<Menu>`, and a submenu is a popover nested in it.** `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>` (`components/menu`) is APG's menu button on the Popover API: `role="menu"`, `menuitem`/`menuitemcheckbox`/`menuitemradio` with `aria-checked`, `role="group"` around a titled section and `role="separator"` between them. The parts are `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group` (a `label` names the group, so its items are read as a set), `Menu.Separator` and `Menu.Sub`, which renders its own item — label, chevron, `aria-haspopup="menu"`, `aria-expanded` — and the menu beside it. **The platform's half is the top layer, light dismiss and the toggle**: Escape closes the innermost menu first, one layer per press, and a press outside closes the lot, with no portal and no `z-index` anywhere. **What is written here is the keyboard and the ARIA**: Down/Up wrap, Home/End, typeahead (a letter again cycles through the items starting with it), Right opens a submenu and Left closes it — the _reading order_, so the two swap in a right-to-left menu and the chevron turns round with them. **Four things to know.** A `disabled` item is `aria-disabled` and **stays focusable**, which is APG's rule: the `disabled` attribute would take it out of the keyboard's reach and silence it. **A command closes the menu and a state does not** — `Menu.Item` closes on select, `Menu.CheckboxItem` and `Menu.RadioItem` do not, so several boxes can be ticked in one visit, and `closeOnSelect` swaps either default. `onOpenChange(open, { reason })` adds **`select`** (an item was chosen — the transition worth telling apart) and **`tab`** to the four a layer always reports. And the menu is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>`, the items render whether or not it has been opened, and the top layer's one cost applies to it as it does to `<Popover>` (rule 30): a menu keeps the side it opened on. Placement is `side`/`align`/`offset`/`flip` on both the menu and each `Menu.Sub`, whose defaults are `side="end"` and `offset={0}` so a submenu abuts the menu it came out of. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in one place. The semantic `<menu>` element is **`MenuList`** from `components/semantics` now — `Menu` there is deprecated, because two exports of that name are one import away from the wrong component.
|
|
54
54
|
|
|
@@ -58,7 +58,7 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
58
58
|
|
|
59
59
|
35. **A slider is `<Slider>`, a bar is `<Progress>`, and a number in is a number out.** `<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />` (`components/slider`) is APG's slider, and `defaultValue={[20, 80]}` with `thumbLabels={['Lowest', 'Highest']}` is the multi-thumb one — **the value's own shape is how many thumbs there are**, so there is no second prop saying which kind this is and no narrowing at the call site: a `number` in means a `number` back, an array means an array. A thumb may meet its neighbour and never pass it. `<Progress label="Upload" value={62} />` (`components/progress`) is the `role="progressbar"` beside it, and **it renders on a server** — no state, no effect, no measurement. **Neither is the native element, and that is the one place the platform loses**: `<input type="range">` holds a single thumb, and it and `<progress>` both draw themselves with vendor pseudo-elements (`::-webkit-slider-thumb`) that no typed prop can reach — so `name` on a `Slider` is what keeps the form working, writing one hidden input per thumb. **Four things to know.** **The position is an inline style, and it is the only one**: a thumb's offset is per _frame_ of a drag, so a class for it would be a rule per frame that is never freed — the exception `useAnchorPosition`'s anchor name and the travelling tab indicator already take, and the reason a `ProgressRing` (`components/chart`) can round its fraction into a class instead, since nobody drags a ring. **No `value` on a `Progress` is a state, not a zero**: `aria-valuenow` is omitted rather than reported as `0`, and the bar sweeps — a named duration, so it stops itself under `prefers-reduced-motion`. **A `role="slider"` has to be named**: `label` names the thumb on a single slider and the `role="group"` on a range, where `thumbLabels` names the thumbs one at a time, and `format` writes `aria-valuetext` for a value a bare number does not read as. And **it mirrors for free** — the fill and the thumbs are placed with `inset-inline-start`, so a right-to-left page draws the minimum on the right with nothing declared twice; only the sideways arrows swap (Left is the increase), and Up/Down never do, because the block axis has no reading order. `onValueChange` fires on every step and `onValueCommit` once at the end, both with `{ reason: 'pointer' | 'keyboard' }`. **A press travels and a nudge does not**: a press on the track is the one move the eye has to follow, so the thumb animates the whole way, while every other move — a drag, an arrow key, a held arrow — takes a short 60ms **`ease-out`** travel on both the thumb and the fill, which is the `tracking` variant and which both parts must carry or they come apart. Short rather than _off_, because off is exact and **steps**: a value on a grid can only be at its grid positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. Eased **out** rather than linear or eased, because a repeat restarts the transition every 33ms so only its first half is ever seen — on this curve that half is the straight part, so it glides while the key is held and still decelerates on the last transition, the only one allowed to finish; plain `ease` replays its slow-in and pulses, and `linear` rides more than a full step behind the value and stops at full speed. And an arrow key is a nudge rather than a jump, so it never takes the press travel at all — one tap spending 250ms on 3.2px was itself the lag. `step` is the real dial — `step={0}` is continuous and needs no interpolation at all. Styling is the `slider`/`slider.track`/`slider.fill`/`slider.thumb` tree and `progress`/`progress.fill`, whose `indeterminate` variant is the sweep; `disabled` is `aria-disabled` here rather than the attribute, since a div takes none and a value nobody can reach is a value nobody can read.
|
|
60
60
|
|
|
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.
|
|
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. **While a modal `Dialog` is open the stack moves inside it**, shown after it — everything outside an open modal is inert, so a toast left on the page would sit under the backdrop, unpressable and silent — and moves back out when the dialog closes; Escape on a toast there dismisses the toast, not the dialog. **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
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
|
|
|
@@ -97,3 +97,5 @@ globs: '**/*.{ts,tsx,jsx}'
|
|
|
97
97
|
Full reference: `src/BOX_KITE_AI_CONTEXT.md` or invoke `/box-kite` skill.
|
|
98
98
|
|
|
99
99
|
54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
|
|
100
|
+
|
|
101
|
+
55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
|
|
@@ -101,7 +101,7 @@ Detect the package manager from the lock file rather than asking:
|
|
|
101
101
|
|
|
102
102
|
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. **A token carries its own value as the fallback** — `bgColor="blue-500"` is `var(--blue-500,oklch(62.3% .214 259.8))`, and `var(--blue-500)` in these rules is that, shortened — so nothing is declared in `:root`: your own stylesheet reads a token through a variable of yours (`vars={{ brand: 'violet-500' }}` on an ancestor), while a hand-written `var(--violet-500)` inside a Box value (`fill`, `vars`, `css`, a `Box.extend()` variable) gets the fallback too. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
103
103
|
|
|
104
|
-
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
104
|
+
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, a `Box.extend()` colour, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
105
105
|
|
|
106
106
|
22. **Nine filters stack too, and a mask is a gradient.** `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia` and `dropShadow` each set their own layer of one composed `filter`, so a blur and a desaturation coexist. **A number is the function's own unit** — `brightness={110}` is a percentage, `hueRotate={90}` degrees, `blur={3}` pixels — and `blur` takes Tailwind's scale as well (`xs` 4px … `xxxl` 64px). `dropShadow` (`xs`…`xxl`, with `dropShadowColor`) is cast by the **shape**, where `shadow` draws a rectangle; `none` clears one function. The nine `backdrop*` props are the same functions behind the element — `backdropBlur="sm" backdropSaturate={180}` over something translucent is glassmorphism — and they and the older `backdropFilter` are the same property, so use one. **`maskImage` takes the `bgGradient` record** and reads its alpha: `maskImage={{ linear: 'b', colors: ['black', 'transparent'] }}` fades an edge, `url(#id)` masks by a shape. **`bgClip="text"` needs `color="transparent"`** beside it, and together they are how a gradient becomes type
|
|
107
107
|
|
|
@@ -117,11 +117,11 @@ Detect the package manager from the lock file rather than asking:
|
|
|
117
117
|
|
|
118
118
|
28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
|
|
119
119
|
|
|
120
|
-
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus
|
|
120
|
+
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
|
|
121
121
|
|
|
122
122
|
30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
|
|
123
123
|
|
|
124
|
-
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
124
|
+
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. **Everything outside an open modal is inert**, a top-layer layer included: a `<Toaster>` moves its stack inside the dialog for that reason, but a `Tooltip` or `Dropdown` declared outside it and left open as it opens stays inert until it closes. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
125
125
|
|
|
126
126
|
32. **A menu button is `<Menu>`, and a submenu is a popover nested in it.** `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>` (`components/menu`) is APG's menu button on the Popover API: `role="menu"`, `menuitem`/`menuitemcheckbox`/`menuitemradio` with `aria-checked`, `role="group"` around a titled section and `role="separator"` between them. The parts are `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group` (a `label` names the group, so its items are read as a set), `Menu.Separator` and `Menu.Sub`, which renders its own item — label, chevron, `aria-haspopup="menu"`, `aria-expanded` — and the menu beside it. **The platform's half is the top layer, light dismiss and the toggle**: Escape closes the innermost menu first, one layer per press, and a press outside closes the lot, with no portal and no `z-index` anywhere. **What is written here is the keyboard and the ARIA**: Down/Up wrap, Home/End, typeahead (a letter again cycles through the items starting with it), Right opens a submenu and Left closes it — the _reading order_, so the two swap in a right-to-left menu and the chevron turns round with them. **Four things to know.** A `disabled` item is `aria-disabled` and **stays focusable**, which is APG's rule: the `disabled` attribute would take it out of the keyboard's reach and silence it. **A command closes the menu and a state does not** — `Menu.Item` closes on select, `Menu.CheckboxItem` and `Menu.RadioItem` do not, so several boxes can be ticked in one visit, and `closeOnSelect` swaps either default. `onOpenChange(open, { reason })` adds **`select`** (an item was chosen — the transition worth telling apart) and **`tab`** to the four a layer always reports. And the menu is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>`, the items render whether or not it has been opened, and the top layer's one cost applies to it as it does to `<Popover>` (rule 30): a menu keeps the side it opened on. Placement is `side`/`align`/`offset`/`flip` on both the menu and each `Menu.Sub`, whose defaults are `side="end"` and `offset={0}` so a submenu abuts the menu it came out of. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in one place. The semantic `<menu>` element is **`MenuList`** from `components/semantics` now — `Menu` there is deprecated, because two exports of that name are one import away from the wrong component.
|
|
127
127
|
|
|
@@ -131,7 +131,7 @@ Detect the package manager from the lock file rather than asking:
|
|
|
131
131
|
|
|
132
132
|
35. **A slider is `<Slider>`, a bar is `<Progress>`, and a number in is a number out.** `<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />` (`components/slider`) is APG's slider, and `defaultValue={[20, 80]}` with `thumbLabels={['Lowest', 'Highest']}` is the multi-thumb one — **the value's own shape is how many thumbs there are**, so there is no second prop saying which kind this is and no narrowing at the call site: a `number` in means a `number` back, an array means an array. A thumb may meet its neighbour and never pass it. `<Progress label="Upload" value={62} />` (`components/progress`) is the `role="progressbar"` beside it, and **it renders on a server** — no state, no effect, no measurement. **Neither is the native element, and that is the one place the platform loses**: `<input type="range">` holds a single thumb, and it and `<progress>` both draw themselves with vendor pseudo-elements (`::-webkit-slider-thumb`) that no typed prop can reach — so `name` on a `Slider` is what keeps the form working, writing one hidden input per thumb. **Four things to know.** **The position is an inline style, and it is the only one**: a thumb's offset is per _frame_ of a drag, so a class for it would be a rule per frame that is never freed — the exception `useAnchorPosition`'s anchor name and the travelling tab indicator already take, and the reason a `ProgressRing` (`components/chart`) can round its fraction into a class instead, since nobody drags a ring. **No `value` on a `Progress` is a state, not a zero**: `aria-valuenow` is omitted rather than reported as `0`, and the bar sweeps — a named duration, so it stops itself under `prefers-reduced-motion`. **A `role="slider"` has to be named**: `label` names the thumb on a single slider and the `role="group"` on a range, where `thumbLabels` names the thumbs one at a time, and `format` writes `aria-valuetext` for a value a bare number does not read as. And **it mirrors for free** — the fill and the thumbs are placed with `inset-inline-start`, so a right-to-left page draws the minimum on the right with nothing declared twice; only the sideways arrows swap (Left is the increase), and Up/Down never do, because the block axis has no reading order. `onValueChange` fires on every step and `onValueCommit` once at the end, both with `{ reason: 'pointer' | 'keyboard' }`. **A press travels and a nudge does not**: a press on the track is the one move the eye has to follow, so the thumb animates the whole way, while every other move — a drag, an arrow key, a held arrow — takes a short 60ms **`ease-out`** travel on both the thumb and the fill, which is the `tracking` variant and which both parts must carry or they come apart. Short rather than _off_, because off is exact and **steps**: a value on a grid can only be at its grid positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. Eased **out** rather than linear or eased, because a repeat restarts the transition every 33ms so only its first half is ever seen — on this curve that half is the straight part, so it glides while the key is held and still decelerates on the last transition, the only one allowed to finish; plain `ease` replays its slow-in and pulses, and `linear` rides more than a full step behind the value and stops at full speed. And an arrow key is a nudge rather than a jump, so it never takes the press travel at all — one tap spending 250ms on 3.2px was itself the lag. `step` is the real dial — `step={0}` is continuous and needs no interpolation at all. Styling is the `slider`/`slider.track`/`slider.fill`/`slider.thumb` tree and `progress`/`progress.fill`, whose `indeterminate` variant is the sweep; `disabled` is `aria-disabled` here rather than the attribute, since a div takes none and a value nobody can reach is a value nobody can read.
|
|
133
133
|
|
|
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.
|
|
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. **While a modal `Dialog` is open the stack moves inside it**, shown after it — everything outside an open modal is inert, so a toast left on the page would sit under the backdrop, unpressable and silent — and moves back out when the dialog closes; Escape on a toast there dismisses the toast, not the dialog. **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
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
|
|
|
@@ -170,3 +170,5 @@ Detect the package manager from the lock file rather than asking:
|
|
|
170
170
|
Full reference: `references/` beside this file, and `docs/props.md` inside the package.
|
|
171
171
|
|
|
172
172
|
54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
|
|
173
|
+
|
|
174
|
+
55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
|
|
@@ -224,7 +224,7 @@ a region inserted together with its content is not reliably announced — and an
|
|
|
224
224
|
`role="alert"`, which is assertive; nothing else carries a region of its own, because the nearest one to a
|
|
225
225
|
change is the one that speaks. It is in the **top layer with no portal**, so it inherits the theme and the
|
|
226
226
|
direction around it, and it takes **no pointer events** while the toasts take them back, or a corner-sized
|
|
227
|
-
fixed strip would swallow every press in its gaps. **Timers stop on hover, on focus and in a background
|
|
227
|
+
fixed strip would swallow every press in its gaps. **While a modal `Dialog` is open the stack moves inside it**, shown after it — everything outside an open modal is inert, so a toast left on the page would sit under the backdrop, unpressable and silent — and moves back out when the dialog closes; Escape on a toast there dismisses the toast, not the dialog. **Timers stop on hover, on focus and in a background
|
|
228
228
|
tab** and resume where they left off (WCAG 2.2.1 — the only reason a toast may carry a control). And a
|
|
229
229
|
toast **never takes focus**: `F6` moves focus to the stack, Tab walks it, and Escape dismisses the toast
|
|
230
230
|
focus is in, handing focus back once the stack is empty.
|
|
@@ -19,7 +19,7 @@ The recipes, plus the parts of the library that are not props: server rendering,
|
|
|
19
19
|
**Config**: `Box.configure({ sink: 'cssom' | 'textContent' | 'string' | 'element', classNames: 'hashed' | 'readable' | 'stable' })` — before the first render.
|
|
20
20
|
**Floating layers**: `Overlay` (`components/overlay`) renders its children where it is declared, in the browser's **top layer** (`popover="manual"`, shown on mount) — so it beats every stacking context, escapes every clipped and transformed ancestor, and still inherits the theme, the custom properties and the direction around it. `manual` because it owns no dismissal, no ARIA and no open state. `Tooltip`, the `Dropdown` popup and the DataGrid column menu are all this. **Never declare one inside its trigger** — a listbox or a menu inside a `<button>` is unreachable content, and the portal used to hide that by moving it. Where the browser has no Popover API it is portalled into `#box-kite-portal` instead, which is the old behaviour. Reach for it only when you want positioning with no pattern on it; a panel with content in it is `Popover`.
|
|
21
21
|
**Form controls** (`components/checkbox`, `components/switch`, `components/radioGroup`, `components/radioButton`): real native inputs, so focus, the checked state and form submission come from the platform. `label` is the prop that matters — it renders the text inside a `<label>` wrapping the input, so there is no `htmlFor`/`id` pair and the whole row is clickable; `labelProps` styles that label, every other Box prop styles the control. `<Checkbox label="Accept the terms" name="terms" />`, `<Checkbox label="Select all" indeterminate />` (sets the DOM property **and** `aria-checked="mixed"`), `<Switch label="Email notifications" name="notify" />` (`role="switch"`; Space and Enter both toggle, Enter does not submit the form). `<RadioGroup label="Plan" name="plan" defaultValue="free" onValueChange={(v, { reason }) => …}>` with `<RadioGroup.Item value="free" label="Free" />` children: `role="radiogroup"` named by the label, one shared `name` (generated if omitted), `orientation` `vertical`|`horizontal`, arrows move **and** select with wrapping and disabled items skipped, and Tab enters/leaves the set once — the platform's own tab order is left alone. Style tree `radioGroup` (a `horizontal` variant) > `label`. `RadioButton` alone is a plain radio and still renders on a server. `checked`/`disabled`/`indeterminate` are each a state _and_ a pseudo-class style prop: pass a boolean, or `[state, styles]` for both.
|
|
22
|
-
**Popover** (`components/popover`): a panel on the platform's Popover API, and the answer whenever a layer has content a user interacts with. `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`. **No portal**: the panel is in the browser's top layer, so it beats every stacking context and clipped ancestor while keeping the theme, custom properties, direction and tab order it was declared in. Light dismiss (Escape, outside press) and focus return are the browser's; the component adds `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus
|
|
22
|
+
**Popover** (`components/popover`): a panel on the platform's Popover API, and the answer whenever a layer has content a user interacts with. `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`. **No portal**: the panel is in the browser's top layer, so it beats every stacking context and clipped ancestor while keeping the theme, custom properties, direction and tab order it was declared in. Light dismiss (Escape, outside press) and focus return are the browser's; the component adds `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto its first control (`autoFocus`, or `initialFocus` for another), the `side`/`align`/`offset`/`flip`/`matchWidth` placement props, and `onOpenChange(open, { reason })` with `trigger`|`escape`|`outside-pointer`|`imperative`. Traps: the trigger must be a **button** (the toggle is the browser's `popovertarget`) and must never carry an `onClick` toggle of your own — light dismiss closes on `pointerdown`, so the `click` after it reopens the panel; the panel is **always rendered** (closed is `display: none`), so the exit is `transitionBehavior="allow-discrete"` and never `<Presence>`, and expensive children want `{open ? … : null}`; and a **close cannot be refused**, since `beforetoggle` is cancelable opening only. Without the Popover API it falls back to a portalled `Overlay` + `useDismiss` + `useFocusReturn`.
|
|
23
23
|
**Dialog / AlertDialog** (`components/dialog`): a real `<dialog>` shown with `showModal()`, so **the top layer, the `::backdrop`, an inert page, Escape, focus containment and focus return are the platform's** — no focus trap, no `aria-hidden` sweep, no `z-index`. `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>`. Rendering a `Dialog.Title` writes `aria-labelledby` and a `Dialog.Description` writes `aria-describedby`; neither attribute exists when the part is absent, so a dialog with no visible heading wants `label`/`labelledBy`/`describedBy`. Props: `trigger` (optional — open it from anywhere), `open`/`defaultOpen`/`onOpenChange(open, { reason })` with `trigger`|`escape`|`outside-pointer`|`imperative`, `modal` (default `true`; `false` is `show()` — no backdrop, nothing inert, positioned against its containing block), `dismissible`, `lockScroll` (default: `modal`) and `initialFocus`. `AlertDialog` is a named export of the same module: `role="alertdialog"`, always modal, never dismissed by a press outside, and `initialFocus` on the least destructive action (APG). Traps, all measured: the dialog is **always rendered** (closed is `display: none`), so the exit is a CSS transition and never `<Presence>`; a **close cannot be refused** (`cancel` fires after the fact — use `dismissible={false}`); a press outside is `closedby="any"`, and on a browser without it the press is measured against the dialog's box, because **for a modal dialog the backdrop is the dialog element**; and the platform does not stop the page scrolling, which is what `lockScroll` is for — an `overflow: hidden` class on `<html>`, counted so nested dialogs cannot unlock it early.
|
|
24
24
|
|
|
25
25
|
**Menu** (`components/menu`): APG's menu button on the same Popover API, and a submenu is a popover nested inside it. `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>`. Parts: `Menu.Item`, `Menu.CheckboxItem` (`checked`/`defaultChecked`/`onCheckedChange`), `Menu.RadioGroup` (`value`/`defaultValue`/`onValueChange`) + `Menu.RadioItem value`, `Menu.Group label` (which names the group through `aria-labelledby`), `Menu.Separator`, and `Menu.Sub label`, which renders its own `menuitem` with `aria-haspopup="menu"` and `aria-expanded` plus the menu beside it. Props on the menu: `trigger`, `open`/`defaultOpen`/`onOpenChange(open, { reason })` with `select`|`tab` beside `trigger`|`escape`|`outside-pointer`|`imperative`, `label`/`labelledBy` (left out, the menu takes its trigger's name — APG's rule), and the four placement props `side`/`align`/`offset`/`flip`, which `Menu.Sub` takes too (`side="end"`, `offset={0}` by default, so a submenu abuts its menu). The platform supplies the top layer, light dismiss — Escape closing the innermost menu first — and the toggle; the component supplies the roles, the keys (Down/Up wrapping, Home/End, typeahead where a letter again cycles, Right/Left for a submenu in **reading order**, so they swap in a right-to-left menu) and the focus moves the browser does not make: into the first item on open, and back onto a submenu's own item when it closes. Four things to know: a `disabled` item is `aria-disabled` and **stays focusable**, as APG asks; `Menu.Item` closes the menu on select while a checkbox or radio item does not, and `closeOnSelect` swaps either default; the menu is **always rendered** (closed is `display: none`), so the exit is a CSS transition and never `<Presence>`, and expensive items want gating with `{open ? … : null}`; and it inherits the top layer's one cost, keeping the side it opened on (see `Overlay`). The semantic `<menu>` element is `MenuList` from `components/semantics` now, since two exports named `Menu` are one import away from the wrong component.
|
|
@@ -6,7 +6,7 @@ Every prop by category, the six kinds of nesting, and the theme system. Read thi
|
|
|
6
6
|
|
|
7
7
|
**Spacing**: `p`/`px`/`py`/`pt`/`pr`/`pb`/`pl`/`ps`/`pe`, `m`/`mx`/`my`/`mt`/`mr`/`mb`/`ml`/`ms`/`me`, `gap` — the axis pair is already logical (`px` is `padding-inline`) and `ps`/`pe`, `ms`/`me` are its two sides
|
|
8
8
|
**Layout**: `display`, `d` (flex-direction), `wrap`, `ai` (align-items), `jc` (justify-content), `justifyItems`/`placeItems`/`placeContent`/`alignContent`/`alignSelf`/`justifySelf`, `flex`/`grow`/`shrink`, `container`/`containerName`/`containerType` (a query container — see `cq`). Every alignment prop also takes the **overflow-safe** forms — `'safe center'`, `'safe start'`, `'safe end'` and the `'unsafe …'` twins: `safe center` centres until the content stops fitting and then aligns to `start`, where plain `center` overflows both edges and the half above the scrollable origin cannot be reached at all. `aspectRatio` is `'square'` (1/1), `'video'` (16/9), a compact ratio (`'4/3'`), a number, or `'auto'`
|
|
9
|
-
**Sizing**: `width`/`height` — number (÷4=rem), `'auto'`, `'fit'` (100%), `'fit-screen'` (100vw/vh), fractions (`'1/2'`…), `'33%'`. `minWidth`/`maxWidth`/`minHeight`/`maxHeight` same. All accept `"5%"`.
|
|
9
|
+
**Sizing**: `width`/`height` — number (÷4=rem), `'auto'`, `'fit'` (100%), `'fit-screen'` (100vw/vh), fractions (`'1/2'`…), `'33%'`. `minWidth`/`maxWidth`/`minHeight`/`maxHeight` same. All accept `"5%"`. `height`/`minHeight` (and `fontSize`) also take `'control'` — the size standard in force, `var(--control-height,…)`, 40px by default — and `'control-compact'`/`'control-normal'`/`'control-large'`. Every built-in component is drawn at `compact` (32px), `normal` or `large` (48px) via `variant`, a size inherits, and a project declares its three once with `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on its root.
|
|
10
10
|
**Colors**: `bgColor`/`color`/`borderColor`/`outlineColor`/`fill`/`stroke` — Tailwind's OKLCH palette, 26 families × 11 steps `'50'`..`'950'` (slate/gray/zinc/neutral/stone/mauve/mist/olive/taupe/red/orange/amber/yellow/lime/green/emerald/teal/cyan/sky/blue/indigo/violet/purple/fuchsia/pink/rose), plus `'white'`/`'black'`/`'transparent'`/`'currentColor'`. **Opacity modifier on any colour value**: `bgColor="blue-500/40"` → `color-mix(in oklab, var(--blue-500) 40%, transparent)` — the mix wraps the _variable_, so it stays themed and shared; unlike `opacity` it fades one declaration, not the element. Works in `vars` and on a `Box.extend()` variable (`"brand/30"`); an unknown token or a percentage outside 0–100 emits nothing. Two more families, `accent` and `base` (copies of `indigo` and `gray`), are what every built-in component is drawn in; `vars={Box.palette({ accent: 'orange', base: 'stone' })}` repoints them for a subtree, by family name or with a ramp of your own
|
|
11
11
|
**Borders**: `b`/`bx`/`by`/`bt`/`br`/`bb`/`bl`/`bs`/`be` (px — `bs`/`be` are the logical sides), `borderRadius` (÷4) and its nine physical corners plus six logical ones (`borderRadiusStart`/`borderRadiusEnd`, and `borderRadiusStartStart`/`StartEnd`/`EndEnd`/`EndStart` — **block axis first, inline axis second**), `borderStyle`
|
|
12
12
|
**Text**: `fontSize` (÷16), `fontWeight`, `lineHeight` (px), `textAlign` (`'start'`/`'end'` follow the text direction where `'left'`/`'right'` do not)/`textDecoration`/`textTransform`/`whiteSpace`/`textOverflow`, `textWrap`
|
|
@@ -116,7 +116,7 @@ themed, takes the opacity modifier and shares one class. The key names the kind
|
|
|
116
116
|
`{ linear: 'r', colors: ['blue-500', 'pink-500'] }` (a direction `t`/`tr`/`r`/`br`/`b`/`bl`/`l`/`tl`, or a number of
|
|
117
117
|
degrees — `{ linear: 135, … }`), `{ radial: 'circle' | 'ellipse' | true, at: 'top left', … }`, `{ conic: 45 | true, … }`.
|
|
118
118
|
Exactly one kind; `at` centres a radial or conic gradient, never a linear one. A stop is any colour value —
|
|
119
|
-
`'blue-500/40'`, `'transparent'`, `'var(--chart-1)'` — or a `[colour, position]` pair (`['sky-500', '20%']`), **two
|
|
119
|
+
`'blue-500/40'`, `'transparent'`, `'var(--chart-1)'`, a `Box.extend()` colour — or a `[colour, position]` pair (`['sky-500', '20%']`), **two
|
|
120
120
|
minimum**. `interpolate` names the space: `'srgb'`, `'hsl'`, `'oklab'`, `'oklch'`, `'hsl-longer'`, `'oklch-longer'` —
|
|
121
121
|
`'oklch'` is what keeps two stops out of the grey middle sRGB drags them through, and a `-longer` hue turns two stops
|
|
122
122
|
into a spectrum. The record is judged **whole**: one unknown stop, one misspelt key (`interpolat`), two kinds at once,
|
|
@@ -36,7 +36,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
36
36
|
|
|
37
37
|
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. **A token carries its own value as the fallback** — `bgColor="blue-500"` is `var(--blue-500,oklch(62.3% .214 259.8))`, and `var(--blue-500)` in these rules is that, shortened — so nothing is declared in `:root`: your own stylesheet reads a token through a variable of yours (`vars={{ brand: 'violet-500' }}` on an ancestor), while a hand-written `var(--violet-500)` inside a Box value (`fill`, `vars`, `css`, a `Box.extend()` variable) gets the fallback too. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
38
38
|
|
|
39
|
-
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
39
|
+
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, a `Box.extend()` colour, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
40
40
|
|
|
41
41
|
22. **Nine filters stack too, and a mask is a gradient.** `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia` and `dropShadow` each set their own layer of one composed `filter`, so a blur and a desaturation coexist. **A number is the function's own unit** — `brightness={110}` is a percentage, `hueRotate={90}` degrees, `blur={3}` pixels — and `blur` takes Tailwind's scale as well (`xs` 4px … `xxxl` 64px). `dropShadow` (`xs`…`xxl`, with `dropShadowColor`) is cast by the **shape**, where `shadow` draws a rectangle; `none` clears one function. The nine `backdrop*` props are the same functions behind the element — `backdropBlur="sm" backdropSaturate={180}` over something translucent is glassmorphism — and they and the older `backdropFilter` are the same property, so use one. **`maskImage` takes the `bgGradient` record** and reads its alpha: `maskImage={{ linear: 'b', colors: ['black', 'transparent'] }}` fades an edge, `url(#id)` masks by a shape. **`bgClip="text"` needs `color="transparent"`** beside it, and together they are how a gradient becomes type
|
|
42
42
|
|
|
@@ -52,11 +52,11 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
52
52
|
|
|
53
53
|
28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
|
|
54
54
|
|
|
55
|
-
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus
|
|
55
|
+
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
|
|
56
56
|
|
|
57
57
|
30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
|
|
58
58
|
|
|
59
|
-
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
59
|
+
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. **Everything outside an open modal is inert**, a top-layer layer included: a `<Toaster>` moves its stack inside the dialog for that reason, but a `Tooltip` or `Dropdown` declared outside it and left open as it opens stays inert until it closes. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
60
60
|
|
|
61
61
|
32. **A menu button is `<Menu>`, and a submenu is a popover nested in it.** `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>` (`components/menu`) is APG's menu button on the Popover API: `role="menu"`, `menuitem`/`menuitemcheckbox`/`menuitemradio` with `aria-checked`, `role="group"` around a titled section and `role="separator"` between them. The parts are `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group` (a `label` names the group, so its items are read as a set), `Menu.Separator` and `Menu.Sub`, which renders its own item — label, chevron, `aria-haspopup="menu"`, `aria-expanded` — and the menu beside it. **The platform's half is the top layer, light dismiss and the toggle**: Escape closes the innermost menu first, one layer per press, and a press outside closes the lot, with no portal and no `z-index` anywhere. **What is written here is the keyboard and the ARIA**: Down/Up wrap, Home/End, typeahead (a letter again cycles through the items starting with it), Right opens a submenu and Left closes it — the _reading order_, so the two swap in a right-to-left menu and the chevron turns round with them. **Four things to know.** A `disabled` item is `aria-disabled` and **stays focusable**, which is APG's rule: the `disabled` attribute would take it out of the keyboard's reach and silence it. **A command closes the menu and a state does not** — `Menu.Item` closes on select, `Menu.CheckboxItem` and `Menu.RadioItem` do not, so several boxes can be ticked in one visit, and `closeOnSelect` swaps either default. `onOpenChange(open, { reason })` adds **`select`** (an item was chosen — the transition worth telling apart) and **`tab`** to the four a layer always reports. And the menu is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>`, the items render whether or not it has been opened, and the top layer's one cost applies to it as it does to `<Popover>` (rule 30): a menu keeps the side it opened on. Placement is `side`/`align`/`offset`/`flip` on both the menu and each `Menu.Sub`, whose defaults are `side="end"` and `offset={0}` so a submenu abuts the menu it came out of. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in one place. The semantic `<menu>` element is **`MenuList`** from `components/semantics` now — `Menu` there is deprecated, because two exports of that name are one import away from the wrong component.
|
|
62
62
|
|
|
@@ -66,7 +66,7 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
66
66
|
|
|
67
67
|
35. **A slider is `<Slider>`, a bar is `<Progress>`, and a number in is a number out.** `<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />` (`components/slider`) is APG's slider, and `defaultValue={[20, 80]}` with `thumbLabels={['Lowest', 'Highest']}` is the multi-thumb one — **the value's own shape is how many thumbs there are**, so there is no second prop saying which kind this is and no narrowing at the call site: a `number` in means a `number` back, an array means an array. A thumb may meet its neighbour and never pass it. `<Progress label="Upload" value={62} />` (`components/progress`) is the `role="progressbar"` beside it, and **it renders on a server** — no state, no effect, no measurement. **Neither is the native element, and that is the one place the platform loses**: `<input type="range">` holds a single thumb, and it and `<progress>` both draw themselves with vendor pseudo-elements (`::-webkit-slider-thumb`) that no typed prop can reach — so `name` on a `Slider` is what keeps the form working, writing one hidden input per thumb. **Four things to know.** **The position is an inline style, and it is the only one**: a thumb's offset is per _frame_ of a drag, so a class for it would be a rule per frame that is never freed — the exception `useAnchorPosition`'s anchor name and the travelling tab indicator already take, and the reason a `ProgressRing` (`components/chart`) can round its fraction into a class instead, since nobody drags a ring. **No `value` on a `Progress` is a state, not a zero**: `aria-valuenow` is omitted rather than reported as `0`, and the bar sweeps — a named duration, so it stops itself under `prefers-reduced-motion`. **A `role="slider"` has to be named**: `label` names the thumb on a single slider and the `role="group"` on a range, where `thumbLabels` names the thumbs one at a time, and `format` writes `aria-valuetext` for a value a bare number does not read as. And **it mirrors for free** — the fill and the thumbs are placed with `inset-inline-start`, so a right-to-left page draws the minimum on the right with nothing declared twice; only the sideways arrows swap (Left is the increase), and Up/Down never do, because the block axis has no reading order. `onValueChange` fires on every step and `onValueCommit` once at the end, both with `{ reason: 'pointer' | 'keyboard' }`. **A press travels and a nudge does not**: a press on the track is the one move the eye has to follow, so the thumb animates the whole way, while every other move — a drag, an arrow key, a held arrow — takes a short 60ms **`ease-out`** travel on both the thumb and the fill, which is the `tracking` variant and which both parts must carry or they come apart. Short rather than _off_, because off is exact and **steps**: a value on a grid can only be at its grid positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. Eased **out** rather than linear or eased, because a repeat restarts the transition every 33ms so only its first half is ever seen — on this curve that half is the straight part, so it glides while the key is held and still decelerates on the last transition, the only one allowed to finish; plain `ease` replays its slow-in and pulses, and `linear` rides more than a full step behind the value and stops at full speed. And an arrow key is a nudge rather than a jump, so it never takes the press travel at all — one tap spending 250ms on 3.2px was itself the lag. `step` is the real dial — `step={0}` is continuous and needs no interpolation at all. Styling is the `slider`/`slider.track`/`slider.fill`/`slider.thumb` tree and `progress`/`progress.fill`, whose `indeterminate` variant is the sweep; `disabled` is `aria-disabled` here rather than the attribute, since a div takes none and a value nobody can reach is a value nobody can read.
|
|
68
68
|
|
|
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.
|
|
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. **While a modal `Dialog` is open the stack moves inside it**, shown after it — everything outside an open modal is inert, so a toast left on the page would sit under the backdrop, unpressable and silent — and moves back out when the dialog closes; Escape on a toast there dismisses the toast, not the dialog. **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
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
|
|
|
@@ -105,3 +105,5 @@ This library is not in your training data: it was renamed at 1.0.0 (September 20
|
|
|
105
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.
|
|
106
106
|
|
|
107
107
|
54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
|
|
108
|
+
|
|
109
|
+
55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
|
package/AGENTS.md
CHANGED
|
@@ -68,7 +68,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
68
68
|
|
|
69
69
|
20. **An opacity is part of the colour, not a second prop**: `bgColor="blue-500/40"`, `borderColor="black/10"`, `color="currentColor/60"` — a slash and a percentage on any colour value, on every colour prop and on a `vars` entry. It compiles to `color-mix(in oklab, var(--blue-500) 40%, transparent)`, so the token stays a variable (still themed, still one shared class) — unlike `opacity`, which fades the element, its text and its children. **A token carries its own value as the fallback** — `bgColor="blue-500"` is `var(--blue-500,oklch(62.3% .214 259.8))`, and `var(--blue-500)` in these rules is that, shortened — so nothing is declared in `:root`: your own stylesheet reads a token through a variable of yours (`vars={{ brand: 'violet-500' }}` on an ancestor), while a hand-written `var(--violet-500)` inside a Box value (`fill`, `vars`, `css`, a `Box.extend()` variable) gets the fallback too. The palette itself is **Tailwind 4.3's, in OKLCH**: twenty-six families (the five neutrals, `mauve`/`mist`/`olive`/`taupe`, and seventeen hues) of eleven steps, `50`–`950`. A token the palette does not have, or a percentage outside 0–100, produces no rule and no class name; a variable from `Box.extend()` takes the modifier too
|
|
70
70
|
|
|
71
|
-
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
71
|
+
21. **A gradient is a value, and a shadow is a stack.** `bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }}` — the key names the kind (`linear` a direction `t`/`tr`/`r`/… or an angle in degrees, `radial` a shape, `conic` a start angle), `colors` are the stops in order, and a stop is any colour value: a token, `blue-500/40`, `var(--chart-1)`, a `Box.extend()` colour, or a `[colour, position]` pair. `at` centres a radial or conic one, `interpolate="oklch"` keeps two stops out of the grey middle sRGB drags them through, and `oklch-longer` turns two stops into a spectrum. Two stops minimum, and the record is judged **whole** — one bad stop or one unknown key and the value emits nothing. It writes `background-image`, so it and `bgImage` are the same property. **Four shadows stack** rather than overwriting one `box-shadow`: `shadow` (`xxs`…`xxl` on Tailwind's scale, or the presets `small`/`medium`/`large`), `insetShadow` (`xxs`/`xs`/`sm`), and `ring`/`insetRing`, which are a **width in px** (`ring={2}`) that follows `borderRadius` and costs no layout, unlike `outline`. Each takes a colour of its own (`shadowColor`, `ringColor`, …) which shows nothing until the layer is painted; `none` — or `0` on a ring — clears just that layer. `textShadow` is `xxs`…`lg` with `textShadowColor`, and `transition="shadow"` covers both properties
|
|
72
72
|
|
|
73
73
|
22. **Nine filters stack too, and a mask is a gradient.** `blur`, `brightness`, `contrast`, `grayscale`, `hueRotate`, `invert`, `saturate`, `sepia` and `dropShadow` each set their own layer of one composed `filter`, so a blur and a desaturation coexist. **A number is the function's own unit** — `brightness={110}` is a percentage, `hueRotate={90}` degrees, `blur={3}` pixels — and `blur` takes Tailwind's scale as well (`xs` 4px … `xxxl` 64px). `dropShadow` (`xs`…`xxl`, with `dropShadowColor`) is cast by the **shape**, where `shadow` draws a rectangle; `none` clears one function. The nine `backdrop*` props are the same functions behind the element — `backdropBlur="sm" backdropSaturate={180}` over something translucent is glassmorphism — and they and the older `backdropFilter` are the same property, so use one. **`maskImage` takes the `bgGradient` record** and reads its alpha: `maskImage={{ linear: 'b', colors: ['black', 'transparent'] }}` fades an edge, `url(#id)` masks by a shape. **`bgClip="text"` needs `color="transparent"`** beside it, and together they are how a gradient becomes type
|
|
74
74
|
|
|
@@ -84,11 +84,11 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
84
84
|
|
|
85
85
|
28. **A length can come off the anchor, and `useAnchorPosition` writes the whole placement for you.** Every sizing prop takes an `anchor-size()` value and every single-side inset prop an `anchor()` one: `minWidth="anchor-size(width)"` is a popup as wide as its trigger with nothing measured, `maxHeight="anchor-size(height, 20rem)"` falls back when there is no anchor, and `top="anchor(bottom)"`/`insetStart="anchor(left)"` place a layer by hand where the 3×3 grid has no cell for it. The name inside is optional (it defaults to `positionAnchor`'s) and takes the same optional `--`; the axis names are `width`/`height`/`block`/`inline` and their `self-` twins, the edges `top`/`right`/`bottom`/`left`/`start`/`end`/`center`/`inside`/`outside` or a percentage. **The hook is `useAnchorPosition` from `@box-kite/react/anchor`**: `const { css, anchorProps, layerProps } = useAnchorPosition({ side: 'bottom', align: 'start', offset: 2, matchWidth: true })`, spread on trigger and layer. `side` is `top`/`bottom` (block axis) or `start`/`end` (inline, so it mirrors in a right-to-left page), `align` lines the layer up with one of the anchor's edges, `offset` is the ÷4 scale, and `flip` is on by default — offering the side's own axis, the alignment's and both, because a candidate has to fit on _both_ axes and a lone `flip-block` does nothing for a layer that overflows the cross one. **The pre-built layers take the same four**: `Overlay` and `Tooltip` place with `side`/`align`/`offset`/`flip` (plus `anchor`, the element to hang off, and `matchWidth`), and the `Dropdown` popup and the DataGrid column menu are those. A gap is `offset`, not a margin or a translate. Where the browser has no anchor positioning it measures instead — same model, flip and shift — and `css` says which path ran. **Three things to know**: the layer is `position: fixed`, so it escapes every `overflow: hidden` ancestor without a portal, but _not_ a transformed one and not the page's stacking order (`Overlay` is this hook plus the top layer, and is still the answer when the layer must come out on top of everything); a flip is **sticky** — the browser keeps it until the layer is laid out afresh, so a popup that mounts when it opens always picks the side that fits while one that stays mounted keeps the side it first chose, and in the **top layer** it is never re-evaluated on scroll at all (rule 30); and the anchor's **name is an inline style rather than a prop**, because an identity is per instance and a class for it would be a rule per instance that is never freed. Which side the browser chose is `trackSide`, and it arrives too late for an entrance: reading the used `position-area` _is_ the style resolution `@starting-style` computes from, so only an exit can depend on it.
|
|
86
86
|
|
|
87
|
-
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus
|
|
87
|
+
29. **A panel with content in it is `<Popover>`, and it needs no portal.** `<Popover label="Filters" trigger={(t) => <Button {...t}>Filters</Button>}><Checkbox label="Only mine" /></Popover>` (`components/popover`) is the platform's Popover API: the panel is in the browser's **top layer**, so it paints above every stacking context and outside every clipped ancestor, and **light dismiss (Escape, an outside press) and focus return are the browser's** rather than something to wire up. Note the shape — `trigger` is the render prop and the **children are the panel**, the other way round from `Tooltip`, whose `content` is the description and whose children are the trigger. It takes `useAnchorPosition`'s four placement props (`side`/`align`/`offset`/`flip`, plus `matchWidth`), and it supplies what the attribute does not: `role="dialog"` named by `label`/`labelledBy`, `aria-expanded`/`aria-haspopup`/`aria-controls` on the trigger, focus onto the panel's first control on open — a link field selected on focus is copyable at once — or onto `initialFocus` (a ref) where the first control is the wrong landing, and `onOpenChange(open, { reason })` with `trigger`/`escape`/`outside-pointer`/`imperative`. **Four things to know, all measured.** The **trigger has to be a button**, because the toggle is the browser's own `popovertarget` — and never add an `onClick` toggle of your own: light dismiss closes on `pointerdown`, so the `click` after it reads "closed" and reopens the panel, and pressing the trigger would never close it. The **panel is always rendered**, closed being `display: none` rather than unmounted, so the exit is a CSS transition (`transitionBehavior="allow-discrete"`, already in its component styles) and **never `<Presence>`** — gate expensive children yourself with `{open ? … : null}`. A **close cannot be refused**: `beforetoggle` is cancelable opening and not closing, so a controlled `<Popover open>` hears about a dismissal only afterwards and keeping `open` true re-shows the panel. And because nothing is portalled, the panel keeps the theme, the custom properties, the direction and the tab order it was declared in — which `Overlay` gets the same way since rule 30. Use `Overlay` only for positioning with no pattern on it; where the browser has no Popover API `<Popover>` falls back to a portalled one, plus `useDismiss` and `useFocusReturn`.
|
|
88
88
|
|
|
89
89
|
30. **Every floating layer is in the top layer now, and none of them is portalled.** `Overlay` — and so `Tooltip`, the `Dropdown` popup and the DataGrid column menu, which are all built on it — renders where it is declared and carries `popover="manual"`, shown as soon as it mounts. `manual`, not `auto`, because a layer owns no dismissal and light dismiss is a pattern: `<Popover>` is the `auto` one (rule 29), and `Tooltip` and `Dropdown` each keep the stricter dismissal they already implement — a tooltip's Escape has to _last_ (WCAG 1.4.13), and a JS-toggled trigger fights light dismiss. Four things follow, all measured in Chrome 152. The layer beats a `z-index: 9999` sibling and a transformed ancestor, where a plain `position: fixed` layer at the same coordinates loses both. It **inherits** the theme, the custom properties and the direction around it, so a local `Box.Theme` finally reaches inside a dropdown and there is no `dir` to copy across. The tab order follows the markup, so a layer declared after its trigger is what Tab reaches next. And a press inside it is a press inside whatever popover it was _declared_ in, so **a `Dropdown` inside a `Popover` panel no longer dismisses the panel** — the bug a portal caused by moving the popup to the body, where every press in it read as outside. **The one cost, measured:** a layer in the top layer **keeps the side it chose when it opened** — Chrome re-evaluates `position-try-fallbacks` on scroll for an ordinary positioned element and never for a top-layer one, so a layer left open while the page scrolls does not flip when its side runs out of room and slides past the viewport edge instead. Every _open_ picks the right side, since a layer that mounts when it opens is laid out for the first time then; only a scroll _while_ open is affected, nothing but leaving and re-entering the top layer re-arms it, and the portal fallback (which measures) does not have it. It applies to `<Popover>` too. Close a layer if the page can scroll far underneath it. The trap is the other side of the same coin: **never declare a layer inside its trigger.** It used to be legal because the portal moved it before a browser saw it, and now a `role="listbox"` or a `role="menu"` inside a `<button>` is unreachable content — put it beside the trigger instead. `<Presence>` still owns the mount, so a closed dropdown renders none of its options and the exit still runs (a top-layer element transitions normally); where the browser has no Popover API the portal comes back, with everything it used to cost.
|
|
90
90
|
|
|
91
|
-
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
91
|
+
31. **A modal is `<Dialog>`, and the platform is the whole pattern.** `<Dialog trigger={(t) => <Button {...t}>Rename</Button>}><Dialog.Title>Rename this view</Dialog.Title><Textbox name="name" /></Dialog>` (`components/dialog`) is a real `<dialog>` shown with `showModal()`, which supplies **the top layer, a `::backdrop`, an inert page behind it, Escape, focus containment and focus return** — so the focus trap, the `aria-hidden` sweep and the `z-index` strategy every other library ships are all absent here. `<AlertDialog>` (a named export of the same module) is `role="alertdialog"`, always modal and never dismissed by a press outside, because a decision that can be clicked away is one the user did not make; Escape still closes it, and `initialFocus` is what puts focus on the least destructive action, which is APG's rule for it. **Rendering a `Dialog.Title` is what names the dialog** — it writes `aria-labelledby`, and a `Dialog.Description` writes `aria-describedby`; neither attribute is written when the part is absent, so `label`/`labelledBy`/`describedBy` are the answer for a dialog that shows no heading. It takes `modal` (default `true` — `false` is `show()`: no backdrop, nothing inert, and the UA positions it against its containing block), `dismissible`, `lockScroll` (default: whatever `modal` is) and `onOpenChange(open, { reason })` with the same four reasons `<Popover>` uses, `imperative` covering a `close()` call and a `<form method="dialog">` submit. **Four things to know, all measured in Chrome 152.** The dialog is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>` — same as `<Popover>`. A **close cannot be refused**: `cancel` is cancelable but the browser has already closed the dialog by the time `onOpenChange` runs, so `dismissible={false}` is how a decision is made unavoidable. **Everything outside an open modal is inert**, a top-layer layer included: a `<Toaster>` moves its stack inside the dialog for that reason, but a `Tooltip` or `Dropdown` declared outside it and left open as it opens stays inert until it closes. A press outside is `closedby="any"`, written for you — and where the browser has not got it yet the press is measured against the dialog's own box, because **for a modal dialog the backdrop _is_ the dialog element** and every containment test calls a press on it inside. And the platform does **not** stop the page scrolling, which is why `lockScroll` exists at all: it is an `overflow: hidden` class on `<html>`, held by a counter so an inner dialog closing does not unlock the page under an outer one.
|
|
92
92
|
|
|
93
93
|
32. **A menu button is `<Menu>`, and a submenu is a popover nested in it.** `<Menu trigger={(t) => <Button {...t}>Actions</Button>}><Menu.Item onSelect={duplicate}>Duplicate</Menu.Item><Menu.Separator /><Menu.Sub label="Share"><Menu.Item>Copy link</Menu.Item></Menu.Sub></Menu>` (`components/menu`) is APG's menu button on the Popover API: `role="menu"`, `menuitem`/`menuitemcheckbox`/`menuitemradio` with `aria-checked`, `role="group"` around a titled section and `role="separator"` between them. The parts are `Menu.Item`, `Menu.CheckboxItem`, `Menu.RadioGroup` + `Menu.RadioItem`, `Menu.Group` (a `label` names the group, so its items are read as a set), `Menu.Separator` and `Menu.Sub`, which renders its own item — label, chevron, `aria-haspopup="menu"`, `aria-expanded` — and the menu beside it. **The platform's half is the top layer, light dismiss and the toggle**: Escape closes the innermost menu first, one layer per press, and a press outside closes the lot, with no portal and no `z-index` anywhere. **What is written here is the keyboard and the ARIA**: Down/Up wrap, Home/End, typeahead (a letter again cycles through the items starting with it), Right opens a submenu and Left closes it — the _reading order_, so the two swap in a right-to-left menu and the chevron turns round with them. **Four things to know.** A `disabled` item is `aria-disabled` and **stays focusable**, which is APG's rule: the `disabled` attribute would take it out of the keyboard's reach and silence it. **A command closes the menu and a state does not** — `Menu.Item` closes on select, `Menu.CheckboxItem` and `Menu.RadioItem` do not, so several boxes can be ticked in one visit, and `closeOnSelect` swaps either default. `onOpenChange(open, { reason })` adds **`select`** (an item was chosen — the transition worth telling apart) and **`tab`** to the four a layer always reports. And the menu is **always rendered**, closed being `display: none`, so the exit is a CSS transition and never `<Presence>`, the items render whether or not it has been opened, and the top layer's one cost applies to it as it does to `<Popover>` (rule 30): a menu keeps the side it opened on. Placement is `side`/`align`/`offset`/`flip` on both the menu and each `Menu.Sub`, whose defaults are `side="end"` and `offset={0}` so a submenu abuts the menu it came out of. Opening a submenu moves focus into it, hover included, which is what keeps the highlight and the keyboard in one place. The semantic `<menu>` element is **`MenuList`** from `components/semantics` now — `Menu` there is deprecated, because two exports of that name are one import away from the wrong component.
|
|
94
94
|
|
|
@@ -98,7 +98,7 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
98
98
|
|
|
99
99
|
35. **A slider is `<Slider>`, a bar is `<Progress>`, and a number in is a number out.** `<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />` (`components/slider`) is APG's slider, and `defaultValue={[20, 80]}` with `thumbLabels={['Lowest', 'Highest']}` is the multi-thumb one — **the value's own shape is how many thumbs there are**, so there is no second prop saying which kind this is and no narrowing at the call site: a `number` in means a `number` back, an array means an array. A thumb may meet its neighbour and never pass it. `<Progress label="Upload" value={62} />` (`components/progress`) is the `role="progressbar"` beside it, and **it renders on a server** — no state, no effect, no measurement. **Neither is the native element, and that is the one place the platform loses**: `<input type="range">` holds a single thumb, and it and `<progress>` both draw themselves with vendor pseudo-elements (`::-webkit-slider-thumb`) that no typed prop can reach — so `name` on a `Slider` is what keeps the form working, writing one hidden input per thumb. **Four things to know.** **The position is an inline style, and it is the only one**: a thumb's offset is per _frame_ of a drag, so a class for it would be a rule per frame that is never freed — the exception `useAnchorPosition`'s anchor name and the travelling tab indicator already take, and the reason a `ProgressRing` (`components/chart`) can round its fraction into a class instead, since nobody drags a ring. **No `value` on a `Progress` is a state, not a zero**: `aria-valuenow` is omitted rather than reported as `0`, and the bar sweeps — a named duration, so it stops itself under `prefers-reduced-motion`. **A `role="slider"` has to be named**: `label` names the thumb on a single slider and the `role="group"` on a range, where `thumbLabels` names the thumbs one at a time, and `format` writes `aria-valuetext` for a value a bare number does not read as. And **it mirrors for free** — the fill and the thumbs are placed with `inset-inline-start`, so a right-to-left page draws the minimum on the right with nothing declared twice; only the sideways arrows swap (Left is the increase), and Up/Down never do, because the block axis has no reading order. `onValueChange` fires on every step and `onValueCommit` once at the end, both with `{ reason: 'pointer' | 'keyboard' }`. **A press travels and a nudge does not**: a press on the track is the one move the eye has to follow, so the thumb animates the whole way, while every other move — a drag, an arrow key, a held arrow — takes a short 60ms **`ease-out`** travel on both the thumb and the fill, which is the `tracking` variant and which both parts must carry or they come apart. Short rather than _off_, because off is exact and **steps**: a value on a grid can only be at its grid positions, so a 1-in-100 slider moves 3.2px at a time on a 320px track. Eased **out** rather than linear or eased, because a repeat restarts the transition every 33ms so only its first half is ever seen — on this curve that half is the straight part, so it glides while the key is held and still decelerates on the last transition, the only one allowed to finish; plain `ease` replays its slow-in and pulses, and `linear` rides more than a full step behind the value and stops at full speed. And an arrow key is a nudge rather than a jump, so it never takes the press travel at all — one tap spending 250ms on 3.2px was itself the lag. `step` is the real dial — `step={0}` is continuous and needs no interpolation at all. Styling is the `slider`/`slider.track`/`slider.fill`/`slider.thumb` tree and `progress`/`progress.fill`, whose `indeterminate` variant is the sweep; `disabled` is `aria-disabled` here rather than the attribute, since a div takes none and a value nobody can reach is a value nobody can read.
|
|
100
100
|
|
|
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.
|
|
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. **While a modal `Dialog` is open the stack moves inside it**, shown after it — everything outside an open modal is inert, so a toast left on the page would sit under the backdrop, unpressable and silent — and moves back out when the dialog closes; Escape on a toast there dismisses the toast, not the dialog. **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
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
|
|
|
@@ -137,3 +137,5 @@ Every value there is a prop, and every prop value becomes one class the engine i
|
|
|
137
137
|
Full reference: `docs/props.md` and `BOX_KITE_AI_CONTEXT.md`, in this package.
|
|
138
138
|
|
|
139
139
|
54. **A design is two families and a `Box.components()` object, and it is chosen in CSS.** Every built-in component is drawn in `accent` and `base` — two palette families that start as copies of `indigo` and `gray` — so `vars={Box.palette({ accent: 'orange', base: 'stone' })}` on an ancestor recolours every component inside it, by family name or with a ramp of your own (`{ 500: 'oklch(…)' }`); write `accent-*`/`base-*` in your own components when they should follow, and a named family when the colour is the content. The rest of a design is `Box.components(styles, { design: 'ember' })`: merged over the app's own and applied under `data-design="ember"`, which `<Box.Theme design="classic" designStorageKey="design">` writes on `<html>` and `Box.useDesign()` switches — chosen by an attribute rather than by context, so a pre-paint script that copies the stored name onto `<html>` is all a prerendered page needs, and server and browser render the same classes. **Three things to know.** A design merges and cannot delete a key, so it takes something back off with that prop's `none` (`shadow: 'none'`, `bgGradient: 'none'` — never `bgImage: 'none'`, which loses `background-image` to `bgGradient`). A design nested inside another stops the outer one, but `vars` inherit, so a `data-design="classic"` preview inside an Ember page redeclares its own `Box.palette()`. And a Box under `<Box.Components value>` resolves against that registry and no named design — `Box.createComponents()` is still the answer for a subtree whose design React decides. A part extends like a component does (`trigger: { extends: 'button' }`, `'dialog.title'`), and `letterSpacing` takes the tracking names `tighter`…`widest` in `em`.
|
|
140
|
+
|
|
141
|
+
55. **Every component is drawn at one of three sizes — `compact`, `normal` (the default) and `large` — and a size is two CSS variables.** A field or a button (`Button`, `Textbox`, `Dropdown`/`Select`, `Combobox`/`TimeZoneSelect`, `DateField`/`DatePicker`) is `height="control"` and `fontSize="control"`: 32, 40 or 48px tall, 13, 14 or 16px text. An item or a row (a menu item, a dropdown option, a tab, an accordion header, a calendar day, a time in a picker) is that height as a `minHeight`, so a long label still wraps. A checkbox, a radio, a switch and a slider scale whole, and a progress bar changes its thickness. **`variant="compact"` or `variant="large"` on any of them sizes it and everything inside it** — `<Menu variant="compact">` makes every item compact, `<DatePicker variant="large">` its calendar too, because the size is `--control-height`/`--control-font-size` and they inherit; `variant="normal"` puts the default back inside a sized subtree. **What the three sizes are is the project's, declared once**: `vars={Box.sizes({ compact: 8, normal: 9, large: 11 })}` on the app's root (a number is the ÷4 scale for the height, `{ height, fontSize }` sets both, a string is CSS) — that line is how a project matches shadcn's 36px — and `vars={Box.sizes({ size: 'compact' })}` draws a whole subtree compact. **Never size one control by hand**: `height={9}` on a single button wins as any prop does, and that is exactly how a row ends up with three heights; padding no longer sizes a control at all (`py` changes nothing, `height="auto"` hands it back). Your own control joins the standard with `height="control"` (`minHeight` where it wraps) and `fontSize="control"`; `'control-compact'`/`'control-normal'`/`'control-large'` name one size outright. `variant="square"` on a `Button` is an icon button as wide as it is tall: `<Flex gap={2}><Textbox …/><Button variant={{ secondary: true, square: true }} props={{ 'aria-label': 'Copy link' }}><Icon size={4}><Copy /></Icon></Button></Flex>`. A `DataGrid` is drawn compact.
|