@hackcove/ui 0.2.3 → 0.2.5

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 (91) hide show
  1. package/README.md +83 -13
  2. package/dist/AppShell.d.ts +19 -4
  3. package/dist/AppShell.js +45 -3
  4. package/dist/Avatar.d.ts +9 -4
  5. package/dist/Avatar.js +9 -3
  6. package/dist/Badge.d.ts +9 -0
  7. package/dist/Badge.js +10 -0
  8. package/dist/Breadcrumbs.d.ts +22 -3
  9. package/dist/Breadcrumbs.js +23 -2
  10. package/dist/BulletList.d.ts +3 -0
  11. package/dist/BulletList.js +6 -0
  12. package/dist/Button.d.ts +18 -8
  13. package/dist/Button.js +54 -22
  14. package/dist/ButtonGroup.d.ts +14 -0
  15. package/dist/ButtonGroup.js +10 -0
  16. package/dist/ButtonLink.d.ts +20 -0
  17. package/dist/ButtonLink.js +18 -0
  18. package/dist/Callout.d.ts +10 -2
  19. package/dist/Callout.js +8 -3
  20. package/dist/Checkbox.d.ts +10 -4
  21. package/dist/Checkbox.js +8 -3
  22. package/dist/Code.d.ts +3 -0
  23. package/dist/Code.js +6 -0
  24. package/dist/ColourSchemeToggle.d.ts +14 -3
  25. package/dist/ColourSchemeToggle.js +13 -9
  26. package/dist/Container.d.ts +3 -1
  27. package/dist/Container.js +2 -2
  28. package/dist/DateField.d.ts +15 -2
  29. package/dist/DateField.js +8 -4
  30. package/dist/DatePicker.d.ts +1 -1
  31. package/dist/DatePicker.js +3 -3
  32. package/dist/Divider.d.ts +3 -0
  33. package/dist/Divider.js +6 -0
  34. package/dist/Drawer.d.ts +29 -0
  35. package/dist/Drawer.js +26 -0
  36. package/dist/FileDrop.d.ts +10 -0
  37. package/dist/FileDrop.js +11 -0
  38. package/dist/InlineLinks.d.ts +11 -0
  39. package/dist/InlineLinks.js +11 -0
  40. package/dist/Input.d.ts +3 -2
  41. package/dist/Input.js +5 -4
  42. package/dist/KeyValueList.d.ts +11 -4
  43. package/dist/KeyValueList.js +14 -5
  44. package/dist/ListRow.d.ts +36 -0
  45. package/dist/ListRow.js +31 -0
  46. package/dist/Markdown.js +1 -1
  47. package/dist/NavTabs.d.ts +15 -4
  48. package/dist/NavTabs.js +13 -2
  49. package/dist/PageFade.d.ts +25 -0
  50. package/dist/PageFade.js +39 -0
  51. package/dist/PageLayouts.d.ts +22 -1
  52. package/dist/PageLayouts.js +20 -2
  53. package/dist/ProgressBar.d.ts +26 -0
  54. package/dist/ProgressBar.js +32 -0
  55. package/dist/SegmentedControl.d.ts +11 -0
  56. package/dist/SegmentedControl.js +22 -0
  57. package/dist/Select.d.ts +3 -2
  58. package/dist/Select.js +4 -4
  59. package/dist/SplitBar.d.ts +21 -0
  60. package/dist/SplitBar.js +15 -0
  61. package/dist/Stack.d.ts +18 -6
  62. package/dist/Stack.js +20 -6
  63. package/dist/StatTile.d.ts +16 -0
  64. package/dist/StatTile.js +21 -0
  65. package/dist/Table.d.ts +48 -0
  66. package/dist/Table.js +42 -0
  67. package/dist/Tabs.d.ts +13 -0
  68. package/dist/Tabs.js +23 -0
  69. package/dist/TextLink.d.ts +27 -0
  70. package/dist/TextLink.js +23 -0
  71. package/dist/Textarea.d.ts +3 -2
  72. package/dist/Textarea.js +4 -4
  73. package/dist/Timeline.d.ts +21 -0
  74. package/dist/Timeline.js +19 -0
  75. package/dist/Video.d.ts +10 -0
  76. package/dist/Video.js +10 -0
  77. package/dist/colourScheme.d.ts +11 -2
  78. package/dist/colourScheme.js +16 -3
  79. package/dist/control.d.ts +19 -2
  80. package/dist/control.js +25 -2
  81. package/dist/index.d.ts +29 -7
  82. package/dist/index.js +24 -3
  83. package/dist/link.d.ts +22 -0
  84. package/dist/link.js +11 -0
  85. package/dist/switcher.d.ts +66 -0
  86. package/dist/switcher.js +53 -0
  87. package/dist/typography.d.ts +32 -24
  88. package/dist/typography.js +39 -9
  89. package/dist/ui.d.ts +25 -5
  90. package/dist/ui.js +51 -7
  91. package/package.json +19 -1
package/README.md CHANGED
@@ -39,12 +39,14 @@ export const Example = () => (
39
39
 
40
40
  ## What is exported
41
41
 
42
- - Primitives: `Card`, `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`, `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; `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`.
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
- `Body`, `BodyStrong`, `Caption`.
47
- - Controls: `Button`, `buttonClass` (the classes of a `Button`, for a router's own `<Link>`), `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). `Checkbox` (a native checkbox with its label; `className` goes on the label, other props 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). `NavTabs` (a row of pill links in a `<nav>`; `items` are `{ label, href, active? }`, `label` names the landmark, the active one is filled and gets `aria-current="page"`). `navTabClass` (the classes of one tab, for a router's own `<Link>`). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, the last is the current page and gets `aria-current="page"`, and each crumb truncates). `crumbLinkClass` (the classes of a crumb that links, for a router's own `<Link>`). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; `size` is `sm`, `md` (default) or `lg`; `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). `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).
46
+ `Body`, `BodyStrong`, `Caption`. Three opt-in props, none changing today's look: `SectionHeading rule`
47
+ (a hairline under it, with its spacing), `SubsectionHeading size` (`sm` default, `base` bold, `lg`),
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).
48
50
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
49
51
 
50
52
  ## Dates
@@ -83,6 +85,30 @@ import { DatePicker, fromISODate, toISODate, type CalendarDate } from "@hackcove
83
85
  error once the field loses focus.
84
86
  - An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
85
87
  typed, focus leaving included, until it is fixed. `errorMessage` replaces either message.
88
+ - `error` is an error from outside the field, a server's say: it shows in place of the description,
89
+ in red with `role="alert"` (as `Field`'s `error` does), and marks the field invalid. It wins over
90
+ the built-in messages; clear it when the date changes. `isInvalid` marks the field invalid with no
91
+ message of its own; pair it with `errorMessage`.
92
+
93
+ ## Router links
94
+
95
+ `@hackcove/ui` has no router dependency. `NavTabs` (a tab), `Tabs` and `SegmentedControl` (an item with `href` or `render`), `Breadcrumbs` (a crumb), `TextLink` and
96
+ `ButtonLink` take a `render` function that draws the app's own `<Link>`, handed the component's
97
+ classes (and `aria-current` on the active tab) to spread on it. The call site carries no `className`:
98
+
99
+ ```tsx
100
+ <NavTabs
101
+ label="Main"
102
+ items={[
103
+ { label: "Quotes", active: true, render: (props) => <Link to="/quotes" {...props} /> },
104
+ { label: "Traces", href: "https://traces.example.com", external: true },
105
+ ]}
106
+ />
107
+ <ButtonLink variant="ghost" render={(props) => <Link to="/quotes" {...props} />}>Back</ButtonLink>
108
+ ```
109
+
110
+ With `render`, `href` is not used. `buttonClass`, `navTabClass`, `crumbLinkClass` and `linkClass` stay
111
+ exported for an app that would rather put the class on its own link.
86
112
 
87
113
  ## Layout
88
114
 
@@ -95,7 +121,7 @@ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackco
95
121
  // short page and a long one centre their content at the same x. AppShell cannot set it itself.
96
122
  <html className={appShellRootClass}>
97
123
  <body>
98
- <AppShell header={<><Logo /><NavTabs label="Main" items={nav} /></>} footer="…">
124
+ <AppShell brand={<Logo />} nav={<NavTabs label="Main" items={nav} />} footer="…">
99
125
  <PageHeader title="Builds" subtitle="Every build" actions={<Button>New</Button>} />
100
126
  <Stack>
101
127
  <Section title="Recent">…</Section>
@@ -106,8 +132,13 @@ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackco
106
132
  ```
107
133
 
108
134
  - **`AppShell`** is the page frame: a sticky header that is always one `h-14` row (a nav that does
109
- not fit scrolls sideways, it never wraps), an optional `subnav` second row (same rule), `main`
110
- with fixed `py-8`, and an optional `footer`. `bleed` drops main's `Container` for a full-bleed
135
+ not fit scrolls sideways, it never wraps, and the edge with more to scroll to fades out until it
136
+ is reached), an optional `subnav` second row (same rule), `main`
137
+ with fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
138
+ `brand` (logo and name, at the brand type with the logo gap), `nav` (a `NavTabs`), `header` (a
139
+ 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
141
+ 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
111
142
  page; the header keeps its own, so put a `Container` back around anything that should line up.
112
143
  - **`Container`** is the only place the content width (`max-w-6xl`) and side padding
113
144
  (`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
@@ -115,10 +146,13 @@ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackco
115
146
  - **`PageHeader`** is the top of every page: optional `breadcrumbs`, the `title` (a `PageHeading`),
116
147
  `subtitle`, `actions` (right of the title from `sm`, under it on a phone) and `tabs` under it. Its
117
148
  fixed bottom margin puts every page's first block at the same y.
118
- - **`Stack`** spaces blocks in a column: `gap="sm"` (`gap-4`) inside a group, `md` (`gap-6`, the
119
- default) between blocks, `lg` (`gap-8`) between groups. Use it instead of `space-y-*`.
120
- **`Section`** is a `<section>` Stack (`sm` by default) with an optional `SectionHeading` `title`
121
- and `actions` beside it.
149
+ - **`Stack`** spaces blocks in a column: `gap="xs"` (`gap-2`) between lines of one text block (a
150
+ heading, the line under it), `sm` (`gap-4`) inside a group, `md` (`gap-6`, the default) between
151
+ blocks, `lg` (`gap-8`) between groups. Use it instead of `space-y-*`. `width` caps the column:
152
+ `narrow` (`max-w-3xl`, a reading measure), `wide` (`max-w-5xl`), `full` (`w-full`) or `default`
153
+ (nothing). A capped Stack starts at the Container's left edge and is never re-centred.
154
+ **`Section`** is a `<section>` Stack (`sm` by default) with an optional `SectionHeading` `title`,
155
+ `actions` beside it and the same `width`.
122
156
 
123
157
  ### Page layouts
124
158
 
@@ -133,6 +167,11 @@ except the last four.
133
167
  - **`FormLayout`**: use this for a create or edit page. A `max-w-3xl` `<form>` that starts at the
134
168
  Container's left edge (it is not re-centred, so the title does not jump), with an `actions` row at
135
169
  the end. Other props (`onSubmit`, `action`) go on the `<form>`.
170
+ - **`ProseLayout`**: use this for a text page (privacy, terms, a CV, an about page). One `narrow`
171
+ (`max-w-3xl`) column that starts at the Container's left edge (not re-centred, so the title does
172
+ not jump): the header, then `children` in a `Stack` (`gap` prop, `md` by default). Unlike
173
+ `FormLayout` the header sits inside the column, so its `actions` stay beside the text.
174
+ `className` goes on the column.
136
175
  - **`CardGrid`**: use this for stat tiles or a gallery of cards. `columns` is `2`, `3` (default) or
137
176
  `4`: one column on a phone, two from `sm`, `columns` from `lg`.
138
177
  - **`SidebarLayout`**: use this when a `rail` of navigation sits beside the content. Render it in
@@ -142,6 +181,18 @@ except the last four.
142
181
  - **`FullBleedLayout`**: use this for a viewer that needs the whole width. Render it in
143
182
  `<AppShell bleed>`; `children` fill the width, and an optional `panel` takes `lg:w-72` on the
144
183
  right and stacks below under `lg`.
184
+
185
+ **`SidebarLayout` and `FullBleedLayout` deliberately break the shared left edge from `md` and
186
+ `lg` up.** A full-height rail and an edge-to-edge viewer need the viewport's edges, so they cannot
187
+ sit inside the Container the header uses. At 1280 px the header logo and every other layout start
188
+ at x=88, `SidebarLayout`'s rail at x=0 and its canvas at x=264 (rail 240 + the Container's 24
189
+ padding), `FullBleedLayout`'s `children` at x=0 (they add their own padding). Below `md` (`lg` for
190
+ `FullBleedLayout`) everything stacks and all layouts agree at the Container's padding. To avoid
191
+ the shift between pages, give a whole section of an app the same layout (every page under a
192
+ project on `SidebarLayout`) rather than mixing it with `ListLayout` pages that a user moves
193
+ between; the header, which stays inside the Container, is the constant. The title's y also
194
+ differs from `ListLayout` only when one page passes `breadcrumbs` and the other does not:
195
+ `PageHeader` puts them above the title in every layout.
145
196
  - **`CenteredLayout`**: use this for sign-in, landing, not-found and error pages. A `max-w-md` card
146
197
  centred at a fixed offset from the top; the one layout that does not share the left edge.
147
198
 
@@ -191,8 +242,27 @@ Two states, light and dark. The stored choice wins; with nothing stored the syst
191
242
  mode) is swallowed and the page still switches. It reads the current state from the `dark` class
192
243
  when it mounts, so it agrees with the script and renders the same on the server and the client.
193
244
 
194
- Without React, `applyColourScheme(scheme, root?)`, `readColourScheme(fallback?)` and
195
- `storeColourScheme(scheme)` are exported too; `ColourScheme` is `"light" | "dark"`.
245
+ 4. **A light theme with a class of its own, or a scheme the server knows.** An app that picks the
246
+ scheme from a cookie and whose light theme has a scope class passes both:
247
+
248
+ ```tsx
249
+ import { ColourSchemeToggle, schemeClass } from "@hackcove/ui";
250
+
251
+ const schemes = { light: "my-light", dark: "dark" }; // default: { light: "", dark: "dark" }
252
+
253
+ <html className={schemeClass(scheme, schemes)}>
254
+ <ColourSchemeToggle schemes={schemes} initial={scheme} onChange={(next) => saveCookie(next)} />
255
+ ```
256
+
257
+ Leave `colourSchemeScript` out here: it knows only the `dark` class and the stored choice, so it would fight the server. `schemes` is the class each scheme puts on `<html>`; a click swaps them. `initial` is the scheme the
258
+ server rendered: the server markup, the hydrating client and the `<html>` class (from
259
+ `schemeClass`) then agree, with no flash. Persist the choice from `onChange`; the toggle still
260
+ writes `localStorage`, which an app reading a cookie simply ignores. With neither prop nothing
261
+ changes: `dark` on and off, light on the server.
262
+
263
+ Without React, `applyColourScheme(scheme, root?, schemes?)`, `readColourScheme(fallback?)`,
264
+ `storeColourScheme(scheme)` and `schemeClass(scheme, schemes?)` are exported too; `ColourScheme` is
265
+ `"light" | "dark"` and `ColourSchemeClasses` is the `schemes` shape.
196
266
 
197
267
  ## Palette
198
268
 
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  /**
3
3
  * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
4
4
  * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
@@ -9,14 +9,29 @@ export declare const appShellRootClass = "overflow-y-scroll";
9
9
  * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
10
10
  * width or padding. `bleed` drops main's Container (the header keeps its own). Main is a flex
11
11
  * column, so a full-height child (`SidebarLayout`) is `flex-1 min-h-0` and needs no header height.
12
+ *
13
+ * The header row is laid out by its slots, left to right: `brand`, `nav`, then `header` (anything
14
+ * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
15
+ * `className` for any of them.
12
16
  */
13
- export declare function AppShell({ header, subnav, footer, bleed, children, }: {
14
- /** The header row: logo, nav, controls. Always one `h-14` row. */
15
- header: ReactNode;
17
+ 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. */
19
+ brand?: ReactNode;
20
+ /** The main navigation, e.g. `NavTabs` (it is already a `<nav>`). */
21
+ nav?: ReactNode;
22
+ /** The end of the row: a picker, a theme toggle. Gap-2, vertically centred. */
23
+ controls?: ReactNode;
24
+ /** The escape hatch: a free node in the header row, after `brand` and `nav`. */
25
+ header?: ReactNode;
16
26
  /** A second fixed-height row under the header, e.g. `NavTabs`. */
17
27
  subnav?: ReactNode;
18
28
  footer?: ReactNode;
19
29
  /** Main without a Container, for a full-bleed layout. */
20
30
  bleed?: boolean;
31
+ /**
32
+ * Print only the page: the header and footer are hidden, and main's Container drops its padding
33
+ * (the page margin is then `@page`'s). Off by default, so a print is unchanged unless an app asks.
34
+ */
35
+ hideChromeInPrint?: boolean;
21
36
  children: ReactNode;
22
37
  }): ReactElement;
package/dist/AppShell.js CHANGED
@@ -1,4 +1,5 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
2
3
  import { Container } from "./Container.js";
3
4
  import { cx } from "./cx.js";
4
5
  /**
@@ -8,12 +9,53 @@ import { cx } from "./cx.js";
8
9
  export const appShellRootClass = "overflow-y-scroll";
9
10
  // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
10
11
  const ROW = "flex items-center gap-4 overflow-x-auto whitespace-nowrap *:shrink-0";
12
+ // While a row has more to scroll to on a side, that edge fades out (a mask, so it follows any theme).
13
+ // `ScrollRow` sets `data-more-start` / `data-more-end` (and `data-more` for either); a row that fits
14
+ // has none and is untouched.
15
+ const FADE = "data-more-start:[--fade-start:2rem] data-more-end:[--fade-end:2rem] data-more:[mask-image:linear-gradient(to_right,transparent,#000_var(--fade-start,0px),#000_calc(100%_-_var(--fade-end,0px)),transparent)]";
16
+ /** A row in a Container, with the fade on whichever side still has content to scroll to. */
17
+ function ScrollRow({ className, children }) {
18
+ const row = useRef(null);
19
+ // Every render: the children may have changed, and each needs observing.
20
+ useEffect(() => {
21
+ const el = row.current;
22
+ if (!el)
23
+ return undefined;
24
+ const update = () => {
25
+ const start = el.scrollLeft > 1;
26
+ const end = el.scrollWidth - el.clientWidth - el.scrollLeft > 1;
27
+ el.toggleAttribute("data-more-start", start);
28
+ el.toggleAttribute("data-more-end", end);
29
+ el.toggleAttribute("data-more", start || end);
30
+ };
31
+ update();
32
+ el.addEventListener("scroll", update, { passive: true });
33
+ // jsdom (a consumer's own tests) has no ResizeObserver.
34
+ const resize = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(update);
35
+ resize?.observe(el);
36
+ for (const child of el.children)
37
+ resize?.observe(child);
38
+ return () => {
39
+ el.removeEventListener("scroll", update);
40
+ resize?.disconnect();
41
+ };
42
+ });
43
+ return (_jsx(Container, { elementRef: (el) => void (row.current = el), className: cx(ROW, FADE, className), children: children }));
44
+ }
45
+ /** The header row's slots in order: brand, nav, the free `children`, then controls at the end. */
46
+ 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 })] }));
48
+ }
11
49
  /**
12
50
  * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
13
51
  * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
14
52
  * width or padding. `bleed` drops main's Container (the header keeps its own). Main is a flex
15
53
  * column, so a full-height child (`SidebarLayout`) is `flex-1 min-h-0` and needs no header height.
54
+ *
55
+ * The header row is laid out by its slots, left to right: `brand`, `nav`, then `header` (anything
56
+ * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
57
+ * `className` for any of them.
16
58
  */
17
- export function AppShell({ header, subnav, footer, bleed = false, children, }) {
18
- return (_jsxs("div", { className: "flex min-h-dvh flex-col", children: [_jsxs("header", { className: "sticky top-0 z-10 border-b border-slate-200 bg-white", children: [_jsx(Container, { className: cx(ROW, "h-14"), children: header }), subnav && _jsx(Container, { className: cx(ROW, "h-12"), children: subnav })] }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? children : _jsx(Container, { className: "py-8", children: children }) }), footer && (_jsx("footer", { className: "border-t border-slate-200", children: _jsx(Container, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
59
+ export function AppShell({ brand, nav, controls, header, subnav, footer, bleed = false, hideChromeInPrint = false, children, }) {
60
+ return (_jsxs("div", { className: "flex min-h-dvh flex-col", children: [_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, children: header }) }), subnav && _jsx(ScrollRow, { className: "h-12", children: subnav })] }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(Container, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(Container, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
19
61
  }
package/dist/Avatar.d.ts CHANGED
@@ -3,6 +3,8 @@ declare const SIZE_CLASSES: {
3
3
  readonly sm: "size-6 text-xs";
4
4
  readonly md: "size-7 text-xs";
5
5
  readonly lg: "size-10 text-base";
6
+ readonly xl: "size-14 text-xl";
7
+ readonly "2xl": "size-24 text-3xl";
6
8
  };
7
9
  export type AvatarSize = keyof typeof SIZE_CLASSES;
8
10
  /** The background class for a name: the same name gets the same colour everywhere. */
@@ -10,13 +12,16 @@ export declare function avatarColourClass(name: string): string;
10
12
  /** Up to two letters or digits of a name, upper-cased; `?` when it has none. */
11
13
  export declare function initialsOf(name: string): string;
12
14
  /**
13
- * A person as a round badge of initials. It never loads a remote image: that is a request to
14
- * another server for decoration, and it makes a screenshot depend on the network.
15
+ * A person as a round badge of initials, or as their photo when `src` is given. It never loads a
16
+ * remote image on its own: that is a request to another server for decoration, and it makes a
17
+ * screenshot depend on the network. `src` is the app's own local path.
15
18
  */
16
- export declare function Avatar({ name, size, ring, className, ...rest }: {
19
+ export declare function Avatar({ name, src, size, ring, className, ...rest }: {
17
20
  name: string;
21
+ /** The person's photo, a local path. It replaces the initials and is announced as `name`. */
22
+ src?: string;
18
23
  size?: AvatarSize;
19
24
  /** A ring in the page colour, for a stack of overlapping avatars. */
20
25
  ring?: boolean;
21
- } & Omit<HTMLAttributes<HTMLSpanElement>, "children">): ReactElement;
26
+ } & Omit<HTMLAttributes<HTMLElement>, "children">): ReactElement;
22
27
  export {};
package/dist/Avatar.js CHANGED
@@ -14,6 +14,8 @@ const SIZE_CLASSES = {
14
14
  sm: "size-6 text-xs",
15
15
  md: "size-7 text-xs",
16
16
  lg: "size-10 text-base",
17
+ xl: "size-14 text-xl",
18
+ "2xl": "size-24 text-3xl",
17
19
  };
18
20
  /** The background class for a name: the same name gets the same colour everywhere. */
19
21
  export function avatarColourClass(name) {
@@ -30,9 +32,13 @@ export function initialsOf(name) {
30
32
  .toUpperCase() || "?");
31
33
  }
32
34
  /**
33
- * A person as a round badge of initials. It never loads a remote image: that is a request to
34
- * another server for decoration, and it makes a screenshot depend on the network.
35
+ * A person as a round badge of initials, or as their photo when `src` is given. It never loads a
36
+ * remote image on its own: that is a request to another server for decoration, and it makes a
37
+ * screenshot depend on the network. `src` is the app's own local path.
35
38
  */
36
- export function Avatar({ name, size = "md", ring = false, className, ...rest }) {
39
+ export function Avatar({ name, src, size = "md", ring = false, className, ...rest }) {
40
+ if (src) {
41
+ return (_jsx("img", { src: src, alt: name, title: name, className: cx("shrink-0 rounded-full object-cover", SIZE_CLASSES[size], ring && "-ml-1.5 border-2 border-slate-50 first:ml-0", className), ...rest }));
42
+ }
37
43
  return (_jsx("span", { "aria-hidden": "true", title: name, className: cx("inline-grid shrink-0 place-items-center rounded-full font-semibold text-violet-50", SIZE_CLASSES[size], avatarColourClass(name), ring && "-ml-1.5 border-2 border-slate-50 first:ml-0", className), ...rest, children: initialsOf(name) }));
38
44
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A small red count, 10px, for sitting on an icon. Position it from outside with `className`
3
+ * (`absolute -right-1 -top-1` over a `relative` parent); a count above 99 reads `99+`. Name the
4
+ * control it sits on (`aria-label` on the button) for a screen reader.
5
+ */
6
+ export declare function Badge({ count, className }: {
7
+ count: number;
8
+ className?: string;
9
+ }): import("react").JSX.Element;
package/dist/Badge.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * A small red count, 10px, for sitting on an icon. Position it from outside with `className`
5
+ * (`absolute -right-1 -top-1` over a `relative` parent); a count above 99 reads `99+`. Name the
6
+ * control it sits on (`aria-label` on the button) for a screen reader.
7
+ */
8
+ export function Badge({ count, className }) {
9
+ return (_jsx("span", { className: cx("inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold leading-none text-red-50", className), children: count > 99 ? "99+" : count }));
10
+ }
@@ -1,8 +1,27 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { type LinkRender } from "./link.js";
2
3
  /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
3
4
  export declare const crumbLinkClass: string;
4
- /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
5
+ /**
6
+ * A crumb that links: a plain `<a href>`, or with `render` the app's own router `<Link>`
7
+ * (`render: (p) => <Link to="/quotes" {...p} />`; `href` is then not used). `external` opens in a
8
+ * new tab. The last crumb is the current page and is never a link.
9
+ */
10
+ export type Crumb = {
11
+ label: ReactNode;
12
+ external?: boolean;
13
+ } & ({
14
+ href: string;
15
+ render?: LinkRender;
16
+ } | {
17
+ href?: string;
18
+ render: LinkRender;
19
+ });
20
+ /**
21
+ * `org / project / build / snapshot`: each crumb truncates, and the last is the current page. A
22
+ * crumb is a node, or a `Crumb` object when it links.
23
+ */
5
24
  export declare function Breadcrumbs({ crumbs, className, }: {
6
- crumbs: ReactNode[];
25
+ crumbs: Array<Crumb | ReactNode>;
7
26
  className?: string;
8
27
  }): ReactElement;
@@ -1,9 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
2
3
  import { cx, focusRing } from "./cx.js";
4
+ import { renderLink } from "./link.js";
3
5
  /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
4
6
  export const crumbLinkClass = cx("rounded text-slate-500 hover:underline", focusRing);
5
- /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
7
+ function isCrumb(crumb) {
8
+ return typeof crumb === "object" && crumb !== null && !isValidElement(crumb) && "label" in crumb;
9
+ }
10
+ function CrumbContent({ crumb, current, }) {
11
+ if (!isCrumb(crumb))
12
+ return crumb;
13
+ if (current)
14
+ return crumb.label;
15
+ return renderLink({
16
+ href: crumb.href,
17
+ render: crumb.render,
18
+ external: crumb.external,
19
+ className: crumbLinkClass,
20
+ children: crumb.label,
21
+ });
22
+ }
23
+ /**
24
+ * `org / project / build / snapshot`: each crumb truncates, and the last is the current page. A
25
+ * crumb is a node, or a `Crumb` object when it links.
26
+ */
6
27
  export function Breadcrumbs({ crumbs, className, }) {
7
28
  const last = crumbs.length - 1;
8
- return (_jsx("nav", { "aria-label": "Breadcrumb", className: cx("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 items-center gap-2 text-sm", children: crumbs.map((crumb, index) => (_jsxs("li", { "aria-current": index === last ? "page" : undefined, className: "flex min-w-0 items-center gap-2", children: [index > 0 && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400", children: "/" })), _jsx("span", { className: cx("min-w-0 truncate", index === last ? "font-medium text-slate-900" : "text-slate-500"), children: crumb })] }, index))) }) }));
29
+ return (_jsx("nav", { "aria-label": "Breadcrumb", className: cx("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 items-center gap-2 text-sm", children: crumbs.map((crumb, index) => (_jsxs("li", { "aria-current": index === last ? "page" : undefined, className: "flex min-w-0 items-center gap-2", children: [index > 0 && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400", children: "/" })), _jsx("span", { className: cx("min-w-0 truncate", index === last ? "font-medium text-slate-900" : "text-slate-500"), children: _jsx(CrumbContent, { crumb: crumb, current: index === last }) })] }, index))) }) }));
9
30
  }
@@ -0,0 +1,3 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ /** A bulleted `<ul>`: discs, indented, a small gap between items. Its children are `<li>`s (e.g. `Body as="li"`). */
3
+ export declare function BulletList({ className, ...rest }: HTMLAttributes<HTMLUListElement>): ReactElement;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** A bulleted `<ul>`: discs, indented, a small gap between items. Its children are `<li>`s (e.g. `Body as="li"`). */
4
+ export function BulletList({ className, ...rest }) {
5
+ return _jsx("ul", { className: cx("list-disc space-y-1 pl-5", className), ...rest });
6
+ }
package/dist/Button.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
- export type ButtonVariant = "primary" | "outline" | "ghost";
2
+ export type ButtonVariant = "primary" | "outline" | "ghost" | "link";
3
+ export type ButtonSize = "md" | "sm";
4
+ export type ButtonTone = "default" | "danger";
5
+ /** Size, tone and variant: the look of a button, apart from its state. */
6
+ export type ButtonLook = {
7
+ variant?: ButtonVariant | undefined;
8
+ /** `sm` is compact, for table rows and toolbars. */
9
+ size?: ButtonSize | undefined;
10
+ /** `danger` is red, for delete and clear. */
11
+ tone?: ButtonTone | undefined;
12
+ };
3
13
  /** The classes of a Button, for a router's own <Link> that should look like one. */
4
- export declare function buttonClass(variant?: ButtonVariant): string;
5
- type ButtonProps = {
6
- variant?: ButtonVariant;
14
+ export declare function buttonClass(variant?: ButtonVariant, { size, tone }?: Omit<ButtonLook, "variant">): string;
15
+ type ButtonProps = ButtonLook & {
7
16
  /** Why the button cannot be pressed: disables it, sets it as the `title`, and greys it flat. */
8
17
  disabledReason?: string | undefined;
9
18
  /** The work is in flight: disabled, `aria-busy`, keeps its colour. Wins over `disabledReason`. */
@@ -12,9 +21,10 @@ type ButtonProps = {
12
21
  busyLabel?: string | undefined;
13
22
  } & ComponentPropsWithoutRef<"button">;
14
23
  /**
15
- * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
16
- * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
17
- * half-opacity look.
24
+ * A button in the primary, outline, ghost or link style (the link has no box: text with an
25
+ * underline on hover, for an in-line action). `size="sm"` is compact; `tone="danger"` is red.
26
+ * Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
27
+ * more than a plain `disabled`, which keeps its half-opacity look.
18
28
  */
19
- export declare function Button({ variant, disabledReason, busy, busyLabel, className, children, title, disabled, ...rest }: ButtonProps): ReactElement;
29
+ export declare function Button({ variant, size, tone, disabledReason, busy, busyLabel, className, children, title, disabled, ...rest }: ButtonProps): ReactElement;
20
30
  export {};
package/dist/Button.js CHANGED
@@ -1,39 +1,71 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
- const SHAPE = "inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition";
3
+ const SHAPE = "inline-flex items-center justify-center rounded-lg border font-medium transition";
4
+ // Size sets the padding and the text. The link variant has no box, so it takes the text size only.
5
+ const SIZE = {
6
+ md: { box: "gap-2 px-4 py-2", text: "text-sm" },
7
+ sm: { box: "gap-1.5 px-2.5 py-1", text: "text-xs" },
8
+ };
4
9
  // A plain `disabled` washes out and ignores the pointer. Blocked and busy buttons are disabled too,
5
10
  // but they keep pointer events (the title shows, the cursor says why) and do not take this.
6
11
  const DISABLED_PLAIN = "disabled:pointer-events-none disabled:opacity-50";
7
- const VARIANT = {
8
- primary: {
9
- color: "border-violet-600 bg-violet-600 text-violet-50",
10
- hover: "hover:bg-violet-700",
12
+ const LINK = "underline-offset-2";
13
+ const TONE = {
14
+ default: {
15
+ primary: {
16
+ color: "border-violet-600 bg-violet-600 text-violet-50",
17
+ hover: "hover:bg-violet-700",
18
+ },
19
+ outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
20
+ ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
21
+ link: {
22
+ color: `border-transparent text-violet-600 ${LINK}`,
23
+ hover: "hover:text-violet-700 hover:underline",
24
+ },
25
+ },
26
+ danger: {
27
+ primary: { color: "border-red-600 bg-red-600 text-red-50", hover: "hover:bg-red-700" },
28
+ outline: { color: "border-red-200 text-red-700", hover: "hover:bg-red-50" },
29
+ ghost: { color: "border-transparent text-red-700", hover: "hover:bg-red-50" },
30
+ link: { color: `border-transparent text-red-700 ${LINK}`, hover: "hover:underline" },
11
31
  },
12
- outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
13
- ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
14
32
  };
15
33
  // Blocked replaces the variant's colour and hover rather than overriding them, so no `!` is needed.
16
34
  const BLOCKED = "cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400";
35
+ const BLOCKED_LINK = "cursor-not-allowed border-transparent text-slate-400";
17
36
  const BUSY = "cursor-progress opacity-85";
37
+ function shape(variant, size) {
38
+ const { box, text } = SIZE[size];
39
+ return cx(SHAPE, variant !== "link" && box, variant === "link" && "gap-1.5", text, focusRing);
40
+ }
18
41
  /** The classes of a Button, for a router's own <Link> that should look like one. */
19
- export function buttonClass(variant = "primary") {
20
- return cx(SHAPE, DISABLED_PLAIN, focusRing, VARIANT[variant].color, VARIANT[variant].hover);
42
+ export function buttonClass(variant = "primary", { size = "md", tone = "default" } = {}) {
43
+ const look = TONE[tone][variant];
44
+ return cx(shape(variant, size), DISABLED_PLAIN, look.color, look.hover);
21
45
  }
22
- /** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
23
- function stateClass(variant, blocked, busy) {
24
- const { color } = VARIANT[variant];
25
- if (blocked)
26
- return cx(SHAPE, focusRing, BLOCKED);
46
+ /** Busy wins over blocked. */
47
+ function stateOf(busy, disabledReason) {
27
48
  if (busy)
28
- return cx(SHAPE, focusRing, color, BUSY);
29
- return buttonClass(variant);
49
+ return "busy";
50
+ return disabledReason === undefined ? "idle" : "blocked";
51
+ }
52
+ /** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
53
+ function stateClass({ variant = "primary", size = "md", tone = "default" }, state) {
54
+ if (state === "blocked") {
55
+ return cx(shape(variant, size), variant === "link" ? BLOCKED_LINK : BLOCKED);
56
+ }
57
+ if (state === "busy")
58
+ return cx(shape(variant, size), TONE[tone][variant].color, BUSY);
59
+ return buttonClass(variant, { size, tone });
30
60
  }
31
61
  /**
32
- * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
33
- * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
34
- * half-opacity look.
62
+ * A button in the primary, outline, ghost or link style (the link has no box: text with an
63
+ * underline on hover, for an in-line action). `size="sm"` is compact; `tone="danger"` is red.
64
+ * Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
65
+ * more than a plain `disabled`, which keeps its half-opacity look.
35
66
  */
36
- export function Button({ variant = "primary", disabledReason, busy = false, busyLabel, className, children, title, disabled, ...rest }) {
37
- const blocked = disabledReason !== undefined && !busy;
38
- return (_jsx("button", { type: "button", className: cx(stateClass(variant, blocked, busy), className), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
67
+ export function Button({ variant, size, tone, disabledReason, busy = false, busyLabel, className, children, title, disabled, ...rest }) {
68
+ const state = stateOf(busy, disabledReason);
69
+ const blocked = state === "blocked";
70
+ return (_jsx("button", { type: "button", className: cx(stateClass({ variant, size, tone }, state), className), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
39
71
  }
@@ -0,0 +1,14 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /**
3
+ * A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
4
+ * narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
5
+ * named `role="group"`.
6
+ */
7
+ export declare function ButtonGroup({ children, end, label, className, }: {
8
+ children: ReactNode;
9
+ /** One item, pushed to the end of the row. */
10
+ end?: ReactNode;
11
+ /** The accessible name of the group: sets `role="group"` and `aria-label`. */
12
+ label?: string;
13
+ className?: string;
14
+ }): ReactElement;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
5
+ * narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
6
+ * named `role="group"`.
7
+ */
8
+ export function ButtonGroup({ children, end, label, className, }) {
9
+ return (_jsxs("div", { ...(label === undefined ? {} : { role: "group", "aria-label": label }), className: cx("flex flex-wrap items-center gap-2", className), children: [children, end ? _jsx("div", { className: "ml-auto", children: end }) : null] }));
10
+ }