@hackcove/ui 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,160 @@ 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`, `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
46
  `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). `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).
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).
48
+ - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
49
+
50
+ ## Dates
51
+
52
+ ```tsx
53
+ import { DatePicker, fromISODate, toISODate, type CalendarDate } from "@hackcove/ui";
54
+
55
+ <DatePicker
56
+ label="Date of birth"
57
+ description="DD/MM/YYYY"
58
+ defaultValue={fromISODate("1990-05-17")}
59
+ maxValue={fromISODate("2026-10-23")}
60
+ onChange={(date: CalendarDate | null) => save(toISODate(date))}
61
+ name="dob"
62
+ isRequired
63
+ />;
64
+ ```
65
+
66
+ - `DatePicker` is a segmented DD/MM/YYYY field (always en-GB, whatever the browser's locale) with a
67
+ calendar button. The popover has ‹ › month steps, month and year dropdowns, Monday-first weeks
68
+ and a Clear action. `defaultOpen` opens it on mount.
69
+ - `DateField` is the same field without the calendar.
70
+ - Both take `label`, `description`, `errorMessage`, `value` / `defaultValue` / `onChange`,
71
+ `minValue`, `maxValue`, `isDisabled`, `isRequired` and `name` (the form posts `YYYY-MM-DD`).
72
+ Values are `CalendarDate`s (from `@internationalized/date`, re-exported as a type).
73
+ - `toISODate(date)` gives `"1990-05-17"` (`""` for `null`); `fromISODate(text)` gives a
74
+ `CalendarDate`, or `null` for a malformed or impossible date such as `"1990-02-31"`.
75
+ - `onChange` reports what the field shows: the date once it is complete, exists and is within
76
+ `minValue` / `maxValue`. When a valid date is edited into one that is not, it reports `null` once
77
+ and `name` posts `""`, so a controlled parent holds `null` until the date is fixed (holding it does
78
+ not clear what was typed). Fixing it reports the date again. A `value` the parent sets is shown,
79
+ not reported back.
80
+ - With `isRequired`, a partly filled date blocks the form and shows "Enter the whole date." once
81
+ the field loses focus.
82
+ - A date before `minValue` or after `maxValue` stays as typed (it is never clamped) and shows the
83
+ error once the field loses focus.
84
+ - An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
85
+ typed, focus leaving included, until it is fixed. `errorMessage` replaces either message.
86
+
87
+ ## Layout
88
+
89
+ One content edge for every page, so nothing moves sideways between pages or viewport sizes.
90
+
91
+ ```tsx
92
+ import { AppShell, PageHeader, Section, Stack, appShellRootClass } from "@hackcove/ui";
93
+
94
+ // Your root layout. `appShellRootClass` is `overflow-y-scroll`: it reserves the scrollbar, so a
95
+ // short page and a long one centre their content at the same x. AppShell cannot set it itself.
96
+ <html className={appShellRootClass}>
97
+ <body>
98
+ <AppShell header={<><Logo /><NavTabs label="Main" items={nav} /></>} footer="…">
99
+ <PageHeader title="Builds" subtitle="Every build" actions={<Button>New</Button>} />
100
+ <Stack>
101
+ <Section title="Recent">…</Section>
102
+ </Stack>
103
+ </AppShell>
104
+ </body>
105
+ </html>;
106
+ ```
107
+
108
+ - **`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
111
+ page; the header keeps its own, so put a `Container` back around anything that should line up.
112
+ - **`Container`** is the only place the content width (`max-w-6xl`) and side padding
113
+ (`px-4 sm:px-6`) are defined. **Pages never set their own width or outer padding**: no `max-w-*`,
114
+ no `mx-auto`, no `px-*` on a page. AppShell already gives them the edge.
115
+ - **`PageHeader`** is the top of every page: optional `breadcrumbs`, the `title` (a `PageHeading`),
116
+ `subtitle`, `actions` (right of the title from `sm`, under it on a phone) and `tabs` under it. Its
117
+ 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.
122
+
123
+ ### Page layouts
124
+
125
+ Small compositions of the above: they take slots, own no data, and set no page width or outer
126
+ padding. Each takes the `PageHeader` props (`title`, `subtitle`, `actions`, `breadcrumbs`, `tabs`)
127
+ except the last four.
128
+
129
+ - **`ListLayout`**: use this when a page is a filterable list. `filters` sits under the header;
130
+ `children` is the list, replaced by `empty` when `isEmpty`.
131
+ - **`DetailLayout`**: use this when a page is one thing with facts beside it. `children` take two
132
+ thirds from `lg` and `aside` one third; below `lg` the aside stacks under the main column.
133
+ - **`FormLayout`**: use this for a create or edit page. A `max-w-3xl` `<form>` that starts at the
134
+ Container's left edge (it is not re-centred, so the title does not jump), with an `actions` row at
135
+ the end. Other props (`onSubmit`, `action`) go on the `<form>`.
136
+ - **`CardGrid`**: use this for stat tiles or a gallery of cards. `columns` is `2`, `3` (default) or
137
+ `4`: one column on a phone, two from `sm`, `columns` from `lg`.
138
+ - **`SidebarLayout`**: use this when a `rail` of navigation sits beside the content. Render it in
139
+ `<AppShell bleed>`: it fills the height under the header with no header height written down
140
+ (`AppShell`'s main is a flex column). The rail is `md:w-60` and stacks above the canvas below `md`;
141
+ the canvas keeps the Container's padding.
142
+ - **`FullBleedLayout`**: use this for a viewer that needs the whole width. Render it in
143
+ `<AppShell bleed>`; `children` fill the width, and an optional `panel` takes `lg:w-72` on the
144
+ right and stacks below under `lg`.
145
+ - **`CenteredLayout`**: use this for sign-in, landing, not-found and error pages. A `max-w-md` card
146
+ centred at a fixed offset from the top; the one layout that does not share the left edge.
147
+
148
+ ```tsx
149
+ <AppShell header={header}>
150
+ <ListLayout title="Quotes" actions={<Button>New</Button>} filters={<FilterGroup …/>}
151
+ isEmpty={quotes.length === 0} empty={<Empty>No quotes yet.</Empty>}>
152
+ {quotes.map(…)}
153
+ </ListLayout>
154
+ </AppShell>
155
+ ```
156
+
157
+ ## Light and dark
158
+
159
+ A dark theme does its colour work under `.dark`; this only decides whether `.dark` is on `<html>`.
160
+ Two states, light and dark. The stored choice wins; with nothing stored the system's
161
+ `prefers-color-scheme` decides. Which dark theme is loaded stays your CSS import.
162
+
163
+ 1. **Inline `colourSchemeScript` in `<head>`**, before the stylesheet, so the class is right at
164
+ first paint and a light user gets no dark flash. It is a few hundred bytes of plain JavaScript:
165
+
166
+ ```tsx
167
+ import { colourSchemeScript } from "@hackcove/ui";
168
+
169
+ <head>
170
+ <script dangerouslySetInnerHTML={{ __html: colourSchemeScript }} />
171
+ </head>;
172
+ ```
173
+
174
+ In Astro, use `<script is:inline set:html={colourSchemeScript} />`.
175
+
176
+ 2. **Drop the hard-coded `class="dark"`** from `<html>`. The script sets and removes it (and
177
+ `color-scheme`, so native controls and scrollbars follow). For a server-rendered `<html>`, add
178
+ `suppressHydrationWarning` (React) because the script changes its `class` before React hydrates.
179
+
180
+ 3. **Place the toggle** wherever the header wants it:
181
+
182
+ ```tsx
183
+ import { ColourSchemeToggle } from "@hackcove/ui";
184
+
185
+ <ColourSchemeToggle className="ml-auto" onChange={(scheme) => track(scheme)} />;
186
+ ```
187
+
188
+ It is an `IconButton` showing the scheme you would switch *to* (a sun on a dark page, a moon on a
189
+ light one), labelled "Switch to light theme" / "Switch to dark theme". A click applies the scheme
190
+ to `<html>` and stores it under `localStorage["hackcove:colour-scheme"]`; a storage error (private
191
+ mode) is swallowed and the page still switches. It reads the current state from the `dark` class
192
+ when it mounts, so it agrees with the script and renders the same on the server and the client.
193
+
194
+ Without React, `applyColourScheme(scheme, root?)`, `readColourScheme(fallback?)` and
195
+ `storeColourScheme(scheme)` are exported too; `ColourScheme` is `"light" | "dark"`.
44
196
 
45
197
  ## Palette
46
198
 
@@ -0,0 +1,22 @@
1
+ import type { ReactElement, 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
+ export declare function AppShell({ header, subnav, footer, bleed, children, }: {
14
+ /** The header row: logo, nav, controls. Always one `h-14` row. */
15
+ header: ReactNode;
16
+ /** A second fixed-height row under the header, e.g. `NavTabs`. */
17
+ subnav?: ReactNode;
18
+ footer?: ReactNode;
19
+ /** Main without a Container, for a full-bleed layout. */
20
+ bleed?: boolean;
21
+ children: ReactNode;
22
+ }): ReactElement;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Container } from "./Container.js";
3
+ import { cx } from "./cx.js";
4
+ /**
5
+ * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
6
+ * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
7
+ */
8
+ export const appShellRootClass = "overflow-y-scroll";
9
+ // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
10
+ const ROW = "flex items-center gap-4 overflow-x-auto whitespace-nowrap *:shrink-0";
11
+ /**
12
+ * The page frame: a sticky one-row header, an optional second `subnav` row, the main area and an
13
+ * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
14
+ * width or padding. `bleed` drops main's Container (the header keeps its own). Main is a flex
15
+ * column, so a full-height child (`SidebarLayout`) is `flex-1 min-h-0` and needs no header height.
16
+ */
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 }) }))] }));
19
+ }
@@ -0,0 +1,22 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ declare const SIZE_CLASSES: {
3
+ readonly sm: "size-6 text-xs";
4
+ readonly md: "size-7 text-xs";
5
+ readonly lg: "size-10 text-base";
6
+ };
7
+ export type AvatarSize = keyof typeof SIZE_CLASSES;
8
+ /** The background class for a name: the same name gets the same colour everywhere. */
9
+ export declare function avatarColourClass(name: string): string;
10
+ /** Up to two letters or digits of a name, upper-cased; `?` when it has none. */
11
+ export declare function initialsOf(name: string): string;
12
+ /**
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
+ */
16
+ export declare function Avatar({ name, size, ring, className, ...rest }: {
17
+ name: string;
18
+ size?: AvatarSize;
19
+ /** A ring in the page colour, for a stack of overlapping avatars. */
20
+ ring?: boolean;
21
+ } & Omit<HTMLAttributes<HTMLSpanElement>, "children">): ReactElement;
22
+ export {};
package/dist/Avatar.js ADDED
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** The identity colours, in hash order. Each is a stock background utility; a theme remaps them. */
4
+ const COLOUR_CLASSES = [
5
+ "bg-violet-600",
6
+ "bg-orange-600",
7
+ "bg-cyan-600",
8
+ "bg-emerald-600",
9
+ "bg-sky-600",
10
+ "bg-pink-600",
11
+ "bg-amber-600",
12
+ ];
13
+ const SIZE_CLASSES = {
14
+ sm: "size-6 text-xs",
15
+ md: "size-7 text-xs",
16
+ lg: "size-10 text-base",
17
+ };
18
+ /** The background class for a name: the same name gets the same colour everywhere. */
19
+ export function avatarColourClass(name) {
20
+ let hash = 0;
21
+ for (const char of name)
22
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
23
+ return COLOUR_CLASSES[hash % COLOUR_CLASSES.length] ?? COLOUR_CLASSES[0];
24
+ }
25
+ /** Up to two letters or digits of a name, upper-cased; `?` when it has none. */
26
+ export function initialsOf(name) {
27
+ return (name
28
+ .replace(/[^a-z0-9]/gi, "")
29
+ .slice(0, 2)
30
+ .toUpperCase() || "?");
31
+ }
32
+ /**
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
+ */
36
+ export function Avatar({ name, size = "md", ring = false, className, ...rest }) {
37
+ 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
+ }
@@ -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,10 @@
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, children, }: {
7
+ as?: "div" | "main" | "header" | "footer" | "section" | "nav";
8
+ className?: string;
9
+ children?: ReactNode;
10
+ }): 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, children, }) {
8
+ return _jsx(Tag, { 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 {};
@@ -0,0 +1,77 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CalendarDate as CalendarDateClass, DateFormatter, } from "@internationalized/date";
3
+ import { useContext, useEffect } from "react";
4
+ import { DateField as AriaDateField, DateFieldStateContext, DateInput, DateSegment, FieldError, I18nProvider, Label, Text, } from "react-aria-components";
5
+ import { cx, focusRing } from "./cx.js";
6
+ import { toISODate } from "./isoDate.js";
7
+ import { ShownInput, useShownDate } from "./shownDate.js";
8
+ /** Fixed, whatever the browser's locale: DD/MM/YYYY and Monday-first weeks. */
9
+ export const LOCALE = "en-GB";
10
+ const MONTHS = new DateFormatter(LOCALE, { month: "long", timeZone: "UTC" });
11
+ export const MONTH_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
12
+ month: i + 1,
13
+ name: MONTHS.format(new Date(Date.UTC(2000, i, 1))),
14
+ }));
15
+ function readSegments(segments) {
16
+ const editable = segments.filter((segment) => segment.isEditable);
17
+ const blank = editable.filter((segment) => segment.isPlaceholder).length;
18
+ const none = { typed: "", impossible: null, partial: blank > 0 && blank < editable.length };
19
+ if (editable.length === 0 || blank > 0)
20
+ return none;
21
+ const part = (type) => editable.find((s) => s.type === type)?.value ?? 0;
22
+ const [day, month, year] = [part("day"), part("month"), part("year")];
23
+ if (day < 1 || month < 1 || year < 1)
24
+ return none;
25
+ const first = new CalendarDateClass(year, month, 1);
26
+ const days = first.calendar.getDaysInMonth(first);
27
+ if (day <= days)
28
+ return { ...none, typed: toISODate(first.set({ day })) };
29
+ return {
30
+ ...none,
31
+ impossible: `${MONTH_OPTIONS[month - 1]?.name ?? ""} ${year} has ${days} days.`,
32
+ };
33
+ }
34
+ /** One segment. The day segment also reports what the segments show to the field. */
35
+ export function FieldSegment({ segment, onRead, }) {
36
+ const state = useContext(DateFieldStateContext);
37
+ const { typed, impossible, partial } = readSegments(state?.segments ?? []);
38
+ useEffect(() => onRead?.({ typed, impossible, partial }), [typed, impossible, partial, onRead]);
39
+ return (_jsx(DateSegment, { segment: segment, className: ({ isFocused, isPlaceholder, isInvalid, type }) => {
40
+ if (type === "literal")
41
+ return "text-slate-400";
42
+ let tone = "text-slate-900";
43
+ if (isFocused)
44
+ tone = "bg-violet-50 text-violet-600";
45
+ else if (isPlaceholder)
46
+ tone = "text-slate-400";
47
+ else if (isInvalid)
48
+ tone = "text-red-700";
49
+ return cx("rounded px-0.5 tabular-nums outline-none", tone);
50
+ }, children: ({ isPlaceholder, placeholder, text }) => (isPlaceholder ? placeholder.toUpperCase() : text) }));
51
+ }
52
+ export function frameClass({ isInvalid, isFocusWithin, isDisabled, }) {
53
+ let tone = "border-slate-300 bg-white text-slate-900 hover:border-violet-400";
54
+ if (isInvalid)
55
+ tone = "border-red-200 bg-red-50 text-red-700";
56
+ else if (isFocusWithin)
57
+ tone = "border-violet-600 bg-white text-slate-900";
58
+ return cx("flex w-full items-center rounded-lg border py-2 pl-2.5 text-sm transition", tone, isDisabled && "opacity-50");
59
+ }
60
+ /** The label, the description or the error line: the shared outside of both components. */
61
+ export function Notes({ description, errorMessage, impossible, isInvalid, }) {
62
+ return (_jsxs(_Fragment, { children: [!isInvalid && description ? (_jsx(Text, { slot: "description", className: "text-xs text-slate-500", children: description })) : null, _jsx(FieldError, { className: "text-xs text-red-700", children: ({ validationErrors }) => errorMessage ?? impossible ?? validationErrors.join(" ") })] }));
63
+ }
64
+ export const labelClass = "text-sm font-medium text-slate-700";
65
+ export 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 ${focusRing}`;
66
+ /** A segmented DD / MM / YYYY date input. Values are `CalendarDate`s; `name` posts it as `YYYY-MM-DD`. */
67
+ export function DateField({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }) {
68
+ const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
69
+ value,
70
+ defaultValue,
71
+ onChange,
72
+ minValue: rest.minValue,
73
+ maxValue: rest.maxValue,
74
+ isRequired: rest.isRequired,
75
+ });
76
+ return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDateField, { ...rest, value: inner, onChange: setInner, validate: validate, ...(impossible ? { isInvalid: true } : {}), isReadOnly: leaving, onBlur: onLeft, granularity: "day", shouldForceLeadingZeros: true, className: cx("flex flex-col gap-1.5", className), children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(DateInput, { className: (state) => cx(frameClass(state), "pr-2.5"), children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, impossible: impossible, isInvalid: isInvalid })] })) }) }));
77
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactElement } from "react";
2
+ import { type DateFieldProps } from "./DateField.js";
3
+ export type DatePickerProps = DateFieldProps & {
4
+ /** Open the calendar on mount. */
5
+ defaultOpen?: boolean;
6
+ };
7
+ /** A `DateField` with a calendar button that opens a month view in a popover. */
8
+ export declare function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }: DatePickerProps): ReactElement;
@@ -0,0 +1,27 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { Button, DatePicker as AriaDatePicker, DateFieldContext, DateInput, Group, I18nProvider, Label, } from "react-aria-components";
4
+ import { cx } from "./cx.js";
5
+ import { CalendarPopover, calendarIcon } from "./DateCalendar.js";
6
+ import { FieldSegment, LOCALE, Notes, frameClass, iconButtonClass, labelClass, } from "./DateField.js";
7
+ import { ShownInput, useShownDate } from "./shownDate.js";
8
+ /**
9
+ * The picker's input, read-only while `leaving`. Only the field: a read-only picker would also
10
+ * disable the calendar button that focus may be moving to.
11
+ */
12
+ function PickerInput({ leaving, onLeft, children, }) {
13
+ const field = useContext(DateFieldContext);
14
+ return (_jsx(DateFieldContext.Provider, { value: { ...field, isReadOnly: leaving, onBlur: onLeft }, children: children }));
15
+ }
16
+ /** A `DateField` with a calendar button that opens a month view in a popover. */
17
+ export function DatePicker({ label, description, errorMessage, className, value, defaultValue, onChange, name, ...rest }) {
18
+ const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
19
+ value,
20
+ defaultValue,
21
+ onChange,
22
+ minValue: rest.minValue,
23
+ maxValue: rest.maxValue,
24
+ isRequired: rest.isRequired,
25
+ });
26
+ return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDatePicker, { ...rest, value: inner, onChange: setInner, validate: validate, ...(impossible ? { isInvalid: true } : {}), granularity: "day", shouldForceLeadingZeros: true, className: cx("flex flex-col gap-1.5", className), children: ({ isInvalid }) => (_jsxs(_Fragment, { children: [label ? _jsx(Label, { className: labelClass, children: label }) : null, _jsxs(Group, { className: (state) => cx(frameClass(state), "gap-1 pr-1"), children: [_jsx("div", { className: "contents", onBlurCapture: onBlurCapture, children: _jsx(PickerInput, { leaving: leaving, onLeft: onLeft, children: _jsx(DateInput, { className: "flex flex-1", children: (segment) => (_jsx(FieldSegment, { segment: segment, ...(segment.type === "day" ? { onRead } : {}) })) }) }) }), _jsx(Button, { className: iconButtonClass, children: calendarIcon })] }), _jsx(ShownInput, { name: name, shown: shown, isDisabled: rest.isDisabled }), _jsx(Notes, { description: description, errorMessage: errorMessage, impossible: impossible, isInvalid: isInvalid }), _jsx(CalendarPopover, {})] })) }) }));
27
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ export type PageHeaderProps = {
3
+ /** A `Breadcrumbs` above the title. */
4
+ breadcrumbs?: ReactNode;
5
+ title: ReactNode;
6
+ subtitle?: ReactNode;
7
+ /** Right of the title on `sm` and up, below it on a phone. */
8
+ actions?: ReactNode;
9
+ /** A `NavTabs` (or similar) under the title. */
10
+ tabs?: ReactNode;
11
+ };
12
+ /** The header block without its bottom margin; PageHeader adds the margin, PageTitle does not. */
13
+ export declare function PageHeaderContent({ breadcrumbs, title, subtitle, actions, tabs, className, }: PageHeaderProps & {
14
+ className?: string;
15
+ }): ReactElement;
16
+ /** The top of a page: breadcrumbs, title, subtitle, actions and tabs. Its fixed bottom margin puts every page's first block at the same y. */
17
+ export declare function PageHeader({ className, ...props }: PageHeaderProps & {
18
+ className?: string;
19
+ }): ReactElement;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { PageHeading, Subtitle } from "./typography.js";
4
+ /** The header block without its bottom margin; PageHeader adds the margin, PageTitle does not. */
5
+ export function PageHeaderContent({ breadcrumbs, title, subtitle, actions, tabs, className, }) {
6
+ return (_jsxs("div", { className: className, children: [breadcrumbs && _jsx("div", { className: "mb-2", children: breadcrumbs }), _jsxs("div", { className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsx(PageHeading, { children: title }), subtitle && _jsx(Subtitle, { className: "mt-1", children: subtitle })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), tabs && _jsx("div", { className: "mt-4", children: tabs })] }));
7
+ }
8
+ /** The top of a page: breadcrumbs, title, subtitle, actions and tabs. Its fixed bottom margin puts every page's first block at the same y. */
9
+ export function PageHeader({ className, ...props }) {
10
+ return _jsx(PageHeaderContent, { ...props, className: cx("mb-8", className) });
11
+ }
@@ -0,0 +1,52 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
+ import { type PageHeaderProps } from "./PageHeader.js";
3
+ /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
4
+ export declare function ListLayout({ filters, empty, isEmpty, children, ...header }: PageHeaderProps & {
5
+ filters?: ReactNode;
6
+ /** Shown in place of `children` when `isEmpty`. */
7
+ empty?: ReactNode;
8
+ isEmpty?: boolean;
9
+ children?: ReactNode;
10
+ }): ReactElement;
11
+ /** A detail page: header, then `children` (two thirds from `lg`) beside `aside` (one third), which stacks below on a smaller screen. */
12
+ export declare function DetailLayout({ aside, children, ...header }: PageHeaderProps & {
13
+ aside: ReactNode;
14
+ children: ReactNode;
15
+ }): ReactElement;
16
+ /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
17
+ export declare function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, className, ...form }: Omit<PageHeaderProps, "actions"> & {
18
+ actions?: ReactNode;
19
+ children: ReactNode;
20
+ } & Omit<ComponentPropsWithoutRef<"form">, "title">): ReactElement;
21
+ declare const COLUMNS: {
22
+ readonly 2: "lg:grid-cols-2";
23
+ readonly 3: "lg:grid-cols-3";
24
+ readonly 4: "lg:grid-cols-4";
25
+ };
26
+ /** A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`. */
27
+ export declare function CardGrid({ columns, className, children, }: {
28
+ columns?: keyof typeof COLUMNS;
29
+ className?: string;
30
+ children: ReactNode;
31
+ }): ReactElement;
32
+ /**
33
+ * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
34
+ * the shell's flex column leaves under the header, so no header height is written down. The rail is
35
+ * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
36
+ */
37
+ export declare function SidebarLayout({ rail, children, }: {
38
+ rail: ReactNode;
39
+ children: ReactNode;
40
+ }): ReactElement;
41
+ /** Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller screen. Render it in `<AppShell bleed>`. */
42
+ export declare function FullBleedLayout({ panel, children, }: {
43
+ panel?: ReactNode;
44
+ children: ReactNode;
45
+ }): ReactElement;
46
+ /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
47
+ export declare function CenteredLayout({ title, subtitle, children, }: {
48
+ title: ReactNode;
49
+ subtitle?: ReactNode;
50
+ children?: ReactNode;
51
+ }): ReactElement;
52
+ export {};
@@ -0,0 +1,45 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card } from "./ui.js";
3
+ import { Container } from "./Container.js";
4
+ import { cx } from "./cx.js";
5
+ import { PageHeader, PageHeaderContent } from "./PageHeader.js";
6
+ import { Stack } from "./Stack.js";
7
+ // Compositions of the layout foundation. None sets a page width or outer padding of its own; the
8
+ // ones that narrow their content do it inside the Container they are rendered in.
9
+ /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
10
+ export function ListLayout({ filters, empty, isEmpty = false, children, ...header }) {
11
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...header }), _jsxs(Stack, { children: [filters, isEmpty ? empty : children] })] }));
12
+ }
13
+ /** A detail page: header, then `children` (two thirds from `lg`) beside `aside` (one third), which stacks below on a smaller screen. */
14
+ export function DetailLayout({ aside, children, ...header }) {
15
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...header }), _jsxs("div", { className: "grid gap-6 lg:grid-cols-3", children: [_jsx(Stack, { className: "min-w-0 lg:col-span-2", children: children }), _jsx("aside", { className: "min-w-0", children: _jsx(Stack, { children: aside }) })] })] }));
16
+ }
17
+ /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
18
+ export function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, className, ...form }) {
19
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, tabs: tabs }), _jsx("form", { className: cx("max-w-3xl", className), ...form, children: _jsxs(Stack, { children: [children, actions && _jsx("div", { className: "flex flex-wrap items-center gap-2", children: actions })] }) })] }));
20
+ }
21
+ const COLUMNS = {
22
+ 2: "lg:grid-cols-2",
23
+ 3: "lg:grid-cols-3",
24
+ 4: "lg:grid-cols-4",
25
+ };
26
+ /** A responsive grid of tiles: one column on a phone, two from `sm`, `columns` from `lg`. */
27
+ export function CardGrid({ columns = 3, className, children, }) {
28
+ return (_jsx("div", { className: cx("grid grid-cols-1 gap-4 sm:grid-cols-2", COLUMNS[columns], className), children: children }));
29
+ }
30
+ /**
31
+ * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
32
+ * the shell's flex column leaves under the header, so no header height is written down. The rail is
33
+ * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
34
+ */
35
+ export function SidebarLayout({ rail, children, }) {
36
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col md:flex-row", children: [_jsx("aside", { className: "w-full shrink-0 border-b border-slate-200 bg-slate-50 p-4 md:w-60 md:border-r md:border-b-0", children: rail }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(Container, { className: "py-8", children: children }) })] }));
37
+ }
38
+ /** Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller screen. Render it in `<AppShell bleed>`. */
39
+ export function FullBleedLayout({ panel, children, }) {
40
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [_jsx("div", { className: "min-w-0 flex-1", children: children }), panel && (_jsx("aside", { className: "w-full shrink-0 border-t border-slate-200 bg-slate-50 p-4 lg:w-72 lg:border-t-0 lg:border-l", children: panel }))] }));
41
+ }
42
+ /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
43
+ export function CenteredLayout({ title, subtitle, children, }) {
44
+ return (_jsx("div", { className: "mx-auto w-full max-w-md pt-16", children: _jsx(Card, { as: "div", children: _jsxs(Stack, { children: [_jsx(PageHeaderContent, { title: title, subtitle: subtitle }), children] }) }) }));
45
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ export type StackGap = "sm" | "md" | "lg";
3
+ /** A vertical column of blocks a fixed `gap` apart: `sm` inside a group, `md` (default) between blocks, `lg` between groups. */
4
+ export declare function Stack({ gap, as: Tag, className, children, ...rest }: {
5
+ gap?: StackGap;
6
+ as?: "div" | "section";
7
+ className?: string | undefined;
8
+ children?: ReactNode;
9
+ "aria-labelledby"?: string | undefined;
10
+ }): ReactElement;
11
+ /** A `<section>` Stack with an optional `SectionHeading` title (which names it) and `actions` beside it. */
12
+ export declare function Section({ title, actions, gap, className, children, }: {
13
+ title?: ReactNode;
14
+ actions?: ReactNode;
15
+ gap?: StackGap;
16
+ className?: string;
17
+ children?: ReactNode;
18
+ }): ReactElement;
package/dist/Stack.js ADDED
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from "react";
3
+ import { cx } from "./cx.js";
4
+ import { SectionHeading } from "./typography.js";
5
+ const GAP = { sm: "gap-4", md: "gap-6", lg: "gap-8" };
6
+ /** A vertical column of blocks a fixed `gap` apart: `sm` inside a group, `md` (default) between blocks, `lg` between groups. */
7
+ export function Stack({ gap = "md", as: Tag = "div", className, children, ...rest }) {
8
+ return (_jsx(Tag, { className: cx("flex flex-col", GAP[gap], className), ...rest, children: children }));
9
+ }
10
+ /** A `<section>` Stack with an optional `SectionHeading` title (which names it) and `actions` beside it. */
11
+ export function Section({ title, actions, gap = "sm", className, children, }) {
12
+ const titleId = useId();
13
+ return (_jsxs(Stack, { as: "section", gap: gap, className: className, "aria-labelledby": title ? titleId : undefined, children: [(title || actions) && (_jsxs("div", { className: "flex items-center justify-between gap-4", children: [title && _jsx(SectionHeading, { id: titleId, children: title }), actions && _jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })] })), children] }));
14
+ }
@@ -0,0 +1,12 @@
1
+ export type ColourScheme = "light" | "dark";
2
+ /** Put `scheme` on `root` (the `<html>` element by default): the `dark` class, and `color-scheme` so native controls and scrollbars follow. */
3
+ export declare function applyColourScheme(scheme: ColourScheme, root?: HTMLElement): void;
4
+ /** The stored choice, else the system preference. `fallback` (default `"light"`) is for when neither is available: server rendering, or storage that throws with no `matchMedia`. */
5
+ export declare function readColourScheme(fallback?: ColourScheme): ColourScheme;
6
+ /** Remember `scheme` for the next visit. A storage error is swallowed, so private mode does not crash. */
7
+ export declare function storeColourScheme(scheme: ColourScheme): void;
8
+ /**
9
+ * JavaScript to inline in `<head>`, before first paint: applies the stored scheme, else the
10
+ * system one, to `<html>` as `applyColourScheme` does. Without it a light user sees a dark flash.
11
+ */
12
+ export declare const colourSchemeScript: string;
@@ -0,0 +1,40 @@
1
+ const STORAGE_KEY = "hackcove:colour-scheme";
2
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
3
+ const isScheme = (value) => value === "light" || value === "dark";
4
+ /** Put `scheme` on `root` (the `<html>` element by default): the `dark` class, and `color-scheme` so native controls and scrollbars follow. */
5
+ export function applyColourScheme(scheme, root = document.documentElement) {
6
+ root.classList.toggle("dark", scheme === "dark");
7
+ root.style.colorScheme = scheme;
8
+ }
9
+ function storedScheme() {
10
+ try {
11
+ const stored = localStorage.getItem(STORAGE_KEY);
12
+ return isScheme(stored) ? stored : undefined;
13
+ }
14
+ catch {
15
+ return undefined; // storage blocked: private mode, or no window at all
16
+ }
17
+ }
18
+ function systemScheme() {
19
+ if (typeof matchMedia !== "function")
20
+ return undefined;
21
+ return matchMedia(DARK_QUERY).matches ? "dark" : "light";
22
+ }
23
+ /** The stored choice, else the system preference. `fallback` (default `"light"`) is for when neither is available: server rendering, or storage that throws with no `matchMedia`. */
24
+ export function readColourScheme(fallback = "light") {
25
+ return storedScheme() ?? systemScheme() ?? fallback;
26
+ }
27
+ /** Remember `scheme` for the next visit. A storage error is swallowed, so private mode does not crash. */
28
+ export function storeColourScheme(scheme) {
29
+ try {
30
+ localStorage.setItem(STORAGE_KEY, scheme);
31
+ }
32
+ catch {
33
+ // Not remembered; the page still switches for this visit.
34
+ }
35
+ }
36
+ /**
37
+ * JavaScript to inline in `<head>`, before first paint: applies the stored scheme, else the
38
+ * system one, to `<html>` as `applyColourScheme` does. Without it a light user sees a dark flash.
39
+ */
40
+ export const colourSchemeScript = `(function(){var r=document.documentElement,s;try{s=localStorage.getItem(${JSON.stringify(STORAGE_KEY)})}catch(e){}if(s!=="light"&&s!=="dark")s=typeof matchMedia==="function"?matchMedia(${JSON.stringify(DARK_QUERY)}).matches?"dark":"light":null;if(s){r.classList.toggle("dark",s==="dark");r.style.colorScheme=s}})()`;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { AppShell, appShellRootClass } from "./AppShell.js";
4
+ export { Container } from "./Container.js";
5
+ export { PageHeader, type PageHeaderProps } from "./PageHeader.js";
6
+ export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, SidebarLayout, } from "./PageLayouts.js";
7
+ export { Section, Stack, type StackGap } from "./Stack.js";
3
8
  export { Button, buttonClass, type ButtonVariant } from "./Button.js";
9
+ export { Avatar, avatarColourClass, initialsOf, type AvatarSize } from "./Avatar.js";
4
10
  export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
5
11
  export { Callout, type CalloutTone } from "./Callout.js";
6
12
  export { Dialog, type DialogProps } from "./Dialog.js";
7
13
  export { IconButton } from "./IconButton.js";
14
+ export { ColourSchemeToggle } from "./ColourSchemeToggle.js";
15
+ export { applyColourScheme, colourSchemeScript, readColourScheme, storeColourScheme, type ColourScheme, } from "./colourScheme.js";
8
16
  export { NavTabs, navTabClass, type NavTab } from "./NavTabs.js";
9
17
  export { ToggleChip } from "./ToggleChip.js";
10
18
  export { FilterGroup } from "./FilterGroup.js";
@@ -19,3 +27,7 @@ export { KeyValue, KeyValueList } from "./KeyValueList.js";
19
27
  export { Select } from "./Select.js";
20
28
  export { Textarea } from "./Textarea.js";
21
29
  export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, type TypographyColor, type TypographyProps, } from "./typography.js";
30
+ export { DateField, type DateFieldProps } from "./DateField.js";
31
+ export { DatePicker, type DatePickerProps } from "./DatePicker.js";
32
+ export { fromISODate, toISODate } from "./isoDate.js";
33
+ export type { CalendarDate } from "@internationalized/date";
package/dist/index.js CHANGED
@@ -1,10 +1,18 @@
1
1
  export { Card, EpicHeader, Empty, PageTitle, Pill, groupByEpic } from "./ui.js";
2
2
  export { Markdown } from "./Markdown.js";
3
+ export { AppShell, appShellRootClass } from "./AppShell.js";
4
+ export { Container } from "./Container.js";
5
+ export { PageHeader } from "./PageHeader.js";
6
+ export { CardGrid, CenteredLayout, DetailLayout, FormLayout, FullBleedLayout, ListLayout, SidebarLayout, } from "./PageLayouts.js";
7
+ export { Section, Stack } from "./Stack.js";
3
8
  export { Button, buttonClass } from "./Button.js";
9
+ export { Avatar, avatarColourClass, initialsOf } from "./Avatar.js";
4
10
  export { Breadcrumbs, crumbLinkClass } from "./Breadcrumbs.js";
5
11
  export { Callout } from "./Callout.js";
6
12
  export { Dialog } from "./Dialog.js";
7
13
  export { IconButton } from "./IconButton.js";
14
+ export { ColourSchemeToggle } from "./ColourSchemeToggle.js";
15
+ export { applyColourScheme, colourSchemeScript, readColourScheme, storeColourScheme, } from "./colourScheme.js";
8
16
  export { NavTabs, navTabClass } from "./NavTabs.js";
9
17
  export { ToggleChip } from "./ToggleChip.js";
10
18
  export { FilterGroup } from "./FilterGroup.js";
@@ -19,3 +27,6 @@ export { KeyValue, KeyValueList } from "./KeyValueList.js";
19
27
  export { Select } from "./Select.js";
20
28
  export { Textarea } from "./Textarea.js";
21
29
  export { Body, BodyStrong, Caption, Display, Lead, ModalHeading, Overline, PageHeading, SectionHeading, SubsectionHeading, Subtitle, } from "./typography.js";
30
+ export { DateField } from "./DateField.js";
31
+ export { DatePicker } from "./DatePicker.js";
32
+ export { fromISODate, toISODate } from "./isoDate.js";
@@ -0,0 +1,5 @@
1
+ import { CalendarDate } from "@internationalized/date";
2
+ /** `"1990-05-17"` for `CalendarDate(1990, 5, 17)`; `""` for no date, so it drops into a form field. */
3
+ export declare function toISODate(date: CalendarDate | null | undefined): string;
4
+ /** The `CalendarDate` of a `"YYYY-MM-DD"` string; `null` for an empty, malformed or impossible one. */
5
+ export declare function fromISODate(iso: string | null | undefined): CalendarDate | null;
@@ -0,0 +1,16 @@
1
+ import { CalendarDate } from "@internationalized/date";
2
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
3
+ /** `"1990-05-17"` for `CalendarDate(1990, 5, 17)`; `""` for no date, so it drops into a form field. */
4
+ export function toISODate(date) {
5
+ return date ? date.toString() : "";
6
+ }
7
+ /** The `CalendarDate` of a `"YYYY-MM-DD"` string; `null` for an empty, malformed or impossible one. */
8
+ export function fromISODate(iso) {
9
+ const match = ISO_DATE.exec(iso ?? "");
10
+ if (!match)
11
+ return null;
12
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
13
+ const date = new CalendarDate(year, month, day);
14
+ // CalendarDate constrains 1990-02-31 to 1990-02-28; a string that does not survive is impossible.
15
+ return date.toString() === iso ? date : null;
16
+ }
@@ -0,0 +1,45 @@
1
+ import type { CalendarDate } from "@internationalized/date";
2
+ import { type FocusEvent, type ReactElement } from "react";
3
+ import type { DateFieldProps, Read } from "./DateField.js";
4
+ type ValueKeys = "value" | "defaultValue" | "onChange" | "minValue" | "maxValue" | "isRequired";
5
+ /** The value props, each of which may be passed as `undefined`. */
6
+ type ValueProps = {
7
+ [K in ValueKeys]?: DateFieldProps[K] | undefined;
8
+ };
9
+ /**
10
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
11
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
12
+ *
13
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
14
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
15
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
16
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
17
+ * `value` the parent sets that differs from the last report is passed through, and not reported
18
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
19
+ * segments: a partly filled required field blocks the form.
20
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
21
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
22
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
23
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
24
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
25
+ *
26
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
27
+ */
28
+ export declare function useShownDate(props: ValueProps): {
29
+ inner: CalendarDate | null;
30
+ setInner: import("react").Dispatch<import("react").SetStateAction<CalendarDate | null>>;
31
+ shown: string;
32
+ impossible: string | null;
33
+ onRead: (next: Read) => void;
34
+ validate: () => "Enter the whole date." | null;
35
+ leaving: boolean;
36
+ onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
37
+ onLeft: () => void;
38
+ };
39
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
40
+ export declare function ShownInput({ name, shown, isDisabled, }: {
41
+ name: string | undefined;
42
+ shown: string;
43
+ isDisabled: boolean | undefined;
44
+ }): ReactElement | null;
45
+ export {};
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from "react";
3
+ import { flushSync } from "react-dom";
4
+ import { fromISODate, toISODate } from "./isoDate.js";
5
+ /** `iso` when it is a date within min/max, else `""`. */
6
+ function withinRange(iso, { minValue, maxValue }) {
7
+ const date = fromISODate(iso);
8
+ if (!date)
9
+ return "";
10
+ if (minValue && date.compare(minValue) < 0)
11
+ return "";
12
+ if (maxValue && date.compare(maxValue) > 0)
13
+ return "";
14
+ return iso;
15
+ }
16
+ /**
17
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
18
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
19
+ *
20
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
21
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
22
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
23
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
24
+ * `value` the parent sets that differs from the last report is passed through, and not reported
25
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
26
+ * segments: a partly filled required field blocks the form.
27
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
28
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
29
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
30
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
31
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
32
+ *
33
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
34
+ */
35
+ export function useShownDate(props) {
36
+ const { value, defaultValue, onChange, isRequired } = props;
37
+ const [inner, setInner] = useState(value ?? defaultValue ?? null);
38
+ const [read, setRead] = useState({
39
+ typed: toISODate(inner),
40
+ impossible: null,
41
+ partial: false,
42
+ });
43
+ const [leaving, setLeaving] = useState(false);
44
+ const shown = withinRange(read.typed, props);
45
+ const reported = useRef(shown);
46
+ const report = useRef(onChange);
47
+ useEffect(() => {
48
+ report.current = onChange;
49
+ });
50
+ const given = value === undefined ? undefined : toISODate(value);
51
+ const givenShown = withinRange(given ?? "", props);
52
+ const lastGiven = useRef(given);
53
+ useEffect(() => {
54
+ if (given === lastGiven.current)
55
+ return;
56
+ lastGiven.current = given;
57
+ if (given === undefined || given === reported.current)
58
+ return;
59
+ reported.current = givenShown;
60
+ setInner(fromISODate(given));
61
+ }, [given, givenShown]);
62
+ const onRead = useCallback((next) => setRead(next), []);
63
+ useEffect(() => {
64
+ if (shown === reported.current)
65
+ return;
66
+ reported.current = shown;
67
+ report.current?.(fromISODate(shown));
68
+ }, [shown]);
69
+ return {
70
+ inner,
71
+ setInner,
72
+ shown,
73
+ impossible: read.impossible,
74
+ onRead,
75
+ validate: () => (isRequired && read.partial ? "Enter the whole date." : null),
76
+ leaving,
77
+ onBlurCapture: (event) => {
78
+ if (read.impossible && !event.currentTarget.contains(event.relatedTarget)) {
79
+ flushSync(() => setLeaving(true));
80
+ }
81
+ },
82
+ onLeft: () => setLeaving(false),
83
+ };
84
+ }
85
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
86
+ export function ShownInput({ name, shown, isDisabled, }) {
87
+ return name ? _jsx("input", { type: "hidden", name: name, value: shown, disabled: isDisabled }) : null;
88
+ }
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ /** @deprecated use PageHeader */
2
3
  export declare function PageTitle({ title, subtitle }: {
3
4
  title: string;
4
5
  subtitle?: string;
package/dist/ui.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "./cx.js";
3
+ import { PageHeaderContent } from "./PageHeader.js";
3
4
  // Small presentational primitives written against Tailwind's slate-based class vocabulary
4
5
  // (remapped to Catppuccin Mocha in dark mode by theme.css).
6
+ /** @deprecated use PageHeader */
5
7
  export function PageTitle({ title, subtitle }) {
6
- return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight text-slate-900", children: title }), subtitle && _jsx("p", { className: "mt-1 text-sm text-slate-500", children: subtitle })] }));
8
+ return _jsx(PageHeaderContent, { title: title, subtitle: subtitle });
7
9
  }
8
10
  /** A bordered panel with an optional title and count; `as` picks the element (default `section`). */
9
11
  export function Card({ title, count, as: Tag = "section", className, children, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a @hackcove/theme-* package.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,10 @@
29
29
  "pack-check": "npm pack --dry-run",
30
30
  "prepack": "bun run build"
31
31
  },
32
+ "dependencies": {
33
+ "@internationalized/date": "^3.12.4",
34
+ "react-aria-components": "^1.22.1"
35
+ },
32
36
  "devDependencies": {
33
37
  "@types/bun": "1.4.3",
34
38
  "@types/react": "^19.3.0",
@@ -41,6 +45,7 @@
41
45
  },
42
46
  "peerDependencies": {
43
47
  "react": ">=18",
48
+ "react-dom": ">=18",
44
49
  "tailwindcss": "^4"
45
50
  },
46
51
  "nx": {