@hackcove/ui 0.2.5 → 0.2.7

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/README.md CHANGED
@@ -39,14 +39,14 @@ export const Example = () => (
39
39
 
40
40
  ## What is exported
41
41
 
42
- - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading; `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; `as` is `section` (default), `article` or `div`), `EpicHeader`, `Empty`, `PageTitle` (deprecated: use `PageHeader`), `Pill`, `groupByEpic`, `Markdown`.
43
- - Layout (see Layout): `AppShell`, `appShellRootClass`, `Container`, `PageHeader`, `Stack`, `Section`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
42
+ - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading; `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; `as` is `section` (default), `article` or `div`; `id`, `data-*` and `aria-*` go on the root), `EpicHeader`, `Empty`, `PageTitle` (deprecated: use `PageHeader`), `Pill`, `groupByEpic`, `Markdown`.
43
+ - Layout (see Layout): `AppShell`, `AppShellBrand`, `appShellRootClass`, `appShellRootClassFor`, `Container`, `PageHeader`, `Stack`, `Section`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
44
44
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
45
45
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `ModalHeading`, `Subtitle`, `Overline`,
46
46
  `Body`, `BodyStrong`, `Caption`. Three opt-in props, none changing today's look: `SectionHeading rule`
47
47
  (a hairline under it, with its spacing), `SubsectionHeading size` (`sm` default, `base` bold, `lg`),
48
48
  `Body preserveNewlines` (keeps a note's line breaks).
49
- - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost` or `link`, the link having no box: text with an underline on hover, for an in-line action; `size="sm"` is compact, `text-xs`, for table rows and toolbars; `tone="danger"` is red, for delete and clear), `buttonClass(variant, { size, tone })` (the classes of a `Button`, for a router's own `<Link>`), `ButtonLink` (a link that looks like a `Button`: `href`, or `render` to draw the app's own router `<Link>`; takes `variant`, `size`, `tone`, `external`), `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`), `ColourSchemeToggle` (a light/dark switch on `<html>`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field), `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, or `sm`: `text-xs`, less padding, for dense forms and table rows) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, or `auto`: no width class, as wide as the content or as a `className` width such as `w-24`); a fixed width replaces `w-full`, so there is no class to collide with. `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; `className` goes on the label, other props on the input; `size="sm"` is `text-xs` with a smaller box, and with no `label` it is a bare box for a table cell, named by `aria-label`). `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, `className` goes on the label, `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; 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). `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; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section). `NavTabs` (a row of pill links in a `<nav>`; `items` are `{ label, href, active?, external? }` or `{ label, render, active? }`, `label` names the landmark, the active one is filled and gets `aria-current="page"`; `external` opens in a new tab; see Router links). `navTabClass` (the classes of one tab, for a router's own `<Link>`). `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 `{ value, label, href | render }` links in a `<nav>` with `aria-current="page"` on the current one; `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange` and `label` as `Tabs`, as a `radiogroup`). `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). `crumbLinkClass` (the classes of a crumb that links, for a router's own `<Link>`). `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`, `TableRow`, `TableHeaderCell` (`align` `start` or `end`) and `TableCell` (`align`, `tone` `default`, `strong` or `muted`, `numeric` for tabular figures, `mono` for a small mono face)). `rowLinkClass` (the classes of the one link in a row whose hit area covers the row, so the whole row is the click target; for a router's own `<Link>`). `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` and the `children` as a note). `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, `className` and every other anchor prop pass through). `linkClass` (the classes of one, for a router's own `<Link>`) and `linkStretchClass` (the stretch). `InlineLinks` (a wrapping row of small links joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; `className` goes on the row). `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). `Divider` (a hairline `<hr>` in the theme's line colour; spacing is the caller's, on `className`). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `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). `ToggleChip` (an on/off filter pill; `pressed` sets `aria-pressed` and the filled look, `dot` adds a leading colour dot, and the caller toggles it in `onClick`). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `ToggleChip`s; `className` goes on the fieldset). `DatePicker` and `DateField` (see Dates).
49
+ - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost` or `link`, the link having no box: text with an underline on hover, for an in-line action; `size="sm"` is compact, `text-xs`, for table rows and toolbars; `tone="danger"` is red, for delete and clear), `buttonClass(variant, { size, tone })` (the classes of a `Button`, for a router's own `<Link>`), `ButtonLink` (a link that looks like a `Button`: `href`, or `render` to draw the app's own router `<Link>`; takes `variant`, `size`, `tone`, `external`), `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`), `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), `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, or `sm`: `text-xs`, less padding, for dense forms and table rows) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, or `auto`: no width class, as wide as the content or as a `className` width such as `w-24`); a fixed width replaces `w-full`, so there is no class to collide with. `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; `className` goes on the label, other props on the input; `size="sm"` is `text-xs` with a smaller box, `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`). `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, `className` goes on the label, `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; 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). `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; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section). `NavTabs` (a row of pill links in a `<nav>`; `items` are `{ label, href, active?, external? }` or `{ label, render, active? }`, `label` names the landmark, the active one is filled and gets `aria-current="page"`; `external` opens in a new tab; see Router links). `navTabClass` (the classes of one tab, for a router's own `<Link>`). `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 `{ value, label, href | render }` links in a `<nav>` with `aria-current="page"` on the current one; `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 like `NavTabs` does, with no rule, for `PageHeader`'s `tabs` slot; `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 `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. `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). `crumbLinkClass` (the classes of a crumb that links, for a router's own `<Link>`). `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`, `TableRow`, `TableHeaderCell` (`align` `start` or `end`) and `TableCell` (`align`, `tone` `default`, `strong` or `muted`, `numeric` for tabular figures, `mono` for a small mono face, `secondary` for a second, muted line under the content); `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` except `Table`, whose goes on the wrapper). `rowLinkClass` (the classes of the one link in a row whose hit area covers the row, so the whole row is the click target; for a router's own `<Link>`). `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` and the `children` as a note). `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, `className` and every other anchor prop pass through). `linkClass` (the classes of one, for a router's own `<Link>`) and `linkStretchClass` (the stretch). `InlineLinks` (a wrapping row of small links joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; `className` goes on the row). `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). `Divider` (a hairline `<hr>` in the theme's line colour; spacing is the caller's, on `className`). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `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). `ToggleChip` (an on/off filter pill; `pressed` sets `aria-pressed` and the filled look, `dot` adds a leading colour dot, and the caller toggles it in `onClick`). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `ToggleChip`s; `className` goes on the fieldset). `DatePicker` and `DateField` (see Dates).
50
50
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
51
51
 
52
52
  ## Dates
@@ -107,7 +107,14 @@ classes (and `aria-current` on the active tab) to spread on it. The call site ca
107
107
  <ButtonLink variant="ghost" render={(props) => <Link to="/quotes" {...props} />}>Back</ButtonLink>
108
108
  ```
109
109
 
110
- With `render`, `href` is not used. `buttonClass`, `navTabClass`, `crumbLinkClass` and `linkClass` stay
110
+ With `render`, `href` is not used. The props `render` is handed are the exported type `LinkRenderProps`:
111
+ `className` and `children`, plus `aria-current`, `target`, `rel` and `title` when there is one. Each
112
+ optional field is `?: T` and an absent one is left out, never set to `undefined`, so the spread
113
+ compiles on a router `<Link>` that declares its props the same way under `exactOptionalPropertyTypes`
114
+ (TanStack Router's does). Any other anchor prop you gave the component (`id`, `download`, a handler)
115
+ is handed over too.
116
+
117
+ `buttonClass`, `navTabClass`, `crumbLinkClass` and `linkClass` stay
111
118
  exported for an app that would rather put the class on its own link.
112
119
 
113
120
  ## Layout
@@ -137,9 +144,25 @@ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackco
137
144
  with fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
138
145
  `brand` (logo and name, at the brand type with the logo gap), `nav` (a `NavTabs`), `header` (a
139
146
  free node, the escape hatch) and `controls` (end-aligned, `gap-2`, centred): no wrapper, no
140
- `className`. `hideChromeInPrint` is opt-in: the header and footer are hidden in print and main
147
+ `className`. Nothing in `controls` shrinks, and a full-width control there (a `Select` at its
148
+ default `width="full"`) sizes to its content, so a `Select` next to a `ColourSchemeToggle` leaves
149
+ the toggle its square. `hideChromeInPrint` is opt-in: the header and footer are hidden in print and main
141
150
  drops its padding (the page margin is then your `@page`); without it a print is unchanged. `bleed` drops main's `Container` for a full-bleed
142
151
  page; the header keeps its own, so put a `Container` back around anything that should line up.
152
+ - **`AppShellBrand`** is the `brand` slot as one link: `logo` and `label` inside the same `<a>`, so
153
+ the whole brand goes home (the label names the link; the logo is hidden from assistive tech). With
154
+ `render` it draws your router's `<Link>`, as `NavTabs`, `Breadcrumbs` and `TextLink` do:
155
+
156
+ ```tsx
157
+ <AppShell brand={<AppShellBrand logo={<Avatar name="Quotes" size="sm" />} label="Quotes"
158
+ render={(props) => <Link to="/" {...props} />} />} …>
159
+ ```
160
+
161
+ A plain node in `brand` still works. Stories: `Layout/AppShell › LinkedBrand`, `RouterBrand`.
162
+ - **`appShellRootClassFor(scheme, schemes?)`** is what goes on `<html>` when the server knows the
163
+ scheme: that scheme's class and `appShellRootClass`, joined, so there is no string concatenation:
164
+ `<html className={appShellRootClassFor(scheme, schemes)}>`. `appShellRootClass` and `schemeClass`
165
+ stay exported.
143
166
  - **`Container`** is the only place the content width (`max-w-6xl`) and side padding
144
167
  (`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
145
168
  no `mx-auto`, no `px-*` on a page. AppShell already gives them the edge.
@@ -260,6 +283,24 @@ Two states, light and dark. The stored choice wins; with nothing stored the syst
260
283
  writes `localStorage`, which an app reading a cookie simply ignores. With neither prop nothing
261
284
  changes: `dark` on and off, light on the server.
262
285
 
286
+ **Persisting to a cookie instead of `localStorage`.** Pass `store={false}` (default `true`) and write the
287
+ choice from `onChange`; the toggle then touches no storage at all:
288
+
289
+ ```tsx
290
+ <ColourSchemeToggle
291
+ store={false}
292
+ initial={scheme} // read from the cookie on the server
293
+ onChange={(next) => {
294
+ document.cookie = `scheme=${next}; path=/; max-age=31536000; samesite=lax`;
295
+ // or POST it to the server, which sets the cookie
296
+ }}
297
+ />
298
+ ```
299
+
300
+ The toggle takes `id`, `data-*` and the rest of a button's props, which go on its button. It has no
301
+ `aria-pressed`: its label already says what a click does ("Switch to dark theme"), and a pressed state on a
302
+ button whose label changes would say it twice.
303
+
263
304
  Without React, `applyColourScheme(scheme, root?, schemes?)`, `readColourScheme(fallback?)`,
264
305
  `storeColourScheme(scheme)` and `schemeClass(scheme, schemes?)` are exported too; `ColourScheme` is
265
306
  `"light" | "dark"` and `ColourSchemeClasses` is the `schemes` shape.
@@ -1,9 +1,34 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
+ import { type ColourScheme, type ColourSchemeClasses } from "./colourScheme.js";
3
+ import { type LinkRender } from "./link.js";
2
4
  /**
3
5
  * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
4
6
  * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
5
7
  */
6
8
  export declare const appShellRootClass = "overflow-y-scroll";
9
+ /**
10
+ * Everything an app puts on its own `<html>` from the scheme the server knows: that scheme's class
11
+ * (`schemeClass`) and `appShellRootClass`, joined. `<html className={appShellRootClassFor(scheme,
12
+ * schemes)}>` replaces `schemeClass(scheme, schemes) + " " + appShellRootClass`.
13
+ */
14
+ export declare function appShellRootClassFor(scheme: ColourScheme, schemes?: ColourSchemeClasses): string;
15
+ /**
16
+ * The `brand` slot as one link: a logo and a label inside the same `<a>`, so the whole brand is the
17
+ * way home. The label names the link; the logo is decoration and hidden from assistive tech. With
18
+ * `render` it draws the app's own router `<Link>` (`render={(p) => <Link to="/" {...p} />}`).
19
+ */
20
+ export declare function AppShellBrand({ logo, label, href, render, }: {
21
+ /** The mark before the label, e.g. an `Avatar` or an `<img>`. */
22
+ logo?: ReactNode;
23
+ /** The name beside the logo. It is the link's accessible name. */
24
+ label: ReactNode;
25
+ } & ({
26
+ href: string;
27
+ render?: LinkRender | undefined;
28
+ } | {
29
+ href?: string | undefined;
30
+ render: LinkRender;
31
+ })): ReactElement;
7
32
  /**
8
33
  * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
9
34
  * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
@@ -15,11 +40,11 @@ export declare const appShellRootClass = "overflow-y-scroll";
15
40
  * `className` for any of them.
16
41
  */
17
42
  export declare function AppShell({ brand, nav, controls, header, subnav, footer, bleed, hideChromeInPrint, children, }: {
18
- /** The brand: a logo and a name, in the header's brand type, with the gap between them. */
43
+ /** The brand: a logo and a name, in the header's brand type, with the gap between them. `AppShellBrand` makes both one link. */
19
44
  brand?: ReactNode;
20
45
  /** The main navigation, e.g. `NavTabs` (it is already a `<nav>`). */
21
46
  nav?: ReactNode;
22
- /** The end of the row: a picker, a theme toggle. Gap-2, vertically centred. */
47
+ /** The end of the row: a picker, a theme toggle. Gap-2, vertically centred; no child shrinks, and a full-width control (a `Select` at its default) sizes to its content. */
23
48
  controls?: ReactNode;
24
49
  /** The escape hatch: a free node in the header row, after `brand` and `nav`. */
25
50
  header?: ReactNode;
package/dist/AppShell.js CHANGED
@@ -1,12 +1,35 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { Container } from "./Container.js";
4
- import { cx } from "./cx.js";
4
+ import { schemeClass } from "./colourScheme.js";
5
+ import { cx, focusRing } from "./cx.js";
6
+ import { renderLink } from "./link.js";
5
7
  /**
6
8
  * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
7
9
  * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
8
10
  */
9
11
  export const appShellRootClass = "overflow-y-scroll";
12
+ /**
13
+ * Everything an app puts on its own `<html>` from the scheme the server knows: that scheme's class
14
+ * (`schemeClass`) and `appShellRootClass`, joined. `<html className={appShellRootClassFor(scheme,
15
+ * schemes)}>` replaces `schemeClass(scheme, schemes) + " " + appShellRootClass`.
16
+ */
17
+ export function appShellRootClassFor(scheme, schemes) {
18
+ return cx(schemeClass(scheme, schemes), appShellRootClass);
19
+ }
20
+ /**
21
+ * The `brand` slot as one link: a logo and a label inside the same `<a>`, so the whole brand is the
22
+ * way home. The label names the link; the logo is decoration and hidden from assistive tech. With
23
+ * `render` it draws the app's own router `<Link>` (`render={(p) => <Link to="/" {...p} />}`).
24
+ */
25
+ export function AppShellBrand({ logo, label, href, render, }) {
26
+ return renderLink({
27
+ href,
28
+ render,
29
+ className: cx("flex items-center gap-2 rounded", focusRing),
30
+ children: (_jsxs(_Fragment, { children: [logo && (_jsx("span", { "aria-hidden": "true", className: "flex shrink-0 items-center", children: logo })), _jsx("span", { children: label })] })),
31
+ });
32
+ }
10
33
  // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
11
34
  const ROW = "flex items-center gap-4 overflow-x-auto whitespace-nowrap *:shrink-0";
12
35
  // While a row has more to scroll to on a side, that edge fades out (a mask, so it follows any theme).
@@ -44,7 +67,7 @@ function ScrollRow({ className, children }) {
44
67
  }
45
68
  /** The header row's slots in order: brand, nav, the free `children`, then controls at the end. */
46
69
  function HeaderSlots({ brand, nav, controls, children, }) {
47
- return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav, children, controls && _jsx("div", { className: "ml-auto flex items-center gap-2", children: controls })] }));
70
+ return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav, children, controls && (_jsx("div", { className: "ml-auto flex items-center gap-2 *:shrink-0 [&>.w-full]:w-auto", children: controls }))] }));
48
71
  }
49
72
  /**
50
73
  * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
@@ -3,9 +3,11 @@ import type { ControlSize } from "./control.js";
3
3
  /**
4
4
  * A native checkbox with its label. `className` goes on the label, every other prop on the input.
5
5
  * `size="sm"` is `text-xs` and a smaller box, for a dense row. Leave `label` out for a bare box in
6
- * a table cell, and name it with `aria-label`.
6
+ * a table cell, and name it with `aria-label`. `align="start"` puts the box on the label's first
7
+ * line, for a label that wraps onto several; the default centres it on the whole label.
7
8
  */
8
- export declare function Checkbox({ label, size, className, ...rest }: {
9
+ export declare function Checkbox({ label, size, align, className, ...rest }: {
9
10
  label?: ReactNode;
10
11
  size?: ControlSize | undefined;
12
+ align?: "center" | "start" | undefined;
11
13
  } & Omit<ComponentPropsWithoutRef<"input">, "type" | "size" | "width">): ReactElement;
package/dist/Checkbox.js CHANGED
@@ -3,9 +3,10 @@ import { cx, focusRing } from "./cx.js";
3
3
  /**
4
4
  * A native checkbox with its label. `className` goes on the label, every other prop on the input.
5
5
  * `size="sm"` is `text-xs` and a smaller box, for a dense row. Leave `label` out for a bare box in
6
- * a table cell, and name it with `aria-label`.
6
+ * a table cell, and name it with `aria-label`. `align="start"` puts the box on the label's first
7
+ * line, for a label that wraps onto several; the default centres it on the whole label.
7
8
  */
8
- export function Checkbox({ label, size = "md", className, ...rest }) {
9
+ export function Checkbox({ label, size = "md", align = "center", className, ...rest }) {
9
10
  const small = size === "sm";
10
- return (_jsxs("label", { className: cx("inline-flex cursor-pointer items-center text-slate-700", small ? "gap-1.5 text-xs" : "gap-2 text-sm", className), children: [_jsx("input", { type: "checkbox", className: cx(small ? "h-3.5 w-3.5" : "h-4 w-4", "accent-violet-600", focusRing), ...rest }), label] }));
11
+ return (_jsxs("label", { className: cx("inline-flex cursor-pointer text-slate-700", align === "start" ? "items-start" : "items-center", small ? "gap-1.5 text-xs" : "gap-2 text-sm", className), children: [_jsx("input", { type: "checkbox", className: cx(small ? "h-3.5 w-3.5" : "h-4 w-4", align === "start" && "shrink-0", align === "start" && (small ? "mt-px" : "mt-0.5"), "accent-violet-600", focusRing), ...rest }), label] }));
11
12
  }
@@ -1,4 +1,4 @@
1
- import { type ReactElement } from "react";
1
+ import { type ComponentPropsWithoutRef, type ReactElement } from "react";
2
2
  import { type ColourScheme, type ColourSchemeClasses } from "./colourScheme.js";
3
3
  /**
4
4
  * A sun/moon button that switches `<html>` between light and dark and remembers the choice. It shows the scheme you would switch to. Pair it with `colourSchemeScript` in `<head>`, unless you pass `schemes` or `initial`: the script knows only the `dark` class and the stored choice, so an app that sets the scheme itself leaves it out.
@@ -7,12 +7,16 @@ import { type ColourScheme, type ColourSchemeClasses } from "./colourScheme.js";
7
7
  * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
8
8
  * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
9
9
  * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
10
+ * `store={false}` skips `localStorage`, for an app that keeps the choice in a server cookie and
11
+ * writes it from `onChange`. Every other button prop (`id`, `data-*`, `aria-*`) goes on the button.
10
12
  */
11
- export declare function ColourSchemeToggle({ className, onChange, schemes, initial, }: {
13
+ export declare function ColourSchemeToggle({ className, onChange, schemes, initial, store, ...rest }: {
12
14
  className?: string;
13
15
  onChange?: (scheme: ColourScheme) => void;
14
16
  /** The class each scheme puts on `<html>`. Default `{ light: "", dark: "dark" }`. */
15
17
  schemes?: ColourSchemeClasses | undefined;
16
18
  /** The scheme the server rendered. What the server and the hydrating client show; the real state follows right after. Default `"light"`. */
17
19
  initial?: ColourScheme | undefined;
18
- }): ReactElement;
20
+ /** Remember the choice in `localStorage`. `false` leaves persisting to `onChange`. Default `true`. */
21
+ store?: boolean | undefined;
22
+ } & Omit<ComponentPropsWithoutRef<"button">, "onChange" | "onClick" | "children" | "className" | "aria-label" | "title">): ReactElement;
@@ -17,13 +17,16 @@ function subscribe(onChange) {
17
17
  * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
18
18
  * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
19
19
  * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
20
+ * `store={false}` skips `localStorage`, for an app that keeps the choice in a server cookie and
21
+ * writes it from `onChange`. Every other button prop (`id`, `data-*`, `aria-*`) goes on the button.
20
22
  */
21
- export function ColourSchemeToggle({ className, onChange, schemes = { light: "", dark: "dark" }, initial, }) {
23
+ export function ColourSchemeToggle({ className, onChange, schemes = { light: "", dark: "dark" }, initial, store = true, ...rest }) {
22
24
  const scheme = useSyncExternalStore(subscribe, () => appliedColourScheme(document.documentElement, schemes), () => initial ?? "light");
23
25
  const next = scheme === "dark" ? "light" : "dark";
24
- return (_jsx(IconButton, { label: `Switch to ${next} theme`, className: className, onClick: () => {
26
+ return (_jsx(IconButton, { ...rest, label: `Switch to ${next} theme`, className: className, onClick: () => {
25
27
  applyColourScheme(next, document.documentElement, schemes);
26
- storeColourScheme(next);
28
+ if (store)
29
+ storeColourScheme(next);
27
30
  onChange?.(next);
28
31
  }, children: next === "light" ? sun : moon }));
29
32
  }
package/dist/NavTabs.d.ts CHANGED
@@ -16,8 +16,8 @@ export type NavTab = {
16
16
  href?: string;
17
17
  render: LinkRender;
18
18
  });
19
- /** The classes of one tab, for a router's own <Link>. */
20
- export declare function navTabClass(active: boolean): string;
19
+ /** The classes of one tab, for a router's own <Link>. A `disabled` one drops the hover fill. */
20
+ export declare function navTabClass(active: boolean, disabled?: boolean): string;
21
21
  /** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
22
22
  export declare function NavTabs({ items, label, className, }: {
23
23
  items: NavTab[];
package/dist/NavTabs.js CHANGED
@@ -2,9 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
3
  import { renderLink } from "./link.js";
4
4
  const BASE = `rounded-lg px-3 py-1.5 transition ${focusRing}`;
5
- /** The classes of one tab, for a router's own <Link>. */
6
- export function navTabClass(active) {
7
- return cx(BASE, active ? "bg-slate-100 font-medium text-slate-900" : "text-slate-600 hover:bg-slate-100");
5
+ /** The classes of one tab, for a router's own <Link>. A `disabled` one drops the hover fill. */
6
+ export function navTabClass(active, disabled = false) {
7
+ return cx(BASE, active ? "bg-slate-100 font-medium text-slate-900" : "text-slate-600", !active && !disabled && "hover:bg-slate-100");
8
8
  }
9
9
  /** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
10
10
  export function NavTabs({ items, label, className, }) {
@@ -6,6 +6,8 @@ export type Segment = SwitchItem;
6
6
  * A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
7
7
  * `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
8
8
  * and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
9
- * `value` marks the current one, which gets `aria-current`). `label` names the group.
9
+ * `value` marks the current one, which gets `aria-current`). An item may carry a `count`, and a
10
+ * button one `disabled` (with a `title` for the reason); `disabled` on the group disables every
11
+ * option. `label` names the group.
10
12
  */
11
13
  export declare function SegmentedControl(props: SwitchProps): ReactElement;
@@ -4,12 +4,15 @@ import { Switcher } from "./switcher.js";
4
4
  // The focus ring sits inside the option, since the group's rounded edge would clip an outer one.
5
5
  const BASE = "border-l border-slate-300 px-3 py-1.5 transition first:rounded-l-full first:border-l-0 last:rounded-r-full focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-violet-600";
6
6
  const ON = "bg-violet-600 font-medium text-violet-50";
7
- const OFF = "bg-white text-slate-600 hover:bg-violet-50 hover:text-violet-700";
7
+ const OFF = "bg-white text-slate-600";
8
+ const OFF_LIVE = "hover:bg-violet-50 hover:text-violet-700";
8
9
  /**
9
10
  * A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
10
11
  * `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
11
12
  * and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
12
- * `value` marks the current one, which gets `aria-current`). `label` names the group.
13
+ * `value` marks the current one, which gets `aria-current`). An item may carry a `count`, and a
14
+ * button one `disabled` (with a `title` for the reason); `disabled` on the group disables every
15
+ * option. `label` names the group.
13
16
  */
14
17
  export function SegmentedControl(props) {
15
18
  return (_jsx(Switcher, { ...props, parts: {
@@ -17,6 +20,6 @@ export function SegmentedControl(props) {
17
20
  item: "radio",
18
21
  vertical: true,
19
22
  root: "inline-flex rounded-full border border-slate-300 text-sm",
20
- option: (active) => cx(BASE, active ? ON : OFF),
23
+ option: (active, disabled) => cx(BASE, active ? ON : OFF, !active && !disabled && OFF_LIVE),
21
24
  } }));
22
25
  }
package/dist/Table.d.ts CHANGED
@@ -1,10 +1,13 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
2
  type Align = "start" | "end";
3
3
  declare const TONE: {
4
4
  readonly default: "text-slate-700";
5
5
  readonly strong: "font-medium text-slate-900";
6
6
  readonly muted: "text-slate-500";
7
7
  };
8
+ type TableVariant = "card" | "plain";
9
+ /** What an element passes through (`data-*`, `aria-*`, `id`, …): the layout and the children stay the component's. */
10
+ type Rest<Tag extends "table" | "thead" | "tbody" | "tr" | "th" | "td", Own extends string = never> = Omit<ComponentPropsWithoutRef<Tag>, "className" | "style" | "children" | Own>;
8
11
  /**
9
12
  * The classes of the one link in a row whose hit area covers the row, so the whole row is the
10
13
  * click target. For a router's own `<Link>`; `TableRow` is the `relative` ancestor it stretches to.
@@ -13,36 +16,44 @@ export declare const rowLinkClass: string;
13
16
  /**
14
17
  * A list of records in a rounded, bordered card that scrolls sideways on a narrow screen. Compose it
15
18
  * from `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell` and `TableCell`. `caption` names the
16
- * table for assistive tech and is not drawn.
19
+ * table for assistive tech and is not drawn. `variant="plain"` drops the card (its border, fill and
20
+ * shadow), the head's tinted strip and the row hover, and keeps the row dividers; it is for a table
21
+ * that sits inside a card of its own. `className` goes on the scrolling wrapper; the other props
22
+ * (`id`, `data-*`, `aria-*`) on the `<table>`.
17
23
  */
18
- export declare function Table({ caption, className, children, }: {
24
+ export declare function Table({ caption, variant, className, children, ...rest }: {
19
25
  caption?: string | undefined;
26
+ variant?: TableVariant | undefined;
20
27
  className?: string | undefined;
21
28
  children: ReactNode;
22
- }): ReactElement;
23
- /** The `<thead>`: small uppercase column titles on a tinted strip. Holds one `TableRow` of `TableHeaderCell`s. */
24
- export declare function TableHead({ children }: {
29
+ } & Rest<"table">): ReactElement;
30
+ /** The `<thead>`: small uppercase column titles, on a tinted strip unless the table is `plain`. Holds one `TableRow` of `TableHeaderCell`s. */
31
+ export declare function TableHead({ children, ...rest }: {
25
32
  children: ReactNode;
26
- }): ReactElement;
27
- /** The `<tbody>`: the rows, each with a line above it and a fill on hover. */
28
- export declare function TableBody({ children }: {
33
+ } & Rest<"thead">): ReactElement;
34
+ /** The `<tbody>`: the rows, each with a line above it and, unless the table is `plain`, a fill on hover. */
35
+ export declare function TableBody({ children, ...rest }: {
29
36
  children: ReactNode;
30
- }): ReactElement;
37
+ } & Rest<"tbody">): ReactElement;
31
38
  /** A `<tr>`, `relative`: the anchor a `rowLinkClass` link stretches to. Use it in the head and the body. */
32
- export declare function TableRow({ children }: {
39
+ export declare function TableRow({ children, ...rest }: {
33
40
  children: ReactNode;
34
- }): ReactElement;
41
+ } & Rest<"tr">): ReactElement;
35
42
  /** A column title, `<th scope="col">`. */
36
- export declare function TableHeaderCell({ align, children, }: {
43
+ export declare function TableHeaderCell({ align, children, ...rest }: {
37
44
  align?: Align | undefined;
38
45
  children?: ReactNode;
39
- }): ReactElement;
40
- /** A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono face. */
41
- export declare function TableCell({ align, tone, numeric, mono, children, }: {
46
+ } & Rest<"th", "align" | "scope">): ReactElement;
47
+ /**
48
+ * A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono
49
+ * face. `secondary` is a second, muted line under the content.
50
+ */
51
+ export declare function TableCell({ align, tone, numeric, mono, secondary, children, ...rest }: {
42
52
  align?: Align | undefined;
43
53
  tone?: keyof typeof TONE | undefined;
44
54
  numeric?: boolean | undefined;
45
55
  mono?: boolean | undefined;
56
+ secondary?: ReactNode;
46
57
  children?: ReactNode;
47
- }): ReactElement;
58
+ } & Rest<"td", "align">): ReactElement;
48
59
  export {};
package/dist/Table.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
2
3
  import { cx } from "./cx.js";
3
4
  import { linkClass, linkStretchClass } from "./TextLink.js";
4
5
  const ALIGN = { start: "text-left", end: "text-right" };
@@ -7,6 +8,7 @@ const TONE = {
7
8
  strong: "font-medium text-slate-900",
8
9
  muted: "text-slate-500",
9
10
  };
11
+ const VariantContext = createContext("card");
10
12
  /**
11
13
  * The classes of the one link in a row whose hit area covers the row, so the whole row is the
12
14
  * click target. For a router's own `<Link>`; `TableRow` is the `relative` ancestor it stretches to.
@@ -15,28 +17,36 @@ export const rowLinkClass = cx(linkClass, linkStretchClass);
15
17
  /**
16
18
  * A list of records in a rounded, bordered card that scrolls sideways on a narrow screen. Compose it
17
19
  * from `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell` and `TableCell`. `caption` names the
18
- * table for assistive tech and is not drawn.
20
+ * table for assistive tech and is not drawn. `variant="plain"` drops the card (its border, fill and
21
+ * shadow), the head's tinted strip and the row hover, and keeps the row dividers; it is for a table
22
+ * that sits inside a card of its own. `className` goes on the scrolling wrapper; the other props
23
+ * (`id`, `data-*`, `aria-*`) on the `<table>`.
19
24
  */
20
- export function Table({ caption, className, children, }) {
21
- return (_jsx("div", { className: cx("overflow-x-auto rounded-xl border border-slate-200/70 bg-white shadow-sm", className), children: _jsxs("table", { className: "w-full text-left text-sm", children: [caption && _jsx("caption", { className: "sr-only", children: caption }), children] }) }));
25
+ export function Table({ caption, variant = "card", className, children, ...rest }) {
26
+ return (_jsx(VariantContext.Provider, { value: variant, children: _jsx("div", { className: cx("overflow-x-auto", variant === "card" && "rounded-xl border border-slate-200/70 bg-white shadow-sm", className), children: _jsxs("table", { ...rest, className: "w-full text-left text-sm", children: [caption && _jsx("caption", { className: "sr-only", children: caption }), children] }) }) }));
22
27
  }
23
- /** The `<thead>`: small uppercase column titles on a tinted strip. Holds one `TableRow` of `TableHeaderCell`s. */
24
- export function TableHead({ children }) {
25
- return (_jsx("thead", { className: "bg-slate-50 text-xs uppercase tracking-wide text-slate-500", children: children }));
28
+ /** The `<thead>`: small uppercase column titles, on a tinted strip unless the table is `plain`. Holds one `TableRow` of `TableHeaderCell`s. */
29
+ export function TableHead({ children, ...rest }) {
30
+ const plain = useContext(VariantContext) === "plain";
31
+ return (_jsx("thead", { ...rest, className: cx("text-xs uppercase tracking-wide text-slate-500", !plain && "bg-slate-50"), children: children }));
26
32
  }
27
- /** The `<tbody>`: the rows, each with a line above it and a fill on hover. */
28
- export function TableBody({ children }) {
29
- return (_jsx("tbody", { className: "[&>tr:hover]:bg-slate-50 [&>tr]:border-t [&>tr]:border-slate-200/70", children: children }));
33
+ /** The `<tbody>`: the rows, each with a line above it and, unless the table is `plain`, a fill on hover. */
34
+ export function TableBody({ children, ...rest }) {
35
+ const plain = useContext(VariantContext) === "plain";
36
+ return (_jsx("tbody", { ...rest, className: cx("[&>tr]:border-t [&>tr]:border-slate-200/70", !plain && "[&>tr:hover]:bg-slate-50"), children: children }));
30
37
  }
31
38
  /** A `<tr>`, `relative`: the anchor a `rowLinkClass` link stretches to. Use it in the head and the body. */
32
- export function TableRow({ children }) {
33
- return _jsx("tr", { className: "relative", children: children });
39
+ export function TableRow({ children, ...rest }) {
40
+ return (_jsx("tr", { ...rest, className: "relative", children: children }));
34
41
  }
35
42
  /** A column title, `<th scope="col">`. */
36
- export function TableHeaderCell({ align = "start", children, }) {
37
- return (_jsx("th", { scope: "col", className: cx("whitespace-nowrap px-4 py-2.5 font-medium", ALIGN[align]), children: children }));
43
+ export function TableHeaderCell({ align = "start", children, ...rest }) {
44
+ return (_jsx("th", { ...rest, scope: "col", className: cx("whitespace-nowrap px-4 py-2.5 font-medium", ALIGN[align]), children: children }));
38
45
  }
39
- /** A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono face. */
40
- export function TableCell({ align = "start", tone = "default", numeric = false, mono = false, children, }) {
41
- return (_jsx("td", { className: cx("whitespace-nowrap px-4 py-3", ALIGN[align], TONE[tone], numeric && "tabular-nums", mono && "font-mono text-xs"), children: children }));
46
+ /**
47
+ * A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono
48
+ * face. `secondary` is a second, muted line under the content.
49
+ */
50
+ export function TableCell({ align = "start", tone = "default", numeric = false, mono = false, secondary, children, ...rest }) {
51
+ return (_jsxs("td", { ...rest, className: cx("whitespace-nowrap px-4 py-3", ALIGN[align], TONE[tone], numeric && "tabular-nums", mono && "font-mono text-xs"), children: [children, secondary != null && secondary !== false && (_jsx("div", { className: "text-xs font-normal text-slate-500", children: secondary }))] }));
42
52
  }
package/dist/Tabs.d.ts CHANGED
@@ -2,12 +2,18 @@ import type { ReactElement } from "react";
2
2
  import { type SwitchItem, type SwitchProps } from "./switcher.js";
3
3
  /** One tab of a Tabs row. See `SwitchItem`. */
4
4
  export type Tab = SwitchItem;
5
+ export type TabsProps = SwitchProps & {
6
+ /** `underline` (default) is a row over a bottom rule; `pill` fills the current tab, as `NavTabs` does. */
7
+ variant?: "underline" | "pill";
8
+ };
5
9
  /**
6
10
  * Underline tabs for switching a view in place: a row over a bottom rule, the current tab
7
11
  * underlined in the accent. `items` are `{ value, label }` buttons (`value` and `onChange` are the
8
12
  * caller's state; the arrow keys, Home and End move between tabs) or links (`href`, or `render` to
9
13
  * draw the app's own router `<Link>`; `value` marks the current one, which gets `aria-current`).
10
- * `label` names the tab list. The panel is the caller's: give it `role="tabpanel"`, and a tab
11
- * `controls` to point at it.
14
+ * `variant="pill"` fills the current tab instead, with no rule, for under a `PageHeader`. An item may
15
+ * carry a `count`, and a button one `disabled` (with a `title` for the reason); `disabled` on the
16
+ * row disables every tab. `label` names the tab list. The panel is the caller's: give it
17
+ * `role="tabpanel"`, and a tab `controls` to point at it.
12
18
  */
13
- export declare function Tabs(props: SwitchProps): ReactElement;
19
+ export declare function Tabs({ variant, ...props }: TabsProps): ReactElement;
package/dist/Tabs.js CHANGED
@@ -1,23 +1,32 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
+ import { navTabClass } from "./NavTabs.js";
3
4
  import { Switcher } from "./switcher.js";
4
5
  const BASE = `-mb-px border-b-2 px-0.5 py-2 transition ${focusRing}`;
5
6
  const ON = "border-violet-600 font-medium text-violet-700";
6
- const OFF = "border-transparent text-slate-600 hover:text-violet-700";
7
+ const OFF = "border-transparent text-slate-600";
8
+ const OFF_LIVE = "hover:text-violet-700";
7
9
  /**
8
10
  * Underline tabs for switching a view in place: a row over a bottom rule, the current tab
9
11
  * underlined in the accent. `items` are `{ value, label }` buttons (`value` and `onChange` are the
10
12
  * caller's state; the arrow keys, Home and End move between tabs) or links (`href`, or `render` to
11
13
  * draw the app's own router `<Link>`; `value` marks the current one, which gets `aria-current`).
12
- * `label` names the tab list. The panel is the caller's: give it `role="tabpanel"`, and a tab
13
- * `controls` to point at it.
14
+ * `variant="pill"` fills the current tab instead, with no rule, for under a `PageHeader`. An item may
15
+ * carry a `count`, and a button one `disabled` (with a `title` for the reason); `disabled` on the
16
+ * row disables every tab. `label` names the tab list. The panel is the caller's: give it
17
+ * `role="tabpanel"`, and a tab `controls` to point at it.
14
18
  */
15
- export function Tabs(props) {
19
+ export function Tabs({ variant = "underline", ...props }) {
20
+ const pill = variant === "pill";
16
21
  return (_jsx(Switcher, { ...props, parts: {
17
22
  group: "tablist",
18
23
  item: "tab",
19
24
  vertical: false,
20
- root: "flex flex-wrap gap-x-5 border-b border-slate-200 text-sm",
21
- option: (active) => cx(BASE, active ? ON : OFF),
25
+ root: pill
26
+ ? "flex flex-wrap gap-1 text-sm"
27
+ : "flex flex-wrap gap-x-5 border-b border-slate-200 text-sm",
28
+ option: (active, disabled) => pill
29
+ ? navTabClass(active, disabled)
30
+ : cx(BASE, active ? ON : OFF, !active && !disabled && OFF_LIVE),
22
31
  } }));
23
32
  }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { StatTile, type StatTileTone } from "./StatTile.js";
4
4
  export { ProgressBar, type BarSize, type BarTone } from "./ProgressBar.js";
5
5
  export { SplitBar } from "./SplitBar.js";
6
6
  export { Markdown } from "./Markdown.js";
7
- export { AppShell, appShellRootClass } from "./AppShell.js";
7
+ export { AppShell, AppShellBrand, appShellRootClass, appShellRootClassFor } from "./AppShell.js";
8
8
  export { Container } from "./Container.js";
9
9
  export { PageHeader, type PageHeaderProps } from "./PageHeader.js";
10
10
  export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, SidebarLayout, } from "./PageLayouts.js";
@@ -29,7 +29,7 @@ export { IconButton } from "./IconButton.js";
29
29
  export { ColourSchemeToggle } from "./ColourSchemeToggle.js";
30
30
  export { applyColourScheme, schemeClass, type ColourSchemeClasses, colourSchemeScript, readColourScheme, storeColourScheme, type ColourScheme, } from "./colourScheme.js";
31
31
  export { NavTabs, navTabClass, type NavTab } from "./NavTabs.js";
32
- export { Tabs, type Tab } from "./Tabs.js";
32
+ export { Tabs, type Tab, type TabsProps } from "./Tabs.js";
33
33
  export { SegmentedControl, type Segment } from "./SegmentedControl.js";
34
34
  export { ToggleChip } from "./ToggleChip.js";
35
35
  export { FilterGroup } from "./FilterGroup.js";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { StatTile } from "./StatTile.js";
4
4
  export { ProgressBar } from "./ProgressBar.js";
5
5
  export { SplitBar } from "./SplitBar.js";
6
6
  export { Markdown } from "./Markdown.js";
7
- export { AppShell, appShellRootClass } from "./AppShell.js";
7
+ export { AppShell, AppShellBrand, appShellRootClass, appShellRootClassFor } from "./AppShell.js";
8
8
  export { Container } from "./Container.js";
9
9
  export { PageHeader } from "./PageHeader.js";
10
10
  export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, ProseLayout, SidebarLayout, } from "./PageLayouts.js";
package/dist/link.d.ts CHANGED
@@ -1,13 +1,25 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ type AriaCurrent = NonNullable<ComponentPropsWithoutRef<"a">["aria-current"]>;
2
3
  /**
3
4
  * What a `render` function receives to draw a link with the app's own router `<Link>`: the
4
- * component's classes, its label as `children`, and (for a tab that is current or a link that is
5
- * external) `aria-current`, `target` and `rel`. Spread it on the router's link:
6
- * `render: (props) => <Link to="/quotes" {...props} />`. `@hackcove/ui` has no router dependency.
5
+ * component's classes, its label as `children`, and (for a tab that is current, a link that is
6
+ * external or an option with a tooltip) `aria-current`, `target`, `rel` and `title`. Spread it on
7
+ * the router's link: `render: (props) => <Link to="/quotes" {...props} />`. `@hackcove/ui` has no
8
+ * router dependency.
9
+ *
10
+ * An optional field is `?: T`, never `?: T | undefined`, and a key that has no value is left out
11
+ * rather than set to `undefined`. A router's `<Link>` declares its props that way, so under
12
+ * `exactOptionalPropertyTypes` the spread compiles. Any other anchor prop the caller gave the
13
+ * component (`id`, `data-*`, `download`, a handler) arrives too, untyped here, and `<Link>`
14
+ * takes it as its own.
7
15
  */
8
- export type LinkRenderProps = Omit<ComponentPropsWithoutRef<"a">, "href" | "className" | "children"> & {
16
+ export type LinkRenderProps = {
9
17
  className: string;
10
18
  children: ReactNode;
19
+ "aria-current"?: AriaCurrent;
20
+ target?: string;
21
+ rel?: string;
22
+ title?: string;
11
23
  };
12
24
  /** Draws one link. The component hands it `LinkRenderProps`; it returns the app's own link element. */
13
25
  export type LinkRender = (props: LinkRenderProps) => ReactNode;
@@ -15,8 +27,15 @@ export type LinkRender = (props: LinkRenderProps) => ReactNode;
15
27
  * A link drawn by `render` when there is one, otherwise a plain `<a href>`. `external` adds
16
28
  * `target="_blank"` and `rel="noreferrer"` to either, unless the caller passes its own.
17
29
  */
18
- export declare function renderLink({ href, render, external, ...props }: LinkRenderProps & {
30
+ export declare function renderLink({ href, render, external, className, children, "aria-current": current, target, rel, title, ...rest }: {
31
+ className: string;
32
+ children: ReactNode;
33
+ "aria-current"?: AriaCurrent | undefined;
34
+ target?: string | undefined;
35
+ rel?: string | undefined;
36
+ title?: string | undefined;
19
37
  href?: string | undefined;
20
38
  render?: LinkRender | undefined;
21
39
  external?: boolean | undefined;
22
- }): ReactElement;
40
+ } & Omit<ComponentPropsWithoutRef<"a">, "href" | "className" | "children" | "aria-current">): ReactElement;
41
+ export {};
package/dist/link.js CHANGED
@@ -3,8 +3,18 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  * A link drawn by `render` when there is one, otherwise a plain `<a href>`. `external` adds
4
4
  * `target="_blank"` and `rel="noreferrer"` to either, unless the caller passes its own.
5
5
  */
6
- export function renderLink({ href, render, external = false, ...props }) {
7
- const all = external ? { target: "_blank", rel: "noreferrer", ...props } : props;
6
+ export function renderLink({ href, render, external = false, className, children, "aria-current": current, target, rel, title, ...rest }) {
7
+ const opened = external ? { target: "_blank", rel: "noreferrer" } : {};
8
+ const all = {
9
+ ...rest,
10
+ className,
11
+ children,
12
+ ...(current === undefined ? {} : { "aria-current": current }),
13
+ ...opened,
14
+ ...(target === undefined ? {} : { target }),
15
+ ...(rel === undefined ? {} : { rel }),
16
+ ...(title === undefined ? {} : { title }),
17
+ };
8
18
  if (render)
9
19
  return _jsx(_Fragment, { children: render(all) });
10
20
  return _jsx("a", { ...(href === undefined ? {} : { href }), ...all });
@@ -4,9 +4,17 @@ type Option = {
4
4
  /** What `value` is set to when this option is current. */
5
5
  value: string;
6
6
  label: ReactNode;
7
+ /** A small muted count after the label, part of the accessible name ("Shots 12"). */
8
+ count?: number;
9
+ /** A native tooltip; on a disabled option it is the reason. */
10
+ title?: string;
7
11
  };
8
12
  /** An option that is a button: it calls `onChange(value)`. */
9
13
  export type SwitchButton = Option & {
14
+ /**
15
+ * Not chosen by click or arrow key. The button stays in the page as `aria-disabled`, so its
16
+ * `title` still shows on hover and is read out.
17
+ */
10
18
  disabled?: boolean;
11
19
  /** The id of the element this option controls (a tab's panel). */
12
20
  controls?: string;
@@ -42,9 +50,12 @@ export type SwitchProps = Shared & ({
42
50
  items: SwitchButton[];
43
51
  /** Called with the option's `value` when one is chosen, by click or arrow key. */
44
52
  onChange: (value: string) => void;
53
+ /** Every option is disabled, e.g. until the page has hydrated. */
54
+ disabled?: boolean;
45
55
  } | {
46
56
  items: SwitchLink[];
47
57
  onChange?: undefined;
58
+ disabled?: undefined;
48
59
  });
49
60
  type Parts = {
50
61
  /** The roles of a group of buttons; a group with links is a `<nav>` instead. */
@@ -53,14 +64,14 @@ type Parts = {
53
64
  /** The vertical arrows move too, as in a radio group. */
54
65
  vertical: boolean;
55
66
  root: string;
56
- option: (active: boolean) => string;
67
+ option: (active: boolean, disabled: boolean) => string;
57
68
  };
58
69
  /**
59
70
  * The shared body of Tabs and SegmentedControl: a row of options with one current. Buttons form a
60
71
  * `tablist` or `radiogroup` with a single tab stop and arrow-key movement (the arrow focuses and
61
72
  * chooses); links form a `<nav>` with `aria-current="page"` on the current one.
62
73
  */
63
- export declare function Switcher({ items, value, onChange, label, className, parts, }: SwitchProps & {
74
+ export declare function Switcher({ items, value, onChange, disabled: groupDisabled, label, className, parts, }: SwitchProps & {
64
75
  parts: Parts;
65
76
  }): ReactElement;
66
77
  export {};
package/dist/switcher.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  import { renderLink } from "./link.js";
@@ -14,28 +14,36 @@ function target(key, at, count, vertical) {
14
14
  return count - 1;
15
15
  return -1;
16
16
  }
17
+ /** The label with its count after it; the plain-space text node keeps the name "Shots 12". */
18
+ function Label({ item }) {
19
+ if (item.count === undefined)
20
+ return _jsx(_Fragment, { children: item.label });
21
+ return (_jsxs(_Fragment, { children: [item.label, " ", _jsx("span", { className: "ml-0.5 text-xs tabular-nums opacity-70", children: item.count })] }));
22
+ }
17
23
  const isLinks = (items) => items.some((item) => item.href !== undefined || item.render !== undefined);
18
24
  /**
19
25
  * The shared body of Tabs and SegmentedControl: a row of options with one current. Buttons form a
20
26
  * `tablist` or `radiogroup` with a single tab stop and arrow-key movement (the arrow focuses and
21
27
  * chooses); links form a `<nav>` with `aria-current="page"` on the current one.
22
28
  */
23
- export function Switcher({ items, value, onChange, label, className, parts, }) {
29
+ export function Switcher({ items, value, onChange, disabled: groupDisabled, label, className, parts, }) {
24
30
  const root = cx(parts.root, className);
25
31
  if (isLinks(items)) {
26
32
  return (_jsx("nav", { "aria-label": label, className: root, children: items.map((item) => (_jsx(Fragment, { children: renderLink({
27
33
  href: item.href,
28
34
  render: item.render,
29
35
  external: item.external,
30
- className: parts.option(item.value === value),
36
+ className: parts.option(item.value === value, false),
31
37
  "aria-current": item.value === value ? "page" : undefined,
32
- children: item.label,
38
+ ...(item.title === undefined ? {} : { title: item.title }),
39
+ children: _jsx(Label, { item: item }),
33
40
  }) }, item.value))) }));
34
41
  }
35
- const enabled = items.filter((item) => !item.disabled);
42
+ const off = (item) => groupDisabled === true || item.disabled === true;
43
+ const enabled = items.filter((item) => !off(item));
36
44
  const stop = enabled.some((item) => item.value === value) ? value : enabled[0]?.value;
37
45
  function onKeyDown(event) {
38
- const buttons = [...event.currentTarget.querySelectorAll("button:not(:disabled)")];
46
+ const buttons = [...event.currentTarget.querySelectorAll("button:not([aria-disabled=true])")];
39
47
  const at = buttons.findIndex((button) => button === event.target);
40
48
  if (at < 0)
41
49
  return;
@@ -47,7 +55,10 @@ export function Switcher({ items, value, onChange, label, className, parts, }) {
47
55
  button.focus();
48
56
  button.click();
49
57
  }
50
- return (_jsx("div", { role: parts.group, "aria-label": label, onKeyDown: onKeyDown, className: root, children: items.map((item) => (_jsx("button", { type: "button", role: parts.item, ...(parts.item === "tab"
58
+ return (_jsx("div", { role: parts.group, "aria-label": label, ...(groupDisabled ? { "aria-disabled": true } : {}), onKeyDown: onKeyDown, className: root, children: items.map((item) => (_jsx("button", { type: "button", role: parts.item, ...(parts.item === "tab"
51
59
  ? { "aria-selected": item.value === value }
52
- : { "aria-checked": item.value === value }), ...(item.controls === undefined ? {} : { "aria-controls": item.controls }), tabIndex: item.value === stop ? 0 : -1, disabled: item.disabled === true, onClick: () => onChange?.(item.value), className: cx(parts.option(item.value === value), "disabled:opacity-50"), children: item.label }, item.value))) }));
60
+ : { "aria-checked": item.value === value }), ...(item.controls === undefined ? {} : { "aria-controls": item.controls }), tabIndex: item.value === stop ? 0 : -1, ...(off(item) ? { "aria-disabled": true } : {}), ...(item.title === undefined ? {} : { title: item.title }), onClick: () => {
61
+ if (!off(item))
62
+ onChange?.(item.value);
63
+ }, className: cx(parts.option(item.value === value, off(item)), off(item) && "cursor-not-allowed opacity-50"), children: _jsx(Label, { item: item }) }, item.value))) }));
53
64
  }
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
2
  /** @deprecated use PageHeader */
3
3
  export declare function PageTitle({ title, subtitle }: {
4
4
  title: string;
@@ -9,10 +9,11 @@ export declare function PageTitle({ title, subtitle }: {
9
9
  * Three opt-in forms for data views: `flush` drops the padding and clips the corners, for a table
10
10
  * that runs edge to edge; `bar` puts the title, count and `actions` in a tinted strip across the
11
11
  * top, over the body; `dashed` is an empty-state shell, a dashed outline with centred muted text.
12
- * `dashed` stands alone: it ignores `flush` and `bar`. A flush card has no padding for a `title`,
13
- * so give it a `bar`.
12
+ * `dashed` stands alone: it ignores `flush` and `bar`; `dashed="filled"` is the same shell on a
13
+ * surface fill with more padding, for an empty state that must read as a panel. A flush card has no
14
+ * padding for a `title`, so give it a `bar`. Other props (`id`, `data-*`, `aria-*`) go on the root.
14
15
  */
15
- export declare function Card({ title, count, actions, bar, flush, dashed, as: Tag, className, children, }: {
16
+ export declare function Card({ title, count, actions, bar, flush, dashed, as: Tag, className, children, ...rest }: {
16
17
  title?: string;
17
18
  count?: number;
18
19
  /** Buttons or links at the end of the header: in the strip with `bar`, else beside the title. */
@@ -21,12 +22,12 @@ export declare function Card({ title, count, actions, bar, flush, dashed, as: Ta
21
22
  bar?: boolean;
22
23
  /** No padding and `overflow-hidden`: the body (a table, a list) fills the card. */
23
24
  flush?: boolean;
24
- /** An empty-state shell: dashed outline, no shadow, no fill, centred muted text. */
25
- dashed?: boolean;
25
+ /** An empty-state shell: dashed outline, no shadow, no fill, centred muted text. `"filled"` puts it on a surface fill with more padding. */
26
+ dashed?: boolean | "filled";
26
27
  as?: "section" | "article" | "div";
27
28
  className?: string;
28
29
  children: ReactNode;
29
- }): import("react").JSX.Element;
30
+ } & Omit<ComponentPropsWithoutRef<"section">, "title" | "className" | "style" | "children">): import("react").JSX.Element;
30
31
  /** A coloured epic header row: a dot in the epic's colour + its label. */
31
32
  export declare function EpicHeader({ color, label }: {
32
33
  color: string;
package/dist/ui.js CHANGED
@@ -24,31 +24,35 @@ function CardHeader({ heading, actions }) {
24
24
  }
25
25
  /** `dashed` stands alone; `bar` needs a title or actions to fill it. */
26
26
  function cardForm(asked, hasHeader) {
27
- const dashed = asked.dashed ?? false;
27
+ const dashed = Boolean(asked.dashed);
28
28
  return {
29
29
  flush: (asked.flush ?? false) && !dashed,
30
30
  bar: (asked.bar ?? false) && !dashed && hasHeader,
31
31
  dashed,
32
+ filled: asked.dashed === "filled",
32
33
  };
33
34
  }
34
35
  function cardClass(form) {
35
- const { flush, bar, dashed } = form;
36
+ const { flush, bar, dashed, filled } = form;
36
37
  const clipped = flush || bar;
37
- const padding = dashed ? "p-8 text-center text-sm text-slate-500" : "p-5";
38
- return cx("rounded-xl border", dashed ? "border-dashed border-slate-300" : "border-slate-200/70 bg-white", !clipped && padding, !dashed && "shadow-sm", clipped && "overflow-hidden");
38
+ const padding = dashed
39
+ ? cx(filled ? "p-10" : "p-8", "text-center text-sm text-slate-500")
40
+ : "p-5";
41
+ return cx("rounded-xl border", dashed ? "border-dashed border-slate-300" : "border-slate-200/70 bg-white", filled && "bg-white", !clipped && padding, !dashed && "shadow-sm", clipped && "overflow-hidden");
39
42
  }
40
43
  /**
41
44
  * A bordered panel with an optional title and count; `as` picks the element (default `section`).
42
45
  * Three opt-in forms for data views: `flush` drops the padding and clips the corners, for a table
43
46
  * that runs edge to edge; `bar` puts the title, count and `actions` in a tinted strip across the
44
47
  * top, over the body; `dashed` is an empty-state shell, a dashed outline with centred muted text.
45
- * `dashed` stands alone: it ignores `flush` and `bar`. A flush card has no padding for a `title`,
46
- * so give it a `bar`.
48
+ * `dashed` stands alone: it ignores `flush` and `bar`; `dashed="filled"` is the same shell on a
49
+ * surface fill with more padding, for an empty state that must read as a panel. A flush card has no
50
+ * padding for a `title`, so give it a `bar`. Other props (`id`, `data-*`, `aria-*`) go on the root.
47
51
  */
48
- export function Card({ title, count, actions, bar, flush, dashed, as: Tag = "section", className, children, }) {
52
+ export function Card({ title, count, actions, bar, flush, dashed, as: Tag = "section", className, children, ...rest }) {
49
53
  const form = cardForm({ flush, bar, dashed }, Boolean(title || actions));
50
54
  const heading = title && (_jsx(CardHeading, { title: title, count: count, bar: form.bar, spaced: !form.bar && !actions }));
51
- return (_jsx(Tag, { className: cx(cardClass(form), className), children: form.bar ? (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions }), _jsx("div", { className: form.flush ? undefined : "p-5", children: children })] })) : (_jsxs(_Fragment, { children: [_jsx(CardHeader, { heading: heading, actions: actions }), children] })) }));
55
+ return (_jsx(Tag, { ...rest, className: cx(cardClass(form), className), children: form.bar ? (_jsxs(_Fragment, { children: [_jsx(CardBar, { heading: heading, actions: actions }), _jsx("div", { className: form.flush ? undefined : "p-5", children: children })] })) : (_jsxs(_Fragment, { children: [_jsx(CardHeader, { heading: heading, actions: actions }), children] })) }));
52
56
  }
53
57
  /** A coloured epic header row: a dot in the epic's colour + its label. */
54
58
  export function EpicHeader({ color, label }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/theme-* package.",
5
5
  "keywords": [
6
6
  "components",