@hackcove/ui 0.2.2 → 0.2.4

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 (47) hide show
  1. package/README.md +183 -4
  2. package/dist/AppShell.d.ts +37 -0
  3. package/dist/AppShell.js +61 -0
  4. package/dist/Avatar.d.ts +9 -4
  5. package/dist/Avatar.js +9 -3
  6. package/dist/BulletList.d.ts +3 -0
  7. package/dist/BulletList.js +6 -0
  8. package/dist/Code.d.ts +3 -0
  9. package/dist/Code.js +6 -0
  10. package/dist/ColourSchemeToggle.d.ts +7 -0
  11. package/dist/ColourSchemeToggle.js +25 -0
  12. package/dist/Container.d.ts +12 -0
  13. package/dist/Container.js +9 -0
  14. package/dist/DateCalendar.d.ts +4 -0
  15. package/dist/DateCalendar.js +40 -0
  16. package/dist/DateField.d.ts +49 -0
  17. package/dist/DateField.js +77 -0
  18. package/dist/DatePicker.d.ts +8 -0
  19. package/dist/DatePicker.js +27 -0
  20. package/dist/Divider.d.ts +3 -0
  21. package/dist/Divider.js +6 -0
  22. package/dist/InlineLinks.d.ts +11 -0
  23. package/dist/InlineLinks.js +11 -0
  24. package/dist/Markdown.js +1 -1
  25. package/dist/PageHeader.d.ts +19 -0
  26. package/dist/PageHeader.js +11 -0
  27. package/dist/PageLayouts.d.ts +73 -0
  28. package/dist/PageLayouts.js +63 -0
  29. package/dist/Stack.d.ts +30 -0
  30. package/dist/Stack.js +28 -0
  31. package/dist/TextLink.d.ts +12 -0
  32. package/dist/TextLink.js +8 -0
  33. package/dist/Video.d.ts +10 -0
  34. package/dist/Video.js +10 -0
  35. package/dist/colourScheme.d.ts +12 -0
  36. package/dist/colourScheme.js +40 -0
  37. package/dist/index.d.ts +18 -1
  38. package/dist/index.js +16 -0
  39. package/dist/isoDate.d.ts +5 -0
  40. package/dist/isoDate.js +16 -0
  41. package/dist/shownDate.d.ts +45 -0
  42. package/dist/shownDate.js +88 -0
  43. package/dist/typography.d.ts +32 -24
  44. package/dist/typography.js +39 -9
  45. package/dist/ui.d.ts +1 -0
  46. package/dist/ui.js +3 -1
  47. package/package.json +24 -1
package/README.md CHANGED
@@ -10,9 +10,12 @@ Status: `0.x`, published to npm. License: MIT.
10
10
 
11
11
  ```sh
12
12
  bun add @hackcove/ui
13
- # peers: react >= 18, tailwindcss ^4
13
+ # peers: react >= 18, react-dom >= 18, tailwindcss ^4
14
14
  ```
15
15
 
16
+ One runtime dependency comes with it: `react-aria-components` (and `@internationalized/date`),
17
+ for `DatePicker` and `DateField`. It brings no CSS; the classes are ours.
18
+
16
19
  Tailwind skips `node_modules`, so tell it to scan the build for the utilities the components use,
17
20
  in the stylesheet that imports Tailwind (the path is relative to that file):
18
21
 
@@ -36,11 +39,187 @@ export const Example = () => (
36
39
 
37
40
  ## What is exported
38
41
 
39
- - Primitives: `Card`, `EpicHeader`, `Empty`, `PageTitle`, `Pill`, `groupByEpic`, `Markdown`.
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`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
40
44
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
41
45
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `ModalHeading`, `Subtitle`, `Overline`,
42
- `Body`, `BodyStrong`, `Caption`.
43
- - 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`), `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).
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`, `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`; 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, `external` adds `target="_blank"` and `rel="noreferrer"`, `className` and every other anchor prop pass through). `linkClass` (the classes of one, for a router's own `<Link>`). `InlineLinks` (a wrapping row of small links joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; `className` goes on the row). `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
+ - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
51
+
52
+ ## Dates
53
+
54
+ ```tsx
55
+ import { DatePicker, fromISODate, toISODate, type CalendarDate } from "@hackcove/ui";
56
+
57
+ <DatePicker
58
+ label="Date of birth"
59
+ description="DD/MM/YYYY"
60
+ defaultValue={fromISODate("1990-05-17")}
61
+ maxValue={fromISODate("2026-10-23")}
62
+ onChange={(date: CalendarDate | null) => save(toISODate(date))}
63
+ name="dob"
64
+ isRequired
65
+ />;
66
+ ```
67
+
68
+ - `DatePicker` is a segmented DD/MM/YYYY field (always en-GB, whatever the browser's locale) with a
69
+ calendar button. The popover has ‹ › month steps, month and year dropdowns, Monday-first weeks
70
+ and a Clear action. `defaultOpen` opens it on mount.
71
+ - `DateField` is the same field without the calendar.
72
+ - Both take `label`, `description`, `errorMessage`, `value` / `defaultValue` / `onChange`,
73
+ `minValue`, `maxValue`, `isDisabled`, `isRequired` and `name` (the form posts `YYYY-MM-DD`).
74
+ Values are `CalendarDate`s (from `@internationalized/date`, re-exported as a type).
75
+ - `toISODate(date)` gives `"1990-05-17"` (`""` for `null`); `fromISODate(text)` gives a
76
+ `CalendarDate`, or `null` for a malformed or impossible date such as `"1990-02-31"`.
77
+ - `onChange` reports what the field shows: the date once it is complete, exists and is within
78
+ `minValue` / `maxValue`. When a valid date is edited into one that is not, it reports `null` once
79
+ and `name` posts `""`, so a controlled parent holds `null` until the date is fixed (holding it does
80
+ not clear what was typed). Fixing it reports the date again. A `value` the parent sets is shown,
81
+ not reported back.
82
+ - With `isRequired`, a partly filled date blocks the form and shows "Enter the whole date." once
83
+ the field loses focus.
84
+ - A date before `minValue` or after `maxValue` stays as typed (it is never clamped) and shows the
85
+ error once the field loses focus.
86
+ - An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
87
+ typed, focus leaving included, until it is fixed. `errorMessage` replaces either message.
88
+
89
+ ## Layout
90
+
91
+ One content edge for every page, so nothing moves sideways between pages or viewport sizes.
92
+
93
+ ```tsx
94
+ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackcove/ui";
95
+
96
+ // Your root layout. `appShellRootClass` is `overflow-y-scroll`: it reserves the scrollbar, so a
97
+ // short page and a long one centre their content at the same x. AppShell cannot set it itself.
98
+ <html className={appShellRootClass}>
99
+ <body>
100
+ <AppShell brand={<Logo />} nav={<NavTabs label="Main" items={nav} />} footer="…">
101
+ <PageHeader title="Builds" subtitle="Every build" actions={<Button>New</Button>} />
102
+ <Stack>
103
+ <Section title="Recent">…</Section>
104
+ </Stack>
105
+ </AppShell>
106
+ </body>
107
+ </html>;
108
+ ```
109
+
110
+ - **`AppShell`** is the page frame: a sticky header that is always one `h-14` row (a nav that does
111
+ not fit scrolls sideways, it never wraps, and the edge with more to scroll to fades out until it
112
+ is reached), an optional `subnav` second row (same rule), `main`
113
+ with fixed `py-8`, and an optional `footer`. The header row is filled by slots, left to right:
114
+ `brand` (logo and name, at the brand type with the logo gap), `nav` (a `NavTabs`), `header` (a
115
+ free node, the escape hatch) and `controls` (end-aligned, `gap-2`, centred): no wrapper, no
116
+ `className`. `hideChromeInPrint` is opt-in: the header and footer are hidden in print and main
117
+ 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
118
+ page; the header keeps its own, so put a `Container` back around anything that should line up.
119
+ - **`Container`** is the only place the content width (`max-w-6xl`) and side padding
120
+ (`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
121
+ no `mx-auto`, no `px-*` on a page. AppShell already gives them the edge.
122
+ - **`PageHeader`** is the top of every page: optional `breadcrumbs`, the `title` (a `PageHeading`),
123
+ `subtitle`, `actions` (right of the title from `sm`, under it on a phone) and `tabs` under it. Its
124
+ fixed bottom margin puts every page's first block at the same y.
125
+ - **`Stack`** spaces blocks in a column: `gap="xs"` (`gap-2`) between lines of one text block (a
126
+ heading, the line under it), `sm` (`gap-4`) inside a group, `md` (`gap-6`, the default) between
127
+ blocks, `lg` (`gap-8`) between groups. Use it instead of `space-y-*`. `width` caps the column:
128
+ `narrow` (`max-w-3xl`, a reading measure), `wide` (`max-w-5xl`), `full` (`w-full`) or `default`
129
+ (nothing). A capped Stack starts at the Container's left edge and is never re-centred.
130
+ **`Section`** is a `<section>` Stack (`sm` by default) with an optional `SectionHeading` `title`,
131
+ `actions` beside it and the same `width`.
132
+
133
+ ### Page layouts
134
+
135
+ Small compositions of the above: they take slots, own no data, and set no page width or outer
136
+ padding. Each takes the `PageHeader` props (`title`, `subtitle`, `actions`, `breadcrumbs`, `tabs`)
137
+ except the last four.
138
+
139
+ - **`ListLayout`**: use this when a page is a filterable list. `filters` sits under the header;
140
+ `children` is the list, replaced by `empty` when `isEmpty`.
141
+ - **`DetailLayout`**: use this when a page is one thing with facts beside it. `children` take two
142
+ thirds from `lg` and `aside` one third; below `lg` the aside stacks under the main column.
143
+ - **`FormLayout`**: use this for a create or edit page. A `max-w-3xl` `<form>` that starts at the
144
+ Container's left edge (it is not re-centred, so the title does not jump), with an `actions` row at
145
+ the end. Other props (`onSubmit`, `action`) go on the `<form>`.
146
+ - **`ProseLayout`**: use this for a text page (privacy, terms, a CV, an about page). One `narrow`
147
+ (`max-w-3xl`) column that starts at the Container's left edge (not re-centred, so the title does
148
+ not jump): the header, then `children` in a `Stack` (`gap` prop, `md` by default). Unlike
149
+ `FormLayout` the header sits inside the column, so its `actions` stay beside the text.
150
+ `className` goes on the column.
151
+ - **`CardGrid`**: use this for stat tiles or a gallery of cards. `columns` is `2`, `3` (default) or
152
+ `4`: one column on a phone, two from `sm`, `columns` from `lg`.
153
+ - **`SidebarLayout`**: use this when a `rail` of navigation sits beside the content. Render it in
154
+ `<AppShell bleed>`: it fills the height under the header with no header height written down
155
+ (`AppShell`'s main is a flex column). The rail is `md:w-60` and stacks above the canvas below `md`;
156
+ the canvas keeps the Container's padding.
157
+ - **`FullBleedLayout`**: use this for a viewer that needs the whole width. Render it in
158
+ `<AppShell bleed>`; `children` fill the width, and an optional `panel` takes `lg:w-72` on the
159
+ right and stacks below under `lg`.
160
+
161
+ **`SidebarLayout` and `FullBleedLayout` deliberately break the shared left edge from `md` and
162
+ `lg` up.** A full-height rail and an edge-to-edge viewer need the viewport's edges, so they cannot
163
+ sit inside the Container the header uses. At 1280 px the header logo and every other layout start
164
+ at x=88, `SidebarLayout`'s rail at x=0 and its canvas at x=264 (rail 240 + the Container's 24
165
+ padding), `FullBleedLayout`'s `children` at x=0 (they add their own padding). Below `md` (`lg` for
166
+ `FullBleedLayout`) everything stacks and all layouts agree at the Container's padding. To avoid
167
+ the shift between pages, give a whole section of an app the same layout (every page under a
168
+ project on `SidebarLayout`) rather than mixing it with `ListLayout` pages that a user moves
169
+ between; the header, which stays inside the Container, is the constant. The title's y also
170
+ differs from `ListLayout` only when one page passes `breadcrumbs` and the other does not:
171
+ `PageHeader` puts them above the title in every layout.
172
+ - **`CenteredLayout`**: use this for sign-in, landing, not-found and error pages. A `max-w-md` card
173
+ centred at a fixed offset from the top; the one layout that does not share the left edge.
174
+
175
+ ```tsx
176
+ <AppShell header={header}>
177
+ <ListLayout title="Quotes" actions={<Button>New</Button>} filters={<FilterGroup …/>}
178
+ isEmpty={quotes.length === 0} empty={<Empty>No quotes yet.</Empty>}>
179
+ {quotes.map(…)}
180
+ </ListLayout>
181
+ </AppShell>
182
+ ```
183
+
184
+ ## Light and dark
185
+
186
+ A dark theme does its colour work under `.dark`; this only decides whether `.dark` is on `<html>`.
187
+ Two states, light and dark. The stored choice wins; with nothing stored the system's
188
+ `prefers-color-scheme` decides. Which dark theme is loaded stays your CSS import.
189
+
190
+ 1. **Inline `colourSchemeScript` in `<head>`**, before the stylesheet, so the class is right at
191
+ first paint and a light user gets no dark flash. It is a few hundred bytes of plain JavaScript:
192
+
193
+ ```tsx
194
+ import { colourSchemeScript } from "@hackcove/ui";
195
+
196
+ <head>
197
+ <script dangerouslySetInnerHTML={{ __html: colourSchemeScript }} />
198
+ </head>;
199
+ ```
200
+
201
+ In Astro, use `<script is:inline set:html={colourSchemeScript} />`.
202
+
203
+ 2. **Drop the hard-coded `class="dark"`** from `<html>`. The script sets and removes it (and
204
+ `color-scheme`, so native controls and scrollbars follow). For a server-rendered `<html>`, add
205
+ `suppressHydrationWarning` (React) because the script changes its `class` before React hydrates.
206
+
207
+ 3. **Place the toggle** wherever the header wants it:
208
+
209
+ ```tsx
210
+ import { ColourSchemeToggle } from "@hackcove/ui";
211
+
212
+ <ColourSchemeToggle className="ml-auto" onChange={(scheme) => track(scheme)} />;
213
+ ```
214
+
215
+ It is an `IconButton` showing the scheme you would switch *to* (a sun on a dark page, a moon on a
216
+ light one), labelled "Switch to light theme" / "Switch to dark theme". A click applies the scheme
217
+ to `<html>` and stores it under `localStorage["hackcove:colour-scheme"]`; a storage error (private
218
+ mode) is swallowed and the page still switches. It reads the current state from the `dark` class
219
+ when it mounts, so it agrees with the script and renders the same on the server and the client.
220
+
221
+ Without React, `applyColourScheme(scheme, root?)`, `readColourScheme(fallback?)` and
222
+ `storeColourScheme(scheme)` are exported too; `ColourScheme` is `"light" | "dark"`.
44
223
 
45
224
  ## Palette
46
225
 
@@ -0,0 +1,37 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ /**
3
+ * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
4
+ * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
5
+ */
6
+ export declare const appShellRootClass = "overflow-y-scroll";
7
+ /**
8
+ * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
9
+ * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
10
+ * width or padding. `bleed` drops main's Container (the header keeps its own). Main is a flex
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.
16
+ */
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;
26
+ /** A second fixed-height row under the header, e.g. `NavTabs`. */
27
+ subnav?: ReactNode;
28
+ footer?: ReactNode;
29
+ /** Main without a Container, for a full-bleed layout. */
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;
36
+ children: ReactNode;
37
+ }): ReactElement;
@@ -0,0 +1,61 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { Container } from "./Container.js";
4
+ import { cx } from "./cx.js";
5
+ /**
6
+ * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
7
+ * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
8
+ */
9
+ export const appShellRootClass = "overflow-y-scroll";
10
+ // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
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
+ }
49
+ /**
50
+ * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
51
+ * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
52
+ * width or padding. `bleed` drops main's Container (the header keeps its own). Main is a flex
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.
58
+ */
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 }) }))] }));
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,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/Code.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ /** Inline code: mono, on a subtle inset background. It takes the surrounding text colour and size (a touch smaller, for the mono face). */
3
+ export declare function Code({ className, ...rest }: HTMLAttributes<HTMLElement>): ReactElement;
package/dist/Code.js ADDED
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** Inline code: mono, on a subtle inset background. It takes the surrounding text colour and size (a touch smaller, for the mono face). */
4
+ export function Code({ className, ...rest }) {
5
+ return (_jsx("code", { className: cx("rounded bg-slate-100 px-1 py-0.5 font-mono text-[0.9em]", className), ...rest }));
6
+ }
@@ -0,0 +1,7 @@
1
+ import { type ReactElement } from "react";
2
+ import { type ColourScheme } from "./colourScheme.js";
3
+ /** 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>`. */
4
+ export declare function ColourSchemeToggle({ className, onChange, }: {
5
+ className?: string;
6
+ onChange?: (scheme: ColourScheme) => void;
7
+ }): ReactElement;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { applyColourScheme, storeColourScheme } from "./colourScheme.js";
4
+ import { IconButton } from "./IconButton.js";
5
+ const sun = (_jsxs("svg", { viewBox: "0 0 24 24", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "4" }), _jsx("path", { d: "M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" })] }));
6
+ const moon = (_jsx("svg", { viewBox: "0 0 24 24", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" }) }));
7
+ // The `dark` class on <html> is the source of truth: the head script set it before React ran.
8
+ function subscribe(onChange) {
9
+ const observer = new MutationObserver(onChange);
10
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
11
+ return () => observer.disconnect();
12
+ }
13
+ const current = () => document.documentElement.classList.contains("dark") ? "dark" : "light";
14
+ // What the server and the hydrating client both render; the real state follows right after.
15
+ const server = () => "light";
16
+ /** 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>`. */
17
+ export function ColourSchemeToggle({ className, onChange, }) {
18
+ const scheme = useSyncExternalStore(subscribe, current, server);
19
+ const next = scheme === "dark" ? "light" : "dark";
20
+ return (_jsx(IconButton, { label: `Switch to ${next} theme`, className: className, onClick: () => {
21
+ applyColourScheme(next);
22
+ storeColourScheme(next);
23
+ onChange?.(next);
24
+ }, children: next === "light" ? sun : moon }));
25
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /**
3
+ * The one content edge: every page's width and side padding. This file is the only place they are
4
+ * defined, so a header, a page and a footer inside a Container start at the same x.
5
+ */
6
+ export declare function Container({ as: Tag, className, elementRef, children, }: {
7
+ as?: "div" | "main" | "header" | "footer" | "section" | "nav";
8
+ className?: string;
9
+ /** The rendered element, for a parent that measures or scrolls it. */
10
+ elementRef?: (element: HTMLElement | null) => void;
11
+ children?: ReactNode;
12
+ }): ReactElement;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * The one content edge: every page's width and side padding. This file is the only place they are
5
+ * defined, so a header, a page and a footer inside a Container start at the same x.
6
+ */
7
+ export function Container({ as: Tag = "div", className, elementRef, children, }) {
8
+ return (_jsx(Tag, { ref: elementRef, className: cx("mx-auto w-full max-w-6xl px-4 sm:px-6", className), children: children }));
9
+ }
@@ -0,0 +1,4 @@
1
+ import { type ReactElement } from "react";
2
+ export declare const calendarIcon: import("react").JSX.Element;
3
+ /** The picker's popover: header, a Monday-first month grid and a Clear footer. */
4
+ export declare function CalendarPopover(): ReactElement;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { getLocalTimeZone, today } from "@internationalized/date";
3
+ import { useContext } from "react";
4
+ import { Button, Calendar, CalendarCell, CalendarGrid, CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, CalendarStateContext, DatePickerStateContext, Dialog, Popover, } from "react-aria-components";
5
+ import { cx, focusRing } from "./cx.js";
6
+ import { MONTH_OPTIONS, iconButtonClass } from "./DateField.js";
7
+ export const calendarIcon = (_jsxs("svg", { viewBox: "0 0 20 20", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "12.5", rx: "2" }), _jsx("path", { d: "M3 8.5h14M7 2.5v4M13 2.5v4" })] }));
8
+ const chevron = (d) => (_jsx("svg", { viewBox: "0 0 20 20", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: d }) }));
9
+ const selectClass = `rounded-md border border-slate-300 bg-white py-1 pr-1 pl-2 text-sm text-slate-900 transition hover:border-violet-400 ${focusRing}`;
10
+ /** ‹, a month dropdown, a year dropdown and ›: a birth date decades back is two picks away. */
11
+ function CalendarHeader() {
12
+ const state = useContext(CalendarStateContext);
13
+ if (!state)
14
+ return null;
15
+ const focused = state.focusedDate;
16
+ const now = today(getLocalTimeZone()).year;
17
+ const first = Math.min(state.minValue?.year ?? now - 100, focused.year);
18
+ const last = Math.max(state.maxValue?.year ?? now + 10, focused.year);
19
+ const years = Array.from({ length: last - first + 1 }, (_, i) => first + i);
20
+ return (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Button, { slot: "previous", className: iconButtonClass, children: chevron("M12.5 15l-5-5 5-5") }), _jsx("select", { "aria-label": "Month", className: cx(selectClass, "flex-1"), value: focused.month, disabled: state.isDisabled, onChange: (event) => state.setFocusedDate(focused.set({ month: Number(event.target.value) })), children: MONTH_OPTIONS.map(({ month, name }) => (_jsx("option", { value: month, children: name }, month))) }), _jsx("select", { "aria-label": "Year", className: selectClass, value: focused.year, disabled: state.isDisabled, onChange: (event) => state.setFocusedDate(focused.set({ year: Number(event.target.value) })), children: years.map((year) => (_jsx("option", { value: year, children: year }, year))) }), _jsx(Button, { slot: "next", className: iconButtonClass, children: chevron("M7.5 5l5 5-5 5") })] }));
21
+ }
22
+ function ClearButton() {
23
+ const picker = useContext(DatePickerStateContext);
24
+ return (_jsx("button", { type: "button", className: `rounded-md px-2 py-1 text-sm font-medium text-violet-600 transition hover:bg-violet-50 ${focusRing}`, onClick: () => {
25
+ picker?.setValue(null);
26
+ picker?.close();
27
+ }, children: "Clear" }));
28
+ }
29
+ const cellClass = ({ isSelected, isOutsideMonth, isDisabled, isUnavailable, isInvalid, }) => {
30
+ let tone = "cursor-pointer text-slate-700 hover:bg-slate-100";
31
+ if (isSelected)
32
+ tone = isInvalid ? "bg-red-50 text-red-700" : "bg-violet-600 text-violet-50";
33
+ else if (isOutsideMonth || isDisabled || isUnavailable)
34
+ tone = "text-slate-300";
35
+ return cx("flex h-9 w-9 items-center justify-center rounded-lg text-sm tabular-nums", tone, focusRing);
36
+ };
37
+ /** The picker's popover: header, a Monday-first month grid and a Clear footer. */
38
+ export function CalendarPopover() {
39
+ return (_jsx(Popover, { placement: "bottom start", className: "rounded-xl border border-slate-200 bg-white p-3 text-slate-900 shadow-lg", children: _jsxs(Dialog, { className: "flex flex-col gap-3 outline-none", children: [_jsxs(Calendar, { firstDayOfWeek: "mon", className: "flex flex-col gap-3", children: [_jsx(CalendarHeader, {}), _jsxs(CalendarGrid, { weekdayStyle: "short", children: [_jsx(CalendarGridHeader, { children: (day) => (_jsx(CalendarHeaderCell, { className: "pb-1 text-xs font-medium text-slate-500", children: day })) }), _jsx(CalendarGridBody, { children: (date) => _jsx(CalendarCell, { date: date, className: cellClass }) })] })] }), _jsx("div", { className: "flex justify-end border-t border-slate-200 pt-2", children: _jsx(ClearButton, {}) })] }) }));
40
+ }
@@ -0,0 +1,49 @@
1
+ import { type CalendarDate } from "@internationalized/date";
2
+ import { type ContextType, type ReactElement, type ReactNode } from "react";
3
+ import { type DateFieldProps as AriaDateFieldProps, DateFieldStateContext } from "react-aria-components";
4
+ /** Fixed, whatever the browser's locale: DD/MM/YYYY and Monday-first weeks. */
5
+ export declare const LOCALE = "en-GB";
6
+ export declare const MONTH_OPTIONS: {
7
+ month: number;
8
+ name: string;
9
+ }[];
10
+ type Segment = NonNullable<ContextType<typeof DateFieldStateContext>>["segments"][number];
11
+ export type DateFieldProps = {
12
+ label?: ReactNode;
13
+ /** A hint under the field; an error replaces it. */
14
+ description?: ReactNode;
15
+ /** Replaces the built-in error message (an impossible date, or one outside min/max). */
16
+ errorMessage?: ReactNode;
17
+ className?: string;
18
+ } & Pick<AriaDateFieldProps<CalendarDate>, "value" | "defaultValue" | "onChange" | "minValue" | "maxValue" | "isDisabled" | "isRequired" | "name" | "aria-label">;
19
+ /** What the segments show, as the day segment reports it to the field. */
20
+ export type Read = {
21
+ /** The date as `YYYY-MM-DD` once every segment is filled and the day exists, else `""`. */
22
+ typed: string;
23
+ /** "February 1990 has 28 days." when the day does not exist. */
24
+ impossible: string | null;
25
+ /** Some segments are filled and some are not. */
26
+ partial: boolean;
27
+ };
28
+ /** One segment. The day segment also reports what the segments show to the field. */
29
+ export declare function FieldSegment({ segment, onRead, }: {
30
+ segment: Segment;
31
+ onRead?: (read: Read) => void;
32
+ }): ReactElement;
33
+ export declare function frameClass({ isInvalid, isFocusWithin, isDisabled, }: {
34
+ isInvalid: boolean;
35
+ isFocusWithin: boolean;
36
+ isDisabled: boolean;
37
+ }): string;
38
+ /** The label, the description or the error line: the shared outside of both components. */
39
+ export declare function Notes({ description, errorMessage, impossible, isInvalid, }: {
40
+ description: ReactNode;
41
+ errorMessage: ReactNode;
42
+ impossible: string | null;
43
+ isInvalid: boolean;
44
+ }): ReactElement;
45
+ export declare const labelClass = "text-sm font-medium text-slate-700";
46
+ export declare const iconButtonClass = "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-100 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600";
47
+ /** A segmented DD / MM / YYYY date input. Values are `CalendarDate`s; `name` posts it as `YYYY-MM-DD`. */
48
+ export declare function DateField({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }: DateFieldProps): ReactElement;
49
+ export {};