@hackcove/ui-react 0.9.0 → 0.9.2

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.
Files changed (50) hide show
  1. package/README.md +18 -8
  2. package/dist/Avatar.d.ts +8 -4
  3. package/dist/Avatar.js +11 -3
  4. package/dist/AvatarStack.js +1 -0
  5. package/dist/Callout.d.ts +8 -2
  6. package/dist/Callout.js +3 -3
  7. package/dist/Card.d.ts +2 -0
  8. package/dist/Card.js +14 -8
  9. package/dist/CardParts.d.ts +6 -0
  10. package/dist/CardParts.js +6 -0
  11. package/dist/Cluster.d.ts +9 -4
  12. package/dist/Cluster.js +12 -5
  13. package/dist/Dialog.d.ts +4 -2
  14. package/dist/Dialog.js +9 -3
  15. package/dist/EmptyState.d.ts +7 -1
  16. package/dist/EmptyState.js +3 -2
  17. package/dist/Field.d.ts +8 -1
  18. package/dist/Field.js +4 -2
  19. package/dist/Image.d.ts +46 -0
  20. package/dist/Image.js +42 -0
  21. package/dist/KeyValueList.d.ts +4 -2
  22. package/dist/KeyValueList.js +3 -3
  23. package/dist/ListRow.d.ts +3 -1
  24. package/dist/ListRow.js +6 -6
  25. package/dist/MediaTile.d.ts +37 -0
  26. package/dist/MediaTile.js +19 -0
  27. package/dist/PageLayouts.d.ts +24 -22
  28. package/dist/PageLayouts.js +25 -21
  29. package/dist/Pill.d.ts +24 -6
  30. package/dist/Pill.js +72 -16
  31. package/dist/ScrollStrip.d.ts +21 -0
  32. package/dist/ScrollStrip.js +12 -0
  33. package/dist/SidebarLayout.d.ts +21 -0
  34. package/dist/SidebarLayout.js +18 -0
  35. package/dist/Stack.d.ts +15 -1
  36. package/dist/Stack.js +12 -2
  37. package/dist/StatTile.d.ts +2 -2
  38. package/dist/StatTile.js +2 -0
  39. package/dist/Table.d.ts +29 -12
  40. package/dist/Table.js +25 -11
  41. package/dist/TableEditCell.d.ts +19 -0
  42. package/dist/TableEditCell.js +58 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/size.d.ts +1 -1
  46. package/dist/stickyColumn.d.ts +17 -0
  47. package/dist/stickyColumn.js +17 -0
  48. package/dist/typography.d.ts +34 -10
  49. package/dist/typography.js +43 -13
  50. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @hackcove/ui-react
2
2
 
3
3
  Hack Cove React components, written against Tailwind v4 utilities. They carry no theme: pair them
4
- with a `@hackcove/theme-*` package such as `@hackcove/theme-catppuccin-mocha` and put its scope
4
+ with a theme from `@hackcove/themes`, such as `@hackcove/themes/catppuccin-mocha.css`, and put its scope
5
5
  class on `<html>` (`.dark` for Mocha). With no theme they render in Tailwind's own default colours,
6
6
  a plain light look; it works, but it is not a look Hack Cove designs or checks.
7
7
 
@@ -60,7 +60,7 @@ wherever a component takes `size`. The controls (`Button` (also as a link), `Ico
60
60
  `DateField` (also with `calendar`), `SegmentedControl`, `Tabs variant="pill"`)
61
61
  share heights at each step (`sm` 26px, `md` 38px, `lg` 46px, the same on a `Select` and a `DateField` as on an `Input`; a Playwright check measures them side by side in every theme), so a row of mixed controls at one size lines up. `Card`, `ListRow`,
62
62
  `FileDrop`, `Dialog`, `Drawer`, `Avatar`, `SubsectionHeading`, `TextLink` and the bars take the
63
- same three names. Only `Avatar` (`xl`, `2xl`) and the `Stack` gap (`xs`, `xl`) go beyond them.
63
+ same three names. Only `Avatar` (`xs`, `xl`, `2xl`) and the `Stack` gap (`xs`, `xl`) go beyond them.
64
64
 
65
65
  One default is not `md`, on purpose: `Cluster`'s gap is `xs` (a row is a label and its chips,
66
66
  which sit tight). `TextLink` with no `size`
@@ -68,7 +68,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
68
68
 
69
69
  ## What is exported
70
70
 
71
- - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading, and `status` (a `Pill` or any node) sits right after them, where `actions` sits at the far end; `flush` drops the padding and clips the corners, for a table that runs edge to edge; `bar` moves the title, count and `actions` into a tinted strip over the body; `dashed` is an empty-state shell with centred muted text, and `dashed="filled"` the same on a surface fill with more padding; `badge` pins a `Pill` to the top-right corner over the content, on a panel or a link card, where it is part of the link's name; `icon` puts one of the `Icon` set's names above the title at `feature` size in the accent colour, decorative, on a panel or a link card but not a `bar`, `flush` or `dashed` one; `attention` draws the strong warn edge (`border-amber-600`) round a card that needs a look, with or without `tone="warn"`, and does nothing on a `dashed` card; `footer` puts a tinted strip under a hairline across the bottom, for the card's own actions or a total: it takes the padding off the root and gives it to the body, so the strip runs edge to edge and stays at the foot of a card stretched in a `CardGrid`; not on a `dashed` card or a card that is a link; `as` is `section` (default), `article` or `div`; `id`, `data-*` and `aria-*` go on the root (on a link card too: the link inside takes only its own `aria-label`, `aria-labelledby` and `aria-current`); a titled panel is named by its title through `aria-labelledby`, unless you pass your own `aria-label` or `aria-labelledby`; a Card with an `id` is an anchor target, scrolled clear of the sticky header and outlined in violet while it is the `:target`), `EpicHeader`, `Pill`, `groupByEpic`, `Markdown`, `Badge` (a small red count for sitting on an icon; `count` above 99 reads `99+`, `corner` pins it to the top-right of its `relative` parent; an `IconButton` takes `badge` and does this itself).
71
+ - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading, and `status` (a `Pill` or any node) sits right after them, where `actions` sits at the far end; `flush` drops the padding and clips the corners, for a table that runs edge to edge; `bar` moves the title, count and `actions` into a tinted strip over the body; `dashed` is an empty-state shell with centred muted text, and `dashed="filled"` the same on a surface fill with more padding; `badge` pins a `Pill` to the top-right corner over the content, on a panel or a link card, where it is part of the link's name; `icon` puts one of the `Icon` set's names above the title at `feature` size in the accent colour, decorative, on a panel or a link card but not a `bar`, `flush` or `dashed` one; `attention` draws the strong warn edge (`border-amber-600`) round a card that needs a look, with or without `tone="warn"`, and does nothing on a `dashed` card; `footer` puts a tinted strip under a hairline across the bottom, for the card's own actions or a total: it takes the padding off the root and gives it to the body, so the strip runs edge to edge and stays at the foot of a card stretched in a `CardGrid`; not on a `dashed` card or a card that is a link; `inset` is a full-bleed band (a progress bar, a chart, an image) under the title and over the children, running edge to edge of a padded card and not on a `dashed` card; a bordered sub-card inside a card is a nested `Card tone="neutral"`; `as` is `section` (default), `article` or `div`; `id`, `data-*` and `aria-*` go on the root (on a link card too: the link inside takes only its own `aria-label`, `aria-labelledby` and `aria-current`); a titled panel is named by its title through `aria-labelledby`, unless you pass your own `aria-label` or `aria-labelledby`; a Card with an `id` is an anchor target, scrolled clear of the sticky header and outlined in violet while it is the `:target`), `EpicHeader`, `Pill` (a small tinted label; `tone`, `dot`, `round` and `title`; `label` makes it a two-tone `label: value` pill, the label keeping the tint and the children, the value, on the surface; `href`/`render` make it a link and `onClick` a button; `onRemove` with `removeLabel` adds an x button, a separate button beside a link or button pill and never inside it), `groupByEpic`, `Markdown`, `Badge` (a small red count for sitting on an icon; `count` above 99 reads `99+`, `corner` pins it to the top-right of its `relative` parent; an `IconButton` takes `badge` and does this itself).
72
72
  - Data: `StatTile` (one headline figure: an uppercase `label` over a large tabular `value`, already formatted, an optional `hint` under it; `tone` is `neutral` (default), `positive` or `negative`, and `accent` adds a bar across the top in the same tone), `ProgressBar` (a thin bar with a filled share of `max` (default 100); `label` names it for a screen reader, which also hears the percentage; `tone` is `accent` (default), `positive`, `warn` or `negative`), `SplitBar` (a bar split between `a` and `b` by their shares; `names` says whose is whose to a screen reader; `toneA` (default `negative`) and `toneB` (default `positive`)), `StackedBar` (any number of `segments`, each `{ label, value, tone }`, sized by its share of the sum; `legend` draws a `Legend` under it) and `Legend` (a swatch, a `label` and an optional `detail` per item, the key to a chart's or a row stripe's colours). The three bars take `size`: `sm` 6px, `md` 8px (default), `lg` 12px.
73
73
  - Status pages: `NotFound` (404), `ErrorPage` (500 or any failure; `detail` is error text shown in a `CodeBlock`, never a stack trace, and `onRetry` adds "Try again"), `Offline` (`onRetry` too), `Maintenance` and `Forbidden` (403). Each is a whole page with default copy: `title` and `children` replace the heading and the body, `action` is the primary action, and `homeHref`, or `renderHome` for the app's own router `<Link>`, adds a "Back to home" button. With neither there is no home link.
74
74
  - Layout (see Layout): `AppShell`, `AppShellBrand`, `appShellRootClassFor`, `Container`, `PageHeader`, `Stack`, `Section`, `Cluster`, `ClusterGrow`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
@@ -80,7 +80,7 @@ takes the size of the text around it. Upgrading: `MIGRATING.md`, shipped in the
80
80
  text colours as a `TableCell` tone, winning over `color`; pair the colour with a word, as in "Saved."),
81
81
  `numeric` (tabular figures), `mono` (the monospace face) and `truncate` (one line, cut with an
82
82
  ellipsis, the full text in a `title`, so its content is text).
83
- - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost`, `link` or `dashed`, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, like `Card dashed`, for an "add another" row, with a leading icon as a child, ignoring `tone`; `size="sm"` is compact, `text-xs`, for table rows and toolbars, `size="lg"` is `text-base` with more padding; `tone="danger"` is red, for delete and clear, and `tone="positive"` green, for accept and approve; `disabledReason` disables it, greys it flat and makes the reason its `title`; `busy` disables it with `aria-busy` and keeps its colour, showing `busyLabel` in place of the children; `pressed` makes it a toggle with `aria-pressed`, on `outline` and `ghost` neutral when off and filled in the tone's primary colour when on, so an `outline` `size="sm"` Button with `pressed` is a filter chip; with `href`, or `render` to draw the app's own router `<Link>`, it is a link with the same look, `external` opening a new tab), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`; `variant` is `outline` (default) or `ghost`, `tone` is `default` or `danger`, `size` matches a `Button`'s height; `badge` puts a red count on its corner, not shown at 0; with `href` or `render` it is a link, `external` opening a new tab), `Icon` (one bare `<svg>` sized to fill a box, or `name` for one of the set's own icons (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`: one 24 grid, a 1.5 stroke, round caps and joins, each the idea and not a product): `size` `sm` 14px, `md` 16px (default), `lg` 20px or `feature` 32px; `tone` is a text tone or `inherit` (default, the text colour around it); `aria-hidden` unless `label` names it as `role="img"`), `ColourSchemeToggle` (a light/dark switch on `<html>`; `id`, `data-*` and other button props go on the button, `store={false}` skips `localStorage`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field; `prefix` is short text inside the border before the value, a currency symbol, on a plain field only; `type="search"` drops the browser's search look and shows a "Clear search" button while the field has a value), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, `sm`: `text-xs`, less padding, for dense forms and table rows, or `lg`: `text-base`, more padding) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, `fill`: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, or `auto`: no width class, as wide as the browser makes it); a fixed width replaces `w-full`, so there is no class to collide with. `Input` also takes `align` (`start` default, or `end` to right-align a numeric value). `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a native checkbox with its label; other props go on the input; `size="sm"` is `text-xs` with a smaller box and `size="lg"` `text-base` with a larger one, `align="start"` puts the box on the first line of a label that wraps, where the default `center` centres it on all of them, and with no `label` it is a bare box for a table cell, named by `aria-label`; `description` is a muted line under the label, the box's `aria-describedby`). `RadioGroup` and `Radio` (one choice from a few options: the group has a `label`, `value`/`onChange` or `defaultValue`, `name`, `isDisabled`, `isRequired`, `size` (`sm`, `md` default, `lg`, as `Checkbox`) and an `error` shown like `Field`'s with `role="alert"`; each `Radio` has a `value`, a `label`, an optional `description` and `isDisabled`; one tab stop, the arrow keys move and choose). `Disclosure` (a summary button with a chevron and `aria-expanded` over a body that opens and closes; `summary`, `children`, `defaultOpen`; it keeps its own state and replaces `<details>`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; `disabled` disables the button; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line; `span="full"` runs it across both columns of a `FormGrid`). `FormGrid` (two columns of `Field`s from `md`, one below it), `FormRow` (an inline-edit row: `FormRowField`s side by side, an `action` after them, and one `hint` or `error` under the whole row; see Layout) and `FormRowField` (a compact label over one control, `span` 1 to 4). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `divided` draws a hairline above the row, to separate rows in a `Card flush`; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section; `leading` is content before the label (an `Avatar`), and `leadingWidth="thumbnail"` gives it a fixed width so rows with and without a picture line up; `size` sets the padding (`sm` dense, `md` default, `lg`); `focused` draws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list; `selected` marks the row in view with a violet fill and `aria-current="true"`; a link row (`href` or `render`) can hold its own link, button or control in `trailing`, which sits above the row's link and takes its own click; `static` makes it a plain `<div>`, for a row that holds its own buttons where the row would otherwise be a `<button>`, and adds `actions`, buttons after `trailing`, and `revealOnHover`, which shows them only on hover or focus inside the row and always on a touch screen; `active` marks the row the pointer or the keyboard is on, with the hover fill and the `revealOnHover` actions shown, for a list that follows both through the row's `onPointerEnter`, `onPointerLeave` and `onFocus`, which every row passes through). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ label, href | render, active?, external? }` links in a `<nav>` with `aria-current="page"` on the current one (`active`, or the item whose `value` is `value`); `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's; `variant="pill"` fills the current tab, with no rule, for `PageHeader`'s `tabs` slot, an `AppShell` `nav` or a rail; only a pill row takes `size` and `orientation`, and `orientation="vertical"` stacks it in one column, the content of a `SidebarLayout` rail; `disabled` on the row disables every tab, e.g. until hydration). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange`, `disabled` and `label` as `Tabs`, as a `radiogroup` with `aria-checked` on each `radio`). On both an item takes `data-*` attributes (typed `data-${string}`) onto its button or link; on `Tabs` and `SegmentedControl` an item takes `count` (a small muted number after the label, part of its name: "Shots 12") and `title`; a `disabled` button option stays in the page as `aria-disabled`, so its `title` is still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it. `Dialog` (a modal on the browser's `<dialog>`, opened on mount, so render it only while open: `title` is its name, `description` a line under it, `footer` the right-aligned actions; Esc, the backdrop or a `<form method="dialog">` call `onClose`; `size` is `sm` (320px, a list), `md` (440px, default, a form) or `lg` (640px, a wider form or a table)). `FieldDialog` (a `Dialog` with one text field and Cancel and submit buttons, every string passed in: `title`, `description`, `label`, `inputLabel`, `placeholder`, `hint`, `emptyReason`, `submit` and `busyLabel`; `onSubmit` gets the trimmed value and a resolved promise closes it, a rejection shows its message). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` (`align` `start` or `end`; `srLabel` is a visually hidden column name, in place of children, for a column of row actions: "Actions") and `TableCell` (`align`, `tone` `default`, `strong` or `muted`, or the Callout tones `neutral`, `info`, `positive`, `warn` or `danger` for an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word; `numeric` for tabular figures, `mono` for a small mono face, `truncate` to cap a long value's width, cut it with an ellipsis and put the full text in a `title`, so its content is text (strings and numbers); `secondary` for a second, muted line under the content; `colSpan` passes through, so a `TableCell` with `colSpan` in a row of its own is a full-width panel, an empty state or a note); `TableFoot` (the `<tfoot>` for totals and subtotals: its cells are strong by default and each row has a firmer line above it, in a card and in a `plain` table alike); `TableGroupRow` (a full-width group header in the body: `label` spans `colSpan` columns, the table's column count, which is required; optional `trailing` content sits at the far end, and the group's rows follow it); `tone` on a `TableRow` in the body marks the row with a stripe on its leading edge over a soft fill: `neutral`, `info`, `positive`, `warn` or `danger`, as `Callout`, or `muted`, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone; `stripe` (a text tone) draws the leading stripe alone, with no fill, and is never combined with `tone`; `variant="plain"` on `Table` drops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passes `id`, `data-*` and `aria-*` through, and none takes a `className` or `style`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time`, the `children` as a note, and `tone`, a Callout tone for the marker dot, `neutral` by default). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead; `size` is `sm`, `md` (default), `lg`, `xl` (56px) or `2xl` (96px); `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `size` (`sm`, `md` or `lg`) pins the type size where it would otherwise follow the text around it, `mono` sets the monospace face, `tone` (`warn`, `danger`, `positive`, `info` or `muted`) gives it a meaning's colour in place of the accent, every other anchor prop passes through). `MetaList` (a wrapping row of facts or links in muted small text, joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; branch · SHA · date, or a footer's Privacy · Terms; an item is text, a `Code` or a `TextLink`; `nowrap` keeps each item on one line, for links; `mono` sets the row in the monospace face, for ids and paths). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `EmptyState` (what a page or list shows when it has nothing yet: a centred `title`, the `children` as the explanation under it, and `actions` in a centred row; a named `<section>`; `level` is the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card, `Text as="p" color="muted"`). `Steps` (a numbered `<ol>` of `Step`s, each a `title` and the `children` as a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress). `Divider` (a hairline `<hr>` in the theme's line colour; spacing comes from the layout around it). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `CodeBlock` (a block of code, no client JS: the text is `children` (a string) or `lines` (one entry per line), `language` (`bash`, `css`, `tsx`, `ts` or `json`) colours it and `text`, the default, leaves it in one colour; `label` is a caption naming the language or file; long lines wrap by default, so nothing is cut off and a command copies as one line, and `overflow="scroll"` keeps lines whole and scrolls them under a thin scrollbar in the theme's muted ink; `copy` adds a copy button beside the code, which copies it exactly as given and needs the page hydrated; other props go on the root `<figure>`). `CodeExample` (one framed example: the rendered `children` over the `code` that makes them, coloured as TSX, in one bordered box, with an optional `caption` as its last band; a wide preview or long code scrolls inside the frame under the same thin scrollbar; `copy` adds a button that copies the code; no client JS without `copy`). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `outline` `sm` `Button`s with `pressed`). `DateField` (see Dates).
83
+ - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost`, `link` or `dashed`, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, like `Card dashed`, for an "add another" row, with a leading icon as a child, ignoring `tone`; `size="sm"` is compact, `text-xs`, for table rows and toolbars, `size="lg"` is `text-base` with more padding; `tone="danger"` is red, for delete and clear, and `tone="positive"` green, for accept and approve; `disabledReason` disables it, greys it flat and makes the reason its `title`; `busy` disables it with `aria-busy` and keeps its colour, showing `busyLabel` in place of the children; `pressed` makes it a toggle with `aria-pressed`, on `outline` and `ghost` neutral when off and filled in the tone's primary colour when on, so an `outline` `size="sm"` Button with `pressed` is a filter chip; with `href`, or `render` to draw the app's own router `<Link>`, it is a link with the same look, `external` opening a new tab), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`; `variant` is `outline` (default) or `ghost`, `tone` is `default` or `danger`, `size` matches a `Button`'s height; `badge` puts a red count on its corner, not shown at 0; with `href` or `render` it is a link, `external` opening a new tab), `Icon` (one bare `<svg>` sized to fill a box, or `name` for one of the set's own icons (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`: one 24 grid, a 1.5 stroke, round caps and joins, each the idea and not a product): `size` `sm` 14px, `md` 16px (default), `lg` 20px or `feature` 32px; `tone` is a text tone or `inherit` (default, the text colour around it); `aria-hidden` unless `label` names it as `role="img"`), `ColourSchemeToggle` (a light/dark switch on `<html>`; `id`, `data-*` and other button props go on the button, `store={false}` skips `localStorage`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field; `prefix` is short text inside the border before the value, a currency symbol, on a plain field only; `type="search"` drops the browser's search look and shows a "Clear search" button while the field has a value), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, `sm`: `text-xs`, less padding, for dense forms and table rows, or `lg`: `text-base`, more padding) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, `fill`: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, or `auto`: no width class, as wide as the browser makes it); a fixed width replaces `w-full`, so there is no class to collide with. `Input` also takes `align` (`start` default, or `end` to right-align a numeric value). `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a native checkbox with its label; other props go on the input; `size="sm"` is `text-xs` with a smaller box and `size="lg"` `text-base` with a larger one, `align="start"` puts the box on the first line of a label that wraps, where the default `center` centres it on all of them, and with no `label` it is a bare box for a table cell, named by `aria-label`; `description` is a muted line under the label, the box's `aria-describedby`). `RadioGroup` and `Radio` (one choice from a few options: the group has a `label`, `value`/`onChange` or `defaultValue`, `name`, `isDisabled`, `isRequired`, `size` (`sm`, `md` default, `lg`, as `Checkbox`) and an `error` shown like `Field`'s with `role="alert"`; each `Radio` has a `value`, a `label`, an optional `description` and `isDisabled`; one tab stop, the arrow keys move and choose). `Disclosure` (a summary button with a chevron and `aria-expanded` over a body that opens and closes; `summary`, `children`, `defaultOpen`; it keeps its own state and replaces `<details>`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; `disabled` disables the button; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line; `span="full"` runs it across both columns of a `FormGrid`). `FormGrid` (two columns of `Field`s from `md`, one below it), `FormRow` (an inline-edit row: `FormRowField`s side by side, an `action` after them, and one `hint` or `error` under the whole row; see Layout) and `FormRowField` (a compact label over one control, `span` 1 to 4). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `divided` draws a hairline above the row, to separate rows in a `Card flush`; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section; `leading` is content before the label (an `Avatar`), and `leadingWidth="thumbnail"` gives it a fixed width so rows with and without a picture line up; `size` sets the padding (`sm` dense, `md` default, `lg`); `focused` draws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list; `selected` marks the row in view with a violet fill and `aria-current="true"`; a link row (`href` or `render`) can hold its own link, button or control in `trailing`, which sits above the row's link and takes its own click; `static` makes it a plain `<div>`, for a row that holds its own buttons where the row would otherwise be a `<button>`, and adds `actions`, buttons after `trailing`, and `revealOnHover`, which shows them only on hover or focus inside the row and always on a touch screen; a compact bordered list is `Card flush` around `static` rows at `size="sm"`, `divided` after the first (no separate part); `active` marks the row the pointer or the keyboard is on, with the hover fill and the `revealOnHover` actions shown, for a list that follows both through the row's `onPointerEnter`, `onPointerLeave` and `onFocus`, which every row passes through). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ label, href | render, active?, external? }` links in a `<nav>` with `aria-current="page"` on the current one (`active`, or the item whose `value` is `value`); `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's; `variant="pill"` fills the current tab, with no rule, for `PageHeader`'s `tabs` slot, an `AppShell` `nav` or a rail; only a pill row takes `size` and `orientation`, and `orientation="vertical"` stacks it in one column, the content of a `SidebarLayout` rail; `disabled` on the row disables every tab, e.g. until hydration). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange`, `disabled` and `label` as `Tabs`, as a `radiogroup` with `aria-checked` on each `radio`). On both an item takes `data-*` attributes (typed `data-${string}`) onto its button or link; on `Tabs` and `SegmentedControl` an item takes `count` (a small muted number after the label, part of its name: "Shots 12") and `title`; a `disabled` button option stays in the page as `aria-disabled`, so its `title` is still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it. `Dialog` (a modal on the browser's `<dialog>`, opened on mount, so render it only while open: `title` is its name, `description` a line under it, `footer` the right-aligned actions; Esc, the backdrop or a `<form method="dialog">` call `onClose`; `size` is `sm` (320px, a list), `md` (440px, default, a form), `lg` (640px, a wider form or a table) or `full` (the whole screen, for media or a player: the body fills the height between the title and the footer and scrolls if taller; there is no backdrop to click, so put a close button on a `<form method="dialog">` in the `footer`)). `FieldDialog` (a `Dialog` with one text field and Cancel and submit buttons, every string passed in: `title`, `description`, `label`, `inputLabel`, `placeholder`, `hint`, `emptyReason`, `submit` and `busyLabel`; `onSubmit` gets the trimmed value and a resolved promise closes it, a rejection shows its message). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableFoot`, `TableRow`, `TableHeaderCell` (`align` `start`, `center` or `end`; `srLabel` is a visually hidden column name, in place of children, for a column of row actions: "Actions") and `TableCell` (`align`, `indent` of 1 to 3 to set a start-aligned cell in, for a line under a parent row, `tone` `default`, `strong` or `muted`, or the Callout tones `neutral`, `info`, `positive`, `warn` or `danger` for an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word; `numeric` for tabular figures, `mono` for a small mono face, `truncate` to cap a long value's width, cut it with an ellipsis and put the full text in a `title`, so its content is text (strings and numbers); `secondary` for a second, muted line under the content; `colSpan` passes through, so a `TableCell` with `colSpan` in a row of its own is a full-width panel, an empty state or a note, centred with `align="center"`); `TableEditCell` (a `TableCell` whose value opens an editor in place: `children` is the value, drawn as a button; a click, Enter or Space swaps it for `renderEditor(close)`, a plain control in the cell and not a popover of its own, and moves the focus into it; `close` and Esc put the focus back on the value, and a press outside or Tab out of it closes it too, so the control saves on its own change or submit; `label` names the button and the editor; it takes the `TableCell` props except `truncate`); `TableFoot` (the `<tfoot>` for totals: its cells are strong by default and each row has a firmer line above it, in a card and in a `plain` table alike); `TableGroupRow` (a full-width group header in the body: `label` spans `colSpan` columns, the table's column count, which is required; optional `trailing` content sits at the far end, and the group's rows follow it); `tone` on a `TableRow` in the body marks the row with a stripe on its leading edge over a soft fill: `neutral`, `info`, `positive`, `warn` or `danger`, as `Callout`, or `muted`, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone: `emphasis` on a `TableRow` in the body gives it a `TableFoot` row's look among the other rows; `stripe` (a text tone) draws the leading stripe alone, with no fill; a row takes one of `tone`, `stripe` and `emphasis`; `variant="plain"` on `Table` drops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passes `id`, `data-*` and `aria-*` through, and none takes a `className` or `style`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time`, the `children` as a note, and `tone`, a Callout tone for the marker dot, `neutral` by default). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead, and if that image fails to load it shows the initials; `size` is `xs` (16px, for an inline mention), `sm`, `md` (default), `lg`, `xl` (56px) or `2xl` (96px); `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `size` (`sm`, `md` or `lg`) pins the type size where it would otherwise follow the text around it, `mono` sets the monospace face, `tone` (`warn`, `danger`, `positive`, `info` or `muted`) gives it a meaning's colour in place of the accent, every other anchor prop passes through). `MetaList` (a wrapping row of facts or links in muted small text, joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; branch · SHA · date, or a footer's Privacy · Terms; an item is text, a `Code` or a `TextLink`; `nowrap` keeps each item on one line, for links; `mono` sets the row in the monospace face, for ids and paths). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `EmptyState` (what a page or list shows when it has nothing yet: a centred `title`, the `children` as the explanation under it, and `actions` in a centred row; a named `<section>`; `level` is the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card, `Text as="p" color="muted"`). `Steps` (a numbered `<ol>` of `Step`s, each a `title` and the `children` as a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress). `Divider` (a hairline `<hr>` in the theme's line colour; spacing comes from the layout around it). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `CodeBlock` (a block of code, no client JS: the text is `children` (a string) or `lines` (one entry per line), `language` (`bash`, `css`, `tsx`, `ts` or `json`) colours it and `text`, the default, leaves it in one colour; `label` is a caption naming the language or file; long lines wrap by default, so nothing is cut off and a command copies as one line, and `overflow="scroll"` keeps lines whole and scrolls them under a thin scrollbar in the theme's muted ink; `copy` adds a copy button beside the code, which copies it exactly as given and needs the page hydrated; other props go on the root `<figure>`). `CodeExample` (one framed example: the rendered `children` over the `code` that makes them, coloured as TSX, in one bordered box, with an optional `caption` as its last band; a wide preview or long code scrolls inside the frame under the same thin scrollbar; `copy` adds a button that copies the code; no client JS without `copy`). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `outline` `sm` `Button`s with `pressed`). `DateField` (see Dates).
84
84
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
85
85
 
86
86
  ## Dates
@@ -214,6 +214,9 @@ import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from
214
214
  blocks, `lg` (`gap-8`) between groups, `xl` (`gap-12`, `sm:gap-16`) between a page's top-level blocks. Use it instead of `space-y-*`. `width` caps the column:
215
215
  `narrow` (`max-w-3xl`, a reading measure), `wide` (`max-w-5xl`), `full` (`w-full`) or `default`
216
216
  (nothing). A capped Stack starts at the Container's left edge and is never re-centred.
217
+ `rowFrom` (`sm`, `md` or `lg`) lays the blocks out in a row from that breakpoint and keeps the
218
+ column below it, a main block beside a side panel that stacks on a phone; a row that is a row at
219
+ every width is a `Cluster`.
217
220
  `as` is `div` (default), `section`, `form`, `ul` or `ol`, and nothing else. A `form` Stack takes
218
221
  the form props (`onSubmit`, `action`, `method`, `noValidate`, `aria-*`, `data-*`). A `ul` or `ol`
219
222
  Stack has no markers and its children are `<li>`s (the Stack does not wrap them, as with
@@ -225,7 +228,8 @@ import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from
225
228
  `start` or `end`), `justify` the main axis (`start` (default), `between` or `end`), and `wrap`
226
229
  lets the row flow onto more lines on a narrow screen (off by default). Wrap the one item that
227
230
  should take the room left in **`ClusterGrow`**, an input beside its buttons say: it can shrink to
228
- nothing, so the items beside it stay on the line. An empty `ClusterGrow` is a spacer that pushes
231
+ nothing, so the items beside it stay on the line; `minWidth` (`sm` 12rem, `md` 17rem) gives it a
232
+ floor instead, so in a `wrap` Cluster it drops under the others rather than squashing. An empty `ClusterGrow` is a spacer that pushes
229
233
  what follows it to the end. `ButtonGroup` stays the row for buttons; `Cluster` is any other row.
230
234
  It is a separate part, not `Stack direction="row"`, because a row has no `lg`, `xl` or
231
235
  `width` and a column has no `align` or `justify`.
@@ -249,6 +253,9 @@ except the last four.
249
253
  `children` is the list, replaced by `empty` when `isEmpty`.
250
254
  - **`DetailLayout`**: use this when a page is one thing with facts beside it. `children` take two
251
255
  thirds from `lg` and `aside` one third; below `lg` the aside stacks under the main column.
256
+ `stickyAside` (off by default, since an aside of ordinary cards may be taller than the screen)
257
+ keeps the aside in view from `lg` as a long page scrolls: it sticks under the shell's header and
258
+ scrolls itself when it is taller than the room left.
252
259
  - **`FormLayout`**: use this for a create or edit page. A `max-w-3xl` `<form>` that starts at the
253
260
  Container's left edge (it is not re-centred, so the title does not jump), with an `actions` row at
254
261
  the end. Other props (`onSubmit`, `action`) go on the `<form>`.
@@ -259,15 +266,18 @@ except the last four.
259
266
  - **`CardGrid`**: use this for stat tiles or a gallery of cards. `columns` is `2`, `3` (default) or
260
267
  `4`: one column on a phone, two from `sm`, `columns` from `lg`. `columns="auto"` instead fits as
261
268
  many per row as the width allows, each at least `minWidth` wide: `md` (17rem, default) or `sm`
262
- (12rem); `minWidth` is only for `auto`. `align="start"` keeps each card its own height; the
269
+ (12rem); `minWidth` is only for `auto`. `columns="2:1"` or `"1:2"` makes two columns in that
270
+ ratio from `md` (one column below), for a main card beside a narrower one. `align="start"` keeps each card its own height; the
263
271
  default stretches every card to the tallest in its row. `as="ul"` makes it a list, each child
264
272
  wrapped in an `<li>`.
265
273
  - **`SidebarLayout`**: use this when a `rail` of navigation sits beside the content. Render it in a
266
274
  plain `<AppShell>`, no `bleed`: it sits in the Container like every other layout, so the rail
267
275
  starts at the header logo's x and the canvas ends at the header's right edge. The rail has no fill
268
276
  and no border (pill `Tabs` with `orientation="vertical"` is its content); from `md` it is `md:w-56` beside
269
- the canvas, and below `md` it stacks above it. A long rail sets `railLabel`: below `md` the rail
270
- then folds, closed, behind a disclosure of that name, so a phone shows the page first.
277
+ the canvas, and below `md` it stacks above it. From `md` the rail also stays in view under the
278
+ shell's header as a long page scrolls (always, with or without `railLabel`); a rail taller than
279
+ the screen scrolls itself, so its last link is reachable. A long rail sets `railLabel`: below `md`
280
+ the rail then folds, closed, behind a disclosure of that name, so a phone shows the page first.
271
281
  - **`FullBleedLayout`**: use this for a viewer that needs the whole width. Render it in
272
282
  `<AppShell bleed>`; `children` fill the width, and an optional `panel` takes `lg:w-72` on the
273
283
  right and stacks below under `lg`. It adds no padding, so a viewer fills the width; set `padded`
package/dist/Avatar.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { HTMLAttributes, ReactElement } from "react";
1
+ import { type HTMLAttributes, type ReactElement } from "react";
2
2
  import type { Unstyled } from "./props.js";
3
3
  declare const SIZE_CLASSES: {
4
+ readonly xs: "size-4 text-[0.5rem]";
4
5
  readonly sm: "size-6 text-xs";
5
6
  readonly md: "size-8 text-xs";
6
7
  readonly lg: "size-10 text-base";
@@ -21,10 +22,13 @@ export declare function initialsOf(name: string): string;
21
22
  type AvatarProps = {
22
23
  /** Whose avatar: its initials, its colour (a hash of the name) and its title or `alt`. */
23
24
  name: string;
24
- /** The person's photo, a local path. It replaces the initials and is announced as `name`. */
25
+ /**
26
+ * The person's photo, a local path. It replaces the initials and is announced as `name`. If it
27
+ * fails to load, the initials show instead.
28
+ */
25
29
  src?: string | undefined;
26
30
  /**
27
- * `sm` 24px, `md` 32px, `lg` 40px, `xl` 56px or `2xl` 96px.
31
+ * `xs` 16px, `sm` 24px, `md` 32px, `lg` 40px, `xl` 56px or `2xl` 96px.
28
32
  * @default md
29
33
  */
30
34
  size?: AvatarSize;
@@ -43,7 +47,7 @@ type AvatarProps = {
43
47
  */
44
48
  export declare function Avatar(props: AvatarProps): ReactElement;
45
49
  /** Avatar plus the stack's ring: internal, so the ring only ever comes from `AvatarStack`. */
46
- export declare function AvatarMark({ name, src, size, shape, ring, ...rest }: AvatarProps & {
50
+ export declare function AvatarMark({ name, src, size, shape, ring, onError, ...rest }: AvatarProps & {
47
51
  ring?: string | false;
48
52
  }): ReactElement;
49
53
  export {};
package/dist/Avatar.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
2
3
  import { cx } from "./cx.js";
3
4
  /** The identity colours, in hash order. Each is a stock background utility; a theme remaps them. */
4
5
  const COLOUR_CLASSES = [
@@ -11,6 +12,7 @@ const COLOUR_CLASSES = [
11
12
  "bg-amber-600",
12
13
  ];
13
14
  const SIZE_CLASSES = {
15
+ xs: "size-4 text-[0.5rem]",
14
16
  sm: "size-6 text-xs",
15
17
  md: "size-8 text-xs",
16
18
  lg: "size-10 text-base",
@@ -18,6 +20,7 @@ const SIZE_CLASSES = {
18
20
  "2xl": "size-24 text-3xl",
19
21
  };
20
22
  const SQUARE_CLASSES = {
23
+ xs: "rounded-sm",
21
24
  sm: "rounded",
22
25
  md: "rounded-md",
23
26
  lg: "rounded-md",
@@ -56,10 +59,15 @@ export function Avatar(props) {
56
59
  return _jsx(AvatarMark, { ...props });
57
60
  }
58
61
  /** Avatar plus the stack's ring: internal, so the ring only ever comes from `AvatarStack`. */
59
- export function AvatarMark({ name, src, size = "md", shape = "round", ring = false, ...rest }) {
62
+ export function AvatarMark({ name, src, size = "md", shape = "round", ring = false, onError, ...rest }) {
63
+ // The `src` that failed to load: a new `src` gets its own try, so a broken one never sticks.
64
+ const [failedSrc, setFailedSrc] = useState();
60
65
  const corners = avatarShapeClass(shape, size);
61
- if (src) {
62
- return (_jsx("img", { src: src, alt: name, title: name, className: cx("shrink-0 object-cover", corners, SIZE_CLASSES[size], ring), ...rest }));
66
+ if (src && src !== failedSrc) {
67
+ return (_jsx("img", { src: src, alt: name, title: name, className: cx("shrink-0 object-cover", corners, SIZE_CLASSES[size], ring), onError: (event) => {
68
+ onError?.(event);
69
+ setFailedSrc(src);
70
+ }, ...rest }));
63
71
  }
64
72
  return (_jsx("span", { "aria-hidden": "true", title: name, className: cx("inline-grid shrink-0 place-items-center font-semibold text-violet-50", corners, SIZE_CLASSES[size], avatarColourClass(name), ring), ...rest, children: initialsOf(name) }));
65
73
  }
@@ -3,6 +3,7 @@ import { AvatarMark, avatarShapeClass, avatarSizeClass, } from "./Avatar.js";
3
3
  import { cx } from "./cx.js";
4
4
  // Each avatar after the first overlaps the one before it by about a quarter of its width.
5
5
  const OVERLAP = {
6
+ xs: "-ml-1",
6
7
  sm: "-ml-1.5",
7
8
  md: "-ml-2",
8
9
  lg: "-ml-2.5",
package/dist/Callout.d.ts CHANGED
@@ -8,16 +8,22 @@ export declare const TONE_SURFACE: Record<CalloutTone, string>;
8
8
  * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
9
9
  * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
10
10
  * top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
11
- * would inset it. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
11
+ * would inset it. `align="center"` centres the text. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
12
12
  * stretch` there makes the whole callout its click target, like a link in a table row.
13
13
  */
14
- export declare function Callout({ tone, title, actions, banner, children, ...rest }: {
14
+ export declare function Callout({ tone, title, actions, banner, align, children, ...rest }: {
15
15
  /** Defaults to "info". */
16
16
  tone?: CalloutTone;
17
17
  /** A bold line above the body. */
18
18
  title?: ReactNode;
19
19
  /** A flat strip: `border-b` only and no radius, for the top of a `Card flush` only. */
20
20
  banner?: boolean;
21
+ /**
22
+ * `center` centres the title and body, for a success or empty state that stands alone. With
23
+ * `actions` only the text is centred.
24
+ * @default start
25
+ */
26
+ align?: "start" | "center" | undefined;
21
27
  /** A button or link at the end of the row, beside the title and body. */
22
28
  actions?: ReactNode;
23
29
  children: ReactNode;
package/dist/Callout.js CHANGED
@@ -19,10 +19,10 @@ const TONE_TEXT = {
19
19
  * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
20
20
  * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
21
21
  * top of a `Card flush` and nowhere else: a padded card or a padded `FullBleedLayout` column
22
- * would inset it. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
22
+ * would inset it. `align="center"` centres the text. In a page's column the notice is a plain Callout, first in the column's `Stack`. With `actions` the box is `relative`, so a `TextLink
23
23
  * stretch` there makes the whole callout its click target, like a link in a table row.
24
24
  */
25
- export function Callout({ tone = "info", title, actions, banner = false, children, ...rest }) {
25
+ export function Callout({ tone = "info", title, actions, banner = false, align = "start", children, ...rest }) {
26
26
  const content = title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children);
27
- return (_jsx("div", { className: cx(Boolean(actions) && "relative", banner ? "border-b px-5 py-3 text-sm" : "rounded-xl border p-4 text-sm", TONE_SURFACE[tone], TONE_TEXT[tone]), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
27
+ return (_jsx("div", { className: cx(Boolean(actions) && "relative", banner ? "border-b px-5 py-3 text-sm" : "rounded-xl border p-4 text-sm", TONE_SURFACE[tone], TONE_TEXT[tone], align === "center" && "text-center"), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
28
28
  }
package/dist/Card.d.ts CHANGED
@@ -21,6 +21,8 @@ type CardBase = {
21
21
  tone?: CalloutTone;
22
22
  /** One of the library's icons, drawn at feature size in the accent colour above the title: the mark of a feature card. Ignored with `bar`, `flush` and `dashed`. */
23
23
  icon?: IconName;
24
+ /** A band across the card, edge to edge inside its padding, under the title and above the children: a `ProgressBar` or a strip of colour that runs the card's full width. Ignored with `dashed`. */
25
+ inset?: ReactNode;
24
26
  /** A `Pill` (or any small node) pinned in the top-right corner, over the content and inside the radius, with no change to the layout. On a card that is a link it is part of the link's name, and a link or button in it is clickable on its own. */
25
27
  badge?: ReactNode;
26
28
  /** The warn tone's strong border in place of the card's own, for the one card in a set that needs a look (a run that differed). It keeps the card's fill. Ignored with `dashed`. */
package/dist/Card.js CHANGED
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useId } from "react";
3
3
  import { TONE_SURFACE } from "./Callout.js";
4
4
  import { CardBadge } from "./CardBadge.js";
5
- import { CardBar, CardFooter, CardHeader, CardHeading, CardIcon } from "./CardParts.js";
5
+ import { CardBar, CardFooter, CardHeader, CardHeading, CardIcon, CardInset } from "./CardParts.js";
6
6
  import { cx, focusRing, liftInteractive } from "./cx.js";
7
7
  import { renderLink } from "./link.js";
8
8
  /** A card with a `footer`: a `dashed` card stands alone and has none. */
@@ -95,7 +95,7 @@ export function Card(props) {
95
95
  }
96
96
  /** A card that is not a link: `as` (a `section` by default) named by its heading. */
97
97
  function PanelCard(props) {
98
- const { title, count, status, actions, footer, bar, flush, dashed, tone, badge, attention, icon, size = "md", children, ...panel } = props;
98
+ const { title, count, status, actions, footer, bar, flush, dashed, tone, badge, inset, attention, icon, size = "md", children, ...panel } = props;
99
99
  const { as: Tag = "section", ...rest } = panel;
100
100
  const headingId = useId();
101
101
  const form = withFooter(cardForm({ flush, bar, dashed, tone, attention, icon, size }, Boolean(title || actions)), footer);
@@ -104,7 +104,7 @@ function PanelCard(props) {
104
104
  Tag !== "div" &&
105
105
  rest["aria-label"] === undefined &&
106
106
  rest["aria-labelledby"] === undefined;
107
- return (_jsxs(Tag, { ...(named ? { "aria-labelledby": headingId } : {}), ...rest, className: cardClass(form), children: [cardBody(form, { title, count, status, actions, children, headingId }), form.footer && _jsx(CardFooter, { size: form.size, children: footer }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
107
+ return (_jsxs(Tag, { ...(named ? { "aria-labelledby": headingId } : {}), ...rest, className: cardClass(form), children: [cardBody(form, { title, count, status, actions, inset, children, headingId }), form.footer && _jsx(CardFooter, { size: form.size, children: footer }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
108
108
  }
109
109
  /** What names or marks the link itself: these stay on the overlay `<a>`; `id`, `data-*` and every other `aria-*` go on the card. */
110
110
  const LINK_OWN = new Set(["aria-label", "aria-labelledby", "aria-current"]);
@@ -120,7 +120,7 @@ function splitRootAttrs(props) {
120
120
  }
121
121
  /** A card that is a link: a `div` with the link stretched over it as an empty overlay, so the card's own links and buttons are not nested in it. */
122
122
  function LinkCard(props) {
123
- const { title, count, status, actions, bar, flush, dashed, tone, badge, attention, icon, size = "md", children, external = false, ...rest } = props;
123
+ const { title, count, status, actions, bar, flush, dashed, tone, badge, inset, attention, icon, size = "md", children, external = false, ...rest } = props;
124
124
  const form = cardForm({ flush, bar, dashed, tone, attention, icon, size }, Boolean(title));
125
125
  const generated = useId();
126
126
  const [root, link] = splitRootAttrs(rest);
@@ -132,15 +132,21 @@ function LinkCard(props) {
132
132
  external,
133
133
  className: cx("link-stretch absolute inset-0 rounded-xl", focusRing, "focus-visible:-outline-offset-2"),
134
134
  children: null,
135
- }), cardBody(form, { title, count, status, actions, children, headingId: undefined }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
135
+ }), cardBody(form, { title, count, status, actions, inset, children, headingId: undefined }), badge && _jsx(CardBadge, { size: form.size, children: badge })] }));
136
136
  }
137
- function cardBody(form, { title, count, status, actions, children, headingId, }) {
137
+ function insetBand(form, inset) {
138
+ if (!inset || form.dashed)
139
+ return null;
140
+ return (_jsx(CardInset, { size: form.size, bleed: !form.flush, children: inset }));
141
+ }
142
+ function cardBody(form, { title, count, status, actions, inset, children, headingId, }) {
138
143
  const heading = title && (_jsx(CardHeading, { id: headingId, title: title, count: count, status: status, bar: form.bar, spaced: !form.bar && !actions, size: form.size }));
139
144
  const grow = form.footer && "flex-1";
145
+ const band = insetBand(form, inset);
140
146
  if (form.bar) {
141
- return (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions, size: form.size }), _jsx("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]) || undefined, children: children })] }));
147
+ return (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions, size: form.size }), _jsxs("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]) || undefined, children: [band, children] })] }));
142
148
  }
143
- const content = (_jsxs(_Fragment, { children: [form.icon && _jsx(CardIcon, { name: form.icon, size: form.size }), _jsx(CardHeader, { heading: heading, actions: actions, size: form.size }), children] }));
149
+ const content = (_jsxs(_Fragment, { children: [form.icon && _jsx(CardIcon, { name: form.icon, size: form.size }), _jsx(CardHeader, { heading: heading, actions: actions, size: form.size }), band, children] }));
144
150
  // With a footer the root has no padding, so the body pads itself and takes the room left over.
145
151
  return form.footer ? (_jsx("div", { className: cx(grow, !form.flush && BODY_PAD[form.size]), children: content })) : (content);
146
152
  }
@@ -15,6 +15,12 @@ export declare function CardIcon({ name, size }: {
15
15
  name: IconName;
16
16
  size: Size;
17
17
  }): import("react").JSX.Element;
18
+ /** A card's `inset`: a band that runs edge to edge across the padded body, the heading's gap under it. A flush card has no padding to cancel. */
19
+ export declare function CardInset({ children, size, bleed, }: {
20
+ children: ReactNode;
21
+ size: Size;
22
+ bleed: boolean;
23
+ }): import("react").JSX.Element;
18
24
  export declare function CardBar({ heading, actions, size, }: {
19
25
  heading: ReactNode;
20
26
  actions: ReactNode;
package/dist/CardParts.js CHANGED
@@ -4,6 +4,8 @@ import { Icon } from "./Icon.js";
4
4
  import { Overline } from "./typography.js";
5
5
  /** The space under a card's heading, by `size`. */
6
6
  const HEADING_GAP = { sm: "mb-2", md: "mb-3", lg: "mb-4" };
7
+ /** The negative margin that cancels a card body's side padding (`BODY_PAD` in `Card`), by `size`. */
8
+ const INSET_BLEED = { sm: "-mx-3", md: "-mx-5", lg: "-mx-6" };
7
9
  /** The padding of a card's bar and footer strips, by `size`. */
8
10
  const STRIP_PAD = { sm: "px-3 py-2", md: "px-5 py-3", lg: "px-6 py-4" };
9
11
  export function CardHeading({ id, title, count, status, bar, spaced, size, }) {
@@ -14,6 +16,10 @@ export function CardHeading({ id, title, count, status, bar, spaced, size, }) {
14
16
  export function CardIcon({ name, size }) {
15
17
  return (_jsx("div", { className: cx("flex text-violet-600", HEADING_GAP[size]), children: _jsx(Icon, { name: name, size: "feature" }) }));
16
18
  }
19
+ /** A card's `inset`: a band that runs edge to edge across the padded body, the heading's gap under it. A flush card has no padding to cancel. */
20
+ export function CardInset({ children, size, bleed, }) {
21
+ return _jsx("div", { className: cx(bleed && INSET_BLEED[size], HEADING_GAP[size]), children: children });
22
+ }
17
23
  function CardActions({ children }) {
18
24
  return _jsx("div", { className: "ml-auto flex items-center gap-2", children: children });
19
25
  }
package/dist/Cluster.d.ts CHANGED
@@ -41,11 +41,16 @@ export declare function Cluster({ as: Tag, gap, align, justify, wrap, children,
41
41
  wrap?: boolean | undefined;
42
42
  children?: ReactNode;
43
43
  } & Passthrough): ReactElement;
44
+ export type ClusterGrowMinWidth = "sm" | "md";
44
45
  /**
45
- * The one child of a `Cluster` that grows to the room left in the row. It can shrink to nothing
46
- * (`min-w-0`), so the items beside it stay on the line and a long value truncates instead of
47
- * pushing them out. Empty, it is a spacer.
46
+ * The one child of a `Cluster` (or of a `Stack` with `rowFrom`) that grows to the room left in the
47
+ * row. By default it can shrink to nothing (`min-w-0`), so the items beside it stay on the line and
48
+ * a long value truncates instead of pushing them out. With `minWidth` it instead keeps that width
49
+ * (`sm` 12rem, `md` 17rem) and, in a `Cluster` with `wrap`, drops onto the next line when the room
50
+ * left is narrower, rather than being squashed. Empty, it is a spacer.
48
51
  */
49
- export declare function ClusterGrow({ children, ...rest }: {
52
+ export declare function ClusterGrow({ minWidth, children, ...rest }: {
53
+ /** The narrowest it gets: `sm` 12rem or `md` 17rem. In a `Cluster` with `wrap` it wraps under the other items below this width. Left out, it shrinks to nothing. */
54
+ minWidth?: ClusterGrowMinWidth | undefined;
50
55
  children?: ReactNode;
51
56
  } & Passthrough): ReactElement;
package/dist/Cluster.js CHANGED
@@ -28,11 +28,18 @@ const JUSTIFY = {
28
28
  export function Cluster({ as: Tag = "div", gap = "xs", align = "center", justify = "start", wrap = false, children, ...rest }) {
29
29
  return (_jsx(Tag, { ...rest, className: cx("flex", STACK_GAP[gap], ALIGN[align], JUSTIFY[justify], wrap && "flex-wrap"), children: children }));
30
30
  }
31
+ /** The narrowest a `ClusterGrow` gets before it wraps under, the same two steps as `CardGrid`'s `minWidth`. */
32
+ const GROW_MIN_WIDTH = {
33
+ sm: "min-w-0 flex-[1_1_12rem]",
34
+ md: "min-w-0 flex-[1_1_17rem]",
35
+ };
31
36
  /**
32
- * The one child of a `Cluster` that grows to the room left in the row. It can shrink to nothing
33
- * (`min-w-0`), so the items beside it stay on the line and a long value truncates instead of
34
- * pushing them out. Empty, it is a spacer.
37
+ * The one child of a `Cluster` (or of a `Stack` with `rowFrom`) that grows to the room left in the
38
+ * row. By default it can shrink to nothing (`min-w-0`), so the items beside it stay on the line and
39
+ * a long value truncates instead of pushing them out. With `minWidth` it instead keeps that width
40
+ * (`sm` 12rem, `md` 17rem) and, in a `Cluster` with `wrap`, drops onto the next line when the room
41
+ * left is narrower, rather than being squashed. Empty, it is a spacer.
35
42
  */
36
- export function ClusterGrow({ children, ...rest }) {
37
- return (_jsx("div", { ...rest, className: "min-w-0 flex-1", children: children }));
43
+ export function ClusterGrow({ minWidth, children, ...rest }) {
44
+ return (_jsx("div", { ...rest, className: minWidth ? GROW_MIN_WIDTH[minWidth] : "min-w-0 flex-1", children: children }));
38
45
  }
package/dist/Dialog.d.ts CHANGED
@@ -11,10 +11,12 @@ export type DialogProps = {
11
11
  footer?: ReactNode;
12
12
  /**
13
13
  * The panel's width: `sm` (320px) is a list, `md` (440px) holds a form, `lg` (640px) a wider
14
- * form or a table.
14
+ * form or a table. `full` fills the whole screen, for media or a player: the body takes the
15
+ * height between the header and the footer and scrolls if it is taller. It has no backdrop to
16
+ * click, so give it a close action in the `footer` (a `<form method="dialog">` button).
15
17
  * @default md
16
18
  */
17
- size?: Size;
19
+ size?: Size | "full";
18
20
  /** Called when the dialog closes: Esc, a click on the backdrop, or a `<form method="dialog">` submit. */
19
21
  onClose: () => void;
20
22
  } & Passthrough;
package/dist/Dialog.js CHANGED
@@ -3,8 +3,13 @@ import { useId, useRef } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  import { useModal } from "./modal.js";
5
5
  import { HeaderText, SectionHeading } from "./typography.js";
6
- const PANEL = "mx-auto mt-28 max-w-[calc(100vw-2rem)] overflow-visible rounded-2xl border border-slate-200 bg-white p-0 text-slate-900 shadow-2xl -translate-y-2 opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:translate-y-0 open:opacity-100 starting:open:-translate-y-2 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
7
- const SIZE = { sm: "w-80", md: "w-110", lg: "w-160" };
6
+ /** Shared by every size: the surface, its colours and the fade (and the scrim's) in and out. */
7
+ const BASE = "bg-white p-0 text-slate-900 opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
8
+ /** A floating panel under the top edge: it also slides down 8px as it fades in. */
9
+ const WINDOW = "mx-auto mt-28 max-w-[calc(100vw-2rem)] overflow-visible rounded-2xl border border-slate-200 shadow-2xl -translate-y-2 open:translate-y-0 starting:open:-translate-y-2";
10
+ const WIDTH = { sm: "w-80", md: "w-110", lg: "w-160" };
11
+ /** The whole viewport, edge to edge: no margin, radius, border or shadow, and a column so the body can fill it. */
12
+ const FULL = "fixed inset-0 m-0 size-full max-h-none max-w-none flex-col overflow-hidden rounded-none border-0 open:flex";
8
13
  /**
9
14
  * A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
10
15
  * only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
@@ -14,5 +19,6 @@ export function Dialog({ title, description, children, footer, size = "md", onCl
14
19
  const dialog = useRef(null);
15
20
  const titleId = useId();
16
21
  const closed = useModal(dialog, onClose);
17
- return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(PANEL, SIZE[size]), children: _jsxs("div", { className: "p-5.5", children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && _jsx("div", { className: "mt-4", children: children }), footer && _jsx("div", { className: "mt-5 flex justify-end gap-2", children: footer })] }) }));
22
+ const full = size === "full";
23
+ return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("p-5.5", full && "flex min-h-0 flex-1 flex-col"), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { className: cx("mt-4", full && "min-h-0 flex-1 overflow-auto"), children: children })), footer && (_jsx("div", { className: cx("mt-5 flex justify-end gap-2", full && "shrink-0"), children: footer }))] }) }));
18
24
  }
@@ -7,11 +7,17 @@ import type { Passthrough } from "./props.js";
7
7
  * line inside a card, use `Text as="p" color="muted"`. `level` is the title's heading level (default 2): one below the
8
8
  * page's own heading, so the empty state inside a `SectionHeading` section is a 3.
9
9
  */
10
- export declare function EmptyState({ title, level, children, actions, ...rest }: {
10
+ export declare function EmptyState({ title, level, padding, children, actions, ...rest }: {
11
11
  /** The heading, centred: what is missing ("No projects yet"). It names the section. */
12
12
  title: ReactNode;
13
13
  /** The title's heading level, 2 to 6. It does not change how the title looks. */
14
14
  level?: 2 | 3 | 4 | 5 | 6 | undefined;
15
+ /**
16
+ * `inset` pads all four sides, for an empty state in a `Card flush`, whose body has no padding
17
+ * of its own. `default` pads above and below only.
18
+ * @default default
19
+ */
20
+ padding?: "default" | "inset" | undefined;
15
21
  children?: ReactNode;
16
22
  /** Buttons or links in a centred row at the bottom. */
17
23
  actions?: ReactNode;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
+ import { cx } from "./cx.js";
3
4
  import { SectionHeading } from "./typography.js";
4
5
  /**
5
6
  * What a list or a page shows when there is nothing in it yet: a centred `title`, the `children`
@@ -8,7 +9,7 @@ import { SectionHeading } from "./typography.js";
8
9
  * line inside a card, use `Text as="p" color="muted"`. `level` is the title's heading level (default 2): one below the
9
10
  * page's own heading, so the empty state inside a `SectionHeading` section is a 3.
10
11
  */
11
- export function EmptyState({ title, level = 2, children, actions, ...rest }) {
12
+ export function EmptyState({ title, level = 2, padding = "default", children, actions, ...rest }) {
12
13
  const titleId = useId();
13
- return (_jsxs("section", { ...rest, "aria-labelledby": titleId, className: "mx-auto flex w-full max-w-xl flex-col items-center gap-4 py-8 text-center", children: [_jsx(SectionHeading, { as: `h${level}`, id: titleId, children: title }), children != null && (_jsx("div", { className: "flex w-full flex-col items-center gap-5", children: children })), actions != null && _jsx("div", { className: "flex flex-wrap justify-center gap-2", children: actions })] }));
14
+ return (_jsxs("section", { ...rest, "aria-labelledby": titleId, className: cx("mx-auto flex w-full max-w-xl flex-col items-center gap-4 text-center", padding === "inset" ? "p-8" : "py-8"), children: [_jsx(SectionHeading, { as: `h${level}`, id: titleId, children: title }), children != null && (_jsx("div", { className: "flex w-full flex-col items-center gap-5", children: children })), actions != null && _jsx("div", { className: "flex flex-wrap justify-center gap-2", children: actions })] }));
14
15
  }
package/dist/Field.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare function fieldNote(hint: ReactNode, error: ReactNode): ReactEleme
6
6
  * A label above a control, with a hint or an error line under it. An error replaces the hint.
7
7
  * Inside a `FormGrid`, `span="full"` runs the field across both columns.
8
8
  */
9
- export declare function Field({ label, htmlFor, hint, error, span, children, ...rest }: {
9
+ export declare function Field({ label, htmlFor, hint, error, span, layout, children, ...rest }: {
10
10
  /** The text above the control. */
11
11
  label: ReactNode;
12
12
  /** The id of the control inside; the label's htmlFor. */
@@ -17,5 +17,12 @@ export declare function Field({ label, htmlFor, hint, error, span, children, ...
17
17
  error?: ReactNode;
18
18
  /** `full` spans every column of a `FormGrid`; unset takes one. It does nothing outside a `FormGrid`. */
19
19
  span?: "full" | undefined;
20
+ /**
21
+ * `row` puts the label beside the control, for a compact setting or a drawer row; the hint or
22
+ * error sits under the control. Every row label takes the same fixed column, so stacked rows line
23
+ * their controls up; a long label wraps inside it. `column` puts the label above it.
24
+ * @default column
25
+ */
26
+ layout?: "column" | "row" | undefined;
20
27
  children: ReactNode;
21
28
  } & Passthrough): ReactElement;
package/dist/Field.js CHANGED
@@ -14,6 +14,8 @@ export function fieldNote(hint, error) {
14
14
  * A label above a control, with a hint or an error line under it. An error replaces the hint.
15
15
  * Inside a `FormGrid`, `span="full"` runs the field across both columns.
16
16
  */
17
- export function Field({ label, htmlFor, hint, error, span, children, ...rest }) {
18
- return (_jsxs("div", { ...rest, className: cx("flex flex-col gap-1.5", span === "full" && "col-span-full"), children: [_jsx("label", { htmlFor: htmlFor, className: "text-sm font-medium text-slate-700", children: label }), children, fieldNote(hint, error)] }));
17
+ export function Field({ label, htmlFor, hint, error, span, layout = "column", children, ...rest }) {
18
+ const row = layout === "row";
19
+ const note = fieldNote(hint, error);
20
+ return (_jsxs("div", { ...rest, className: cx(row ? "grid grid-cols-[8rem_1fr] items-center gap-x-3 gap-y-1.5" : "flex flex-col gap-1.5", span === "full" && "col-span-full"), children: [_jsx("label", { htmlFor: htmlFor, className: cx("text-sm font-medium text-slate-700", row && "min-w-0 wrap-anywhere"), children: label }), children, note && row ? _jsx("div", { className: "col-start-2", children: note }) : note] }));
19
21
  }