@hackcove/ui 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +47 -4
  2. package/dist/Badge.d.ts +9 -0
  3. package/dist/Badge.js +10 -0
  4. package/dist/Breadcrumbs.d.ts +22 -3
  5. package/dist/Breadcrumbs.js +23 -2
  6. package/dist/Button.d.ts +18 -8
  7. package/dist/Button.js +54 -22
  8. package/dist/ButtonGroup.d.ts +14 -0
  9. package/dist/ButtonGroup.js +10 -0
  10. package/dist/ButtonLink.d.ts +20 -0
  11. package/dist/ButtonLink.js +18 -0
  12. package/dist/Callout.d.ts +10 -2
  13. package/dist/Callout.js +8 -3
  14. package/dist/Checkbox.d.ts +10 -4
  15. package/dist/Checkbox.js +8 -3
  16. package/dist/ColourSchemeToggle.d.ts +14 -3
  17. package/dist/ColourSchemeToggle.js +13 -9
  18. package/dist/DateField.d.ts +15 -2
  19. package/dist/DateField.js +8 -4
  20. package/dist/DatePicker.d.ts +1 -1
  21. package/dist/DatePicker.js +3 -3
  22. package/dist/Drawer.d.ts +29 -0
  23. package/dist/Drawer.js +26 -0
  24. package/dist/FileDrop.d.ts +10 -0
  25. package/dist/FileDrop.js +11 -0
  26. package/dist/Input.d.ts +3 -2
  27. package/dist/Input.js +5 -4
  28. package/dist/KeyValueList.d.ts +11 -4
  29. package/dist/KeyValueList.js +14 -5
  30. package/dist/ListRow.d.ts +36 -0
  31. package/dist/ListRow.js +31 -0
  32. package/dist/NavTabs.d.ts +15 -4
  33. package/dist/NavTabs.js +13 -2
  34. package/dist/PageFade.d.ts +25 -0
  35. package/dist/PageFade.js +39 -0
  36. package/dist/ProgressBar.d.ts +26 -0
  37. package/dist/ProgressBar.js +32 -0
  38. package/dist/SegmentedControl.d.ts +11 -0
  39. package/dist/SegmentedControl.js +22 -0
  40. package/dist/Select.d.ts +3 -2
  41. package/dist/Select.js +4 -4
  42. package/dist/SplitBar.d.ts +21 -0
  43. package/dist/SplitBar.js +15 -0
  44. package/dist/StatTile.d.ts +16 -0
  45. package/dist/StatTile.js +21 -0
  46. package/dist/Table.d.ts +48 -0
  47. package/dist/Table.js +42 -0
  48. package/dist/Tabs.d.ts +13 -0
  49. package/dist/Tabs.js +23 -0
  50. package/dist/TextLink.d.ts +19 -4
  51. package/dist/TextLink.js +19 -4
  52. package/dist/Textarea.d.ts +3 -2
  53. package/dist/Textarea.js +4 -4
  54. package/dist/Timeline.d.ts +21 -0
  55. package/dist/Timeline.js +19 -0
  56. package/dist/colourScheme.d.ts +11 -2
  57. package/dist/colourScheme.js +16 -3
  58. package/dist/control.d.ts +19 -2
  59. package/dist/control.js +25 -2
  60. package/dist/index.d.ts +21 -5
  61. package/dist/index.js +18 -3
  62. package/dist/link.d.ts +22 -0
  63. package/dist/link.js +11 -0
  64. package/dist/switcher.d.ts +66 -0
  65. package/dist/switcher.js +53 -0
  66. package/dist/ui.d.ts +25 -5
  67. package/dist/ui.js +51 -7
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -39,14 +39,14 @@ export const Example = () => (
39
39
 
40
40
  ## What is exported
41
41
 
42
- - Primitives: `Card`, `EpicHeader`, `Empty`, `PageTitle` (deprecated: use `PageHeader`), `Pill`, `groupByEpic`, `Markdown`.
42
+ - Primitives: `Card` (a bordered panel; `title` and `count` make a small heading; `flush` drops the padding and clips the corners, for a table that runs edge to edge; `bar` moves the title, count and `actions` into a tinted strip over the body; `dashed` is an empty-state shell with centred muted text; `as` is `section` (default), `article` or `div`), `EpicHeader`, `Empty`, `PageTitle` (deprecated: use `PageHeader`), `Pill`, `groupByEpic`, `Markdown`.
43
43
  - Layout (see Layout): `AppShell`, `appShellRootClass`, `Container`, `PageHeader`, `Stack`, `Section`, and the page layouts `ListLayout`, `DetailLayout`, `FormLayout`, `ProseLayout`, `CardGrid`, `SidebarLayout`, `FullBleedLayout`, `CenteredLayout`.
44
44
  - Typography roles (each pins size + weight, takes a `color` and an `as`): `Display`, `Lead`,
45
45
  `PageHeading`, `SectionHeading`, `SubsectionHeading`, `ModalHeading`, `Subtitle`, `Overline`,
46
46
  `Body`, `BodyStrong`, `Caption`. Three opt-in props, none changing today's look: `SectionHeading rule`
47
47
  (a hairline under it, with its spacing), `SubsectionHeading size` (`sm` default, `base` bold, `lg`),
48
48
  `Body preserveNewlines` (keeps a note's line breaks).
49
- - Controls: `Button`, `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).
49
+ - Controls: `Button` (`variant` is `primary` (default), `outline`, `ghost` or `link`, the link having no box: text with an underline on hover, for an in-line action; `size="sm"` is compact, `text-xs`, for table rows and toolbars; `tone="danger"` is red, for delete and clear), `buttonClass(variant, { size, tone })` (the classes of a `Button`, for a router's own `<Link>`), `ButtonLink` (a link that looks like a `Button`: `href`, or `render` to draw the app's own router `<Link>`; takes `variant`, `size`, `tone`, `external`), `ButtonGroup` (a wrapping row of buttons; `end` is one item pushed to the far side, `label` makes it a named `role="group"`), `IconButton` (a square button holding one icon; `label` is required and becomes its `aria-label` and `title`), `ColourSchemeToggle` (a light/dark switch on `<html>`; see Light and dark), `Input` (a text input; `icon` adds a leading icon inside the field), `Textarea` (a multi-line text input, `rows` defaults to 3), `Select` (a native `<select>` in the control look; takes `<option>` children and keeps the browser's arrow). `Input`, `Select` and `Textarea` take `size` (`md` default, or `sm`: `text-xs`, less padding, for dense forms and table rows) and `width` (`full` default, the fixed `xs` 5rem, `sm` 7rem, `md` 9rem, `lg` 12rem, or `auto`: no width class, as wide as the content or as a `className` width such as `w-24`); a fixed width replaces `w-full`, so there is no class to collide with. `size` replaces the native `size` attribute, so a multi-row `<select size={4}>` is not reachable through `Select`. `Checkbox` (a native checkbox with its label; `className` goes on the label, other props on the input; `size="sm"` is `text-xs` with a smaller box, and with no `label` it is a bare box for a table cell, named by `aria-label`). `FileDrop` (a dashed drop zone, a `<label>` around a file input that covers it, so a click opens the dialog and a dropped file lands on the input; `children` is the prompt, `className` goes on the label, `accept`, `multiple`, `name`, `disabled` and `onChange` on the input). `CopyButton` (a small text button that writes `text` to the clipboard and swaps `label` ("copy") for `copiedLabel` ("copied"); every other button prop, `disabled` included, goes on the `<button>`). `CopyLine` (one line of mono text in a box with a `CopyButton` beside it; other props go on the root `<div>`). `Field` (a label above a control, `htmlFor` names the control's id; a `hint` sits under it, and an `error` replaces the hint with a `role="alert"` line). `Callout` (a tinted notice box; `tone` is `neutral`, `info` (default), `positive`, `warn` or `danger`; an optional `title` sits in bold above the body; `actions` puts a button or link at the end of the row; `banner` makes it a flat strip, bottom border only and no radius, for the top of a `Card flush`). `ListRow` (a full-width row that is a `<button>`, or a link with `href` or `render`, with a hover fill, a left-aligned label and `trailing` content; `expanded` makes it the header of a collapsible section: `aria-expanded` and a chevron, the caller owning the state and the section). `NavTabs` (a row of pill links in a `<nav>`; `items` are `{ label, href, active?, external? }` or `{ label, render, active? }`, `label` names the landmark, the active one is filled and gets `aria-current="page"`; `external` opens in a new tab; see Router links). `navTabClass` (the classes of one tab, for a router's own `<Link>`). `Tabs` (underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent; `items` are `{ value, label }` buttons, with `value` the current one's `value` and `onChange(value)` the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or `{ value, label, href | render }` links in a `<nav>` with `aria-current="page"` on the current one; `label` names the group; a button tab takes `disabled` and `controls`, the id of its `role="tabpanel"`, which is the caller's). `SegmentedControl` (a joined pill group, one option filled; the same `items`, `value`, `onChange` and `label` as `Tabs`, as a `radiogroup`). `Drawer` (a modal slide-over on the right edge, on a native `<dialog>` opened on mount like `Dialog`, so render it only while open: `title` is its name, a close button sits beside it, `children` scroll between the header and an optional pinned `footer`; Esc, the scrim and the close button call `onClose`; `size` is `sm`, `md` (default) or `lg`; `closeLabel` names the close button). `Breadcrumbs` (a `<nav aria-label="Breadcrumb">` over an ordered list; `crumbs` are nodes, or `{ label, href | render, external? }` for one that links; the last is the current page and is never a link and gets `aria-current="page"`, and each crumb truncates). `crumbLinkClass` (the classes of a crumb that links, for a router's own `<Link>`). `Table` (a list of records in a rounded, bordered card around a full-width `<table>` that scrolls sideways on a narrow screen; `caption` names it for assistive tech and is not drawn; compose it from `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell` (`align` `start` or `end`) and `TableCell` (`align`, `tone` `default`, `strong` or `muted`, `numeric` for tabular figures, `mono` for a small mono face)). `rowLinkClass` (the classes of the one link in a row whose hit area covers the row, so the whole row is the click target; for a router's own `<Link>`). `Timeline` (a named `<ol>` of events; `label` is its `aria-label`, `empty` is drawn instead when there are no items) with `TimelineItem` (a `title`, an already-formatted `time` and the `children` as a note). `KeyValueList` (a `<dl>` of label/value rows; each `KeyValue` takes a `label` and its value as children, the value right-aligned in tabular figures; `align="start"` puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value). `Avatar` (a round badge of the initials of `name`, titled with the name and `aria-hidden`; with `src` (the app's own local image path, never a remote one) it is a round, cropped `<img alt={name}>` instead; `size` is `sm`, `md` (default), `lg`, `xl` (56px) or `2xl` (96px); `ring` adds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, and `avatarColourClass(name)` returns it to colour something else the same way; `initialsOf(name)` is the two letters). `TextLink` (an inline text link in the accent colour; `href` is required unless `render` draws the app's own router `<Link>`, `external` adds `target="_blank"` and `rel="noreferrer"`, `stretch` covers the nearest `relative` ancestor so a whole row or card is the click target, `className` and every other anchor prop pass through). `linkClass` (the classes of one, for a router's own `<Link>`) and `linkStretchClass` (the stretch). `InlineLinks` (a wrapping row of small links joined by an `aria-hidden` `separator`: `·` (default), `•` or `none`; `className` goes on the row). `PageFade` (the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each a `name` (a CSS identifier) when there are two; it fades when the navigation runs in a view transition, through `startPageTransition(update)` (flushes a synchronous state change inside `document.startViewTransition`) or a router's own option such as TanStack Router `viewTransition`, and otherwise, in a browser without View Transitions, or under `prefers-reduced-motion: reduce`, it swaps with no fade; it renders a small `<style>`, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame). `Divider` (a hairline `<hr>` in the theme's line colour; spacing is the caller's, on `className`). `BulletList` (a `<ul>` with discs, an indent and a small gap; children are `<li>`s). `Code` (inline code: mono, on a subtle background). `Video` (a full-width, rounded `<video>` with controls that loads metadata only; `src` is required, `poster` and a WebVTT `captions` track are optional, children are the fallback). `Kbd` (a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too). `ToggleChip` (an on/off filter pill; `pressed` sets `aria-pressed` and the filled look, `dot` adds a leading colour dot, and the caller toggles it in `onClick`). `FilterGroup` (a `<fieldset>` with a small uppercase `legend` over a wrapped row of its children, e.g. `ToggleChip`s; `className` goes on the fieldset). `DatePicker` and `DateField` (see Dates).
50
50
  - Dates: `toISODate`, `fromISODate`, and the `CalendarDate` type.
51
51
 
52
52
  ## Dates
@@ -85,6 +85,30 @@ import { DatePicker, fromISODate, toISODate, type CalendarDate } from "@hackcove
85
85
  error once the field loses focus.
86
86
  - An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
87
87
  typed, focus leaving included, until it is fixed. `errorMessage` replaces either message.
88
+ - `error` is an error from outside the field, a server's say: it shows in place of the description,
89
+ in red with `role="alert"` (as `Field`'s `error` does), and marks the field invalid. It wins over
90
+ the built-in messages; clear it when the date changes. `isInvalid` marks the field invalid with no
91
+ message of its own; pair it with `errorMessage`.
92
+
93
+ ## Router links
94
+
95
+ `@hackcove/ui` has no router dependency. `NavTabs` (a tab), `Tabs` and `SegmentedControl` (an item with `href` or `render`), `Breadcrumbs` (a crumb), `TextLink` and
96
+ `ButtonLink` take a `render` function that draws the app's own `<Link>`, handed the component's
97
+ classes (and `aria-current` on the active tab) to spread on it. The call site carries no `className`:
98
+
99
+ ```tsx
100
+ <NavTabs
101
+ label="Main"
102
+ items={[
103
+ { label: "Quotes", active: true, render: (props) => <Link to="/quotes" {...props} /> },
104
+ { label: "Traces", href: "https://traces.example.com", external: true },
105
+ ]}
106
+ />
107
+ <ButtonLink variant="ghost" render={(props) => <Link to="/quotes" {...props} />}>Back</ButtonLink>
108
+ ```
109
+
110
+ With `render`, `href` is not used. `buttonClass`, `navTabClass`, `crumbLinkClass` and `linkClass` stay
111
+ exported for an app that would rather put the class on its own link.
88
112
 
89
113
  ## Layout
90
114
 
@@ -218,8 +242,27 @@ Two states, light and dark. The stored choice wins; with nothing stored the syst
218
242
  mode) is swallowed and the page still switches. It reads the current state from the `dark` class
219
243
  when it mounts, so it agrees with the script and renders the same on the server and the client.
220
244
 
221
- Without React, `applyColourScheme(scheme, root?)`, `readColourScheme(fallback?)` and
222
- `storeColourScheme(scheme)` are exported too; `ColourScheme` is `"light" | "dark"`.
245
+ 4. **A light theme with a class of its own, or a scheme the server knows.** An app that picks the
246
+ scheme from a cookie and whose light theme has a scope class passes both:
247
+
248
+ ```tsx
249
+ import { ColourSchemeToggle, schemeClass } from "@hackcove/ui";
250
+
251
+ const schemes = { light: "my-light", dark: "dark" }; // default: { light: "", dark: "dark" }
252
+
253
+ <html className={schemeClass(scheme, schemes)}>
254
+ <ColourSchemeToggle schemes={schemes} initial={scheme} onChange={(next) => saveCookie(next)} />
255
+ ```
256
+
257
+ Leave `colourSchemeScript` out here: it knows only the `dark` class and the stored choice, so it would fight the server. `schemes` is the class each scheme puts on `<html>`; a click swaps them. `initial` is the scheme the
258
+ server rendered: the server markup, the hydrating client and the `<html>` class (from
259
+ `schemeClass`) then agree, with no flash. Persist the choice from `onChange`; the toggle still
260
+ writes `localStorage`, which an app reading a cookie simply ignores. With neither prop nothing
261
+ changes: `dark` on and off, light on the server.
262
+
263
+ Without React, `applyColourScheme(scheme, root?, schemes?)`, `readColourScheme(fallback?)`,
264
+ `storeColourScheme(scheme)` and `schemeClass(scheme, schemes?)` are exported too; `ColourScheme` is
265
+ `"light" | "dark"` and `ColourSchemeClasses` is the `schemes` shape.
223
266
 
224
267
  ## Palette
225
268
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A small red count, 10px, for sitting on an icon. Position it from outside with `className`
3
+ * (`absolute -right-1 -top-1` over a `relative` parent); a count above 99 reads `99+`. Name the
4
+ * control it sits on (`aria-label` on the button) for a screen reader.
5
+ */
6
+ export declare function Badge({ count, className }: {
7
+ count: number;
8
+ className?: string;
9
+ }): import("react").JSX.Element;
package/dist/Badge.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * A small red count, 10px, for sitting on an icon. Position it from outside with `className`
5
+ * (`absolute -right-1 -top-1` over a `relative` parent); a count above 99 reads `99+`. Name the
6
+ * control it sits on (`aria-label` on the button) for a screen reader.
7
+ */
8
+ export function Badge({ count, className }) {
9
+ return (_jsx("span", { className: cx("inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold leading-none text-red-50", className), children: count > 99 ? "99+" : count }));
10
+ }
@@ -1,8 +1,27 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { type LinkRender } from "./link.js";
2
3
  /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
3
4
  export declare const crumbLinkClass: string;
4
- /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
5
+ /**
6
+ * A crumb that links: a plain `<a href>`, or with `render` the app's own router `<Link>`
7
+ * (`render: (p) => <Link to="/quotes" {...p} />`; `href` is then not used). `external` opens in a
8
+ * new tab. The last crumb is the current page and is never a link.
9
+ */
10
+ export type Crumb = {
11
+ label: ReactNode;
12
+ external?: boolean;
13
+ } & ({
14
+ href: string;
15
+ render?: LinkRender;
16
+ } | {
17
+ href?: string;
18
+ render: LinkRender;
19
+ });
20
+ /**
21
+ * `org / project / build / snapshot`: each crumb truncates, and the last is the current page. A
22
+ * crumb is a node, or a `Crumb` object when it links.
23
+ */
5
24
  export declare function Breadcrumbs({ crumbs, className, }: {
6
- crumbs: ReactNode[];
25
+ crumbs: Array<Crumb | ReactNode>;
7
26
  className?: string;
8
27
  }): ReactElement;
@@ -1,9 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
2
3
  import { cx, focusRing } from "./cx.js";
4
+ import { renderLink } from "./link.js";
3
5
  /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
4
6
  export const crumbLinkClass = cx("rounded text-slate-500 hover:underline", focusRing);
5
- /** `org / project / build / snapshot`: each crumb truncates, and the last is the current page. */
7
+ function isCrumb(crumb) {
8
+ return typeof crumb === "object" && crumb !== null && !isValidElement(crumb) && "label" in crumb;
9
+ }
10
+ function CrumbContent({ crumb, current, }) {
11
+ if (!isCrumb(crumb))
12
+ return crumb;
13
+ if (current)
14
+ return crumb.label;
15
+ return renderLink({
16
+ href: crumb.href,
17
+ render: crumb.render,
18
+ external: crumb.external,
19
+ className: crumbLinkClass,
20
+ children: crumb.label,
21
+ });
22
+ }
23
+ /**
24
+ * `org / project / build / snapshot`: each crumb truncates, and the last is the current page. A
25
+ * crumb is a node, or a `Crumb` object when it links.
26
+ */
6
27
  export function Breadcrumbs({ crumbs, className, }) {
7
28
  const last = crumbs.length - 1;
8
- return (_jsx("nav", { "aria-label": "Breadcrumb", className: cx("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 items-center gap-2 text-sm", children: crumbs.map((crumb, index) => (_jsxs("li", { "aria-current": index === last ? "page" : undefined, className: "flex min-w-0 items-center gap-2", children: [index > 0 && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400", children: "/" })), _jsx("span", { className: cx("min-w-0 truncate", index === last ? "font-medium text-slate-900" : "text-slate-500"), children: crumb })] }, index))) }) }));
29
+ return (_jsx("nav", { "aria-label": "Breadcrumb", className: cx("min-w-0", className), children: _jsx("ol", { className: "flex min-w-0 items-center gap-2 text-sm", children: crumbs.map((crumb, index) => (_jsxs("li", { "aria-current": index === last ? "page" : undefined, className: "flex min-w-0 items-center gap-2", children: [index > 0 && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400", children: "/" })), _jsx("span", { className: cx("min-w-0 truncate", index === last ? "font-medium text-slate-900" : "text-slate-500"), children: _jsx(CrumbContent, { crumb: crumb, current: index === last }) })] }, index))) }) }));
9
30
  }
package/dist/Button.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
- export type ButtonVariant = "primary" | "outline" | "ghost";
2
+ export type ButtonVariant = "primary" | "outline" | "ghost" | "link";
3
+ export type ButtonSize = "md" | "sm";
4
+ export type ButtonTone = "default" | "danger";
5
+ /** Size, tone and variant: the look of a button, apart from its state. */
6
+ export type ButtonLook = {
7
+ variant?: ButtonVariant | undefined;
8
+ /** `sm` is compact, for table rows and toolbars. */
9
+ size?: ButtonSize | undefined;
10
+ /** `danger` is red, for delete and clear. */
11
+ tone?: ButtonTone | undefined;
12
+ };
3
13
  /** The classes of a Button, for a router's own <Link> that should look like one. */
4
- export declare function buttonClass(variant?: ButtonVariant): string;
5
- type ButtonProps = {
6
- variant?: ButtonVariant;
14
+ export declare function buttonClass(variant?: ButtonVariant, { size, tone }?: Omit<ButtonLook, "variant">): string;
15
+ type ButtonProps = ButtonLook & {
7
16
  /** Why the button cannot be pressed: disables it, sets it as the `title`, and greys it flat. */
8
17
  disabledReason?: string | undefined;
9
18
  /** The work is in flight: disabled, `aria-busy`, keeps its colour. Wins over `disabledReason`. */
@@ -12,9 +21,10 @@ type ButtonProps = {
12
21
  busyLabel?: string | undefined;
13
22
  } & ComponentPropsWithoutRef<"button">;
14
23
  /**
15
- * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
16
- * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
17
- * half-opacity look.
24
+ * A button in the primary, outline, ghost or link style (the link has no box: text with an
25
+ * underline on hover, for an in-line action). `size="sm"` is compact; `tone="danger"` is red.
26
+ * Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
27
+ * more than a plain `disabled`, which keeps its half-opacity look.
18
28
  */
19
- export declare function Button({ variant, disabledReason, busy, busyLabel, className, children, title, disabled, ...rest }: ButtonProps): ReactElement;
29
+ export declare function Button({ variant, size, tone, disabledReason, busy, busyLabel, className, children, title, disabled, ...rest }: ButtonProps): ReactElement;
20
30
  export {};
package/dist/Button.js CHANGED
@@ -1,39 +1,71 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
- const SHAPE = "inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition";
3
+ const SHAPE = "inline-flex items-center justify-center rounded-lg border font-medium transition";
4
+ // Size sets the padding and the text. The link variant has no box, so it takes the text size only.
5
+ const SIZE = {
6
+ md: { box: "gap-2 px-4 py-2", text: "text-sm" },
7
+ sm: { box: "gap-1.5 px-2.5 py-1", text: "text-xs" },
8
+ };
4
9
  // A plain `disabled` washes out and ignores the pointer. Blocked and busy buttons are disabled too,
5
10
  // but they keep pointer events (the title shows, the cursor says why) and do not take this.
6
11
  const DISABLED_PLAIN = "disabled:pointer-events-none disabled:opacity-50";
7
- const VARIANT = {
8
- primary: {
9
- color: "border-violet-600 bg-violet-600 text-violet-50",
10
- hover: "hover:bg-violet-700",
12
+ const LINK = "underline-offset-2";
13
+ const TONE = {
14
+ default: {
15
+ primary: {
16
+ color: "border-violet-600 bg-violet-600 text-violet-50",
17
+ hover: "hover:bg-violet-700",
18
+ },
19
+ outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
20
+ ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
21
+ link: {
22
+ color: `border-transparent text-violet-600 ${LINK}`,
23
+ hover: "hover:text-violet-700 hover:underline",
24
+ },
25
+ },
26
+ danger: {
27
+ primary: { color: "border-red-600 bg-red-600 text-red-50", hover: "hover:bg-red-700" },
28
+ outline: { color: "border-red-200 text-red-700", hover: "hover:bg-red-50" },
29
+ ghost: { color: "border-transparent text-red-700", hover: "hover:bg-red-50" },
30
+ link: { color: `border-transparent text-red-700 ${LINK}`, hover: "hover:underline" },
11
31
  },
12
- outline: { color: "border-violet-600 text-violet-600", hover: "hover:bg-violet-50" },
13
- ghost: { color: "border-transparent text-slate-600", hover: "hover:bg-slate-100" },
14
32
  };
15
33
  // Blocked replaces the variant's colour and hover rather than overriding them, so no `!` is needed.
16
34
  const BLOCKED = "cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400";
35
+ const BLOCKED_LINK = "cursor-not-allowed border-transparent text-slate-400";
17
36
  const BUSY = "cursor-progress opacity-85";
37
+ function shape(variant, size) {
38
+ const { box, text } = SIZE[size];
39
+ return cx(SHAPE, variant !== "link" && box, variant === "link" && "gap-1.5", text, focusRing);
40
+ }
18
41
  /** The classes of a Button, for a router's own <Link> that should look like one. */
19
- export function buttonClass(variant = "primary") {
20
- return cx(SHAPE, DISABLED_PLAIN, focusRing, VARIANT[variant].color, VARIANT[variant].hover);
42
+ export function buttonClass(variant = "primary", { size = "md", tone = "default" } = {}) {
43
+ const look = TONE[tone][variant];
44
+ return cx(shape(variant, size), DISABLED_PLAIN, look.color, look.hover);
21
45
  }
22
- /** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
23
- function stateClass(variant, blocked, busy) {
24
- const { color } = VARIANT[variant];
25
- if (blocked)
26
- return cx(SHAPE, focusRing, BLOCKED);
46
+ /** Busy wins over blocked. */
47
+ function stateOf(busy, disabledReason) {
27
48
  if (busy)
28
- return cx(SHAPE, focusRing, color, BUSY);
29
- return buttonClass(variant);
49
+ return "busy";
50
+ return disabledReason === undefined ? "idle" : "blocked";
51
+ }
52
+ /** The classes of a Button in a given state: blocked swaps the colour out, busy keeps it. */
53
+ function stateClass({ variant = "primary", size = "md", tone = "default" }, state) {
54
+ if (state === "blocked") {
55
+ return cx(shape(variant, size), variant === "link" ? BLOCKED_LINK : BLOCKED);
56
+ }
57
+ if (state === "busy")
58
+ return cx(shape(variant, size), TONE[tone][variant].color, BUSY);
59
+ return buttonClass(variant, { size, tone });
30
60
  }
31
61
  /**
32
- * A button in the primary, outline or ghost style. Defaults to `type="button"`. `disabledReason`
33
- * and `busy` are the two disabled states that say more than a plain `disabled`, which keeps its
34
- * half-opacity look.
62
+ * A button in the primary, outline, ghost or link style (the link has no box: text with an
63
+ * underline on hover, for an in-line action). `size="sm"` is compact; `tone="danger"` is red.
64
+ * Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
65
+ * more than a plain `disabled`, which keeps its half-opacity look.
35
66
  */
36
- export function Button({ variant = "primary", disabledReason, busy = false, busyLabel, className, children, title, disabled, ...rest }) {
37
- const blocked = disabledReason !== undefined && !busy;
38
- return (_jsx("button", { type: "button", className: cx(stateClass(variant, blocked, busy), className), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
67
+ export function Button({ variant, size, tone, disabledReason, busy = false, busyLabel, className, children, title, disabled, ...rest }) {
68
+ const state = stateOf(busy, disabledReason);
69
+ const blocked = state === "blocked";
70
+ return (_jsx("button", { type: "button", className: cx(stateClass({ variant, size, tone }, state), className), disabled: disabled || busy || blocked, "aria-busy": busy || undefined, title: blocked ? disabledReason : title, ...rest, children: busy && busyLabel ? busyLabel : children }));
39
71
  }
@@ -0,0 +1,14 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ /**
3
+ * A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
4
+ * narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
5
+ * named `role="group"`.
6
+ */
7
+ export declare function ButtonGroup({ children, end, label, className, }: {
8
+ children: ReactNode;
9
+ /** One item, pushed to the end of the row. */
10
+ end?: ReactNode;
11
+ /** The accessible name of the group: sets `role="group"` and `aria-label`. */
12
+ label?: string;
13
+ className?: string;
14
+ }): ReactElement;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /**
4
+ * A wrapping row of buttons with one optional item pushed to the far end (a Back link, say). On a
5
+ * narrow viewport the row wraps and `end` stays at the end of the last line. `label` makes it a
6
+ * named `role="group"`.
7
+ */
8
+ export function ButtonGroup({ children, end, label, className, }) {
9
+ return (_jsxs("div", { ...(label === undefined ? {} : { role: "group", "aria-label": label }), className: cx("flex flex-wrap items-center gap-2", className), children: [children, end ? _jsx("div", { className: "ml-auto", children: end }) : null] }));
10
+ }
@@ -0,0 +1,20 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ import { type ButtonLook } from "./Button.js";
3
+ import { type LinkRender } from "./link.js";
4
+ type ButtonLinkProps = ButtonLook & {
5
+ /** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
6
+ external?: boolean | undefined;
7
+ } & Omit<ComponentPropsWithoutRef<"a">, "href"> & ({
8
+ href: string;
9
+ render?: LinkRender | undefined;
10
+ } | {
11
+ href?: string | undefined;
12
+ render: LinkRender;
13
+ });
14
+ /**
15
+ * A link that looks like a Button. A plain `<a href>` by default; with `render` it draws the app's
16
+ * own router `<Link>` instead (`render={(p) => <Link to="/quotes" {...p} />}`), already carrying the
17
+ * button classes. `variant`, `size` and `tone` are the Button's.
18
+ */
19
+ export declare function ButtonLink({ variant, size, tone, href, render, external, className, children, ...rest }: ButtonLinkProps): ReactElement;
20
+ export {};
@@ -0,0 +1,18 @@
1
+ import { buttonClass } from "./Button.js";
2
+ import { cx } from "./cx.js";
3
+ import { renderLink } from "./link.js";
4
+ /**
5
+ * A link that looks like a Button. A plain `<a href>` by default; with `render` it draws the app's
6
+ * own router `<Link>` instead (`render={(p) => <Link to="/quotes" {...p} />}`), already carrying the
7
+ * button classes. `variant`, `size` and `tone` are the Button's.
8
+ */
9
+ export function ButtonLink({ variant = "primary", size = "md", tone = "default", href, render, external = false, className, children, ...rest }) {
10
+ return renderLink({
11
+ ...rest,
12
+ href,
13
+ render,
14
+ external,
15
+ className: cx(buttonClass(variant, { size, tone }), className),
16
+ children,
17
+ });
18
+ }
package/dist/Callout.d.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
2
  /** The colour family of a callout. */
3
3
  export type CalloutTone = "neutral" | "info" | "positive" | "warn" | "danger";
4
- /** A tinted notice box, with an optional bold title above its body. */
5
- export declare function Callout({ tone, title, children, className, ...rest }: {
4
+ /**
5
+ * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
6
+ * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
7
+ * top of a `Card flush` (a padded card would inset it).
8
+ */
9
+ export declare function Callout({ tone, title, actions, banner, children, className, ...rest }: {
6
10
  /** Defaults to "info". */
7
11
  tone?: CalloutTone;
8
12
  title?: ReactNode;
13
+ /** A button or link at the end of the row, beside the title and body. */
14
+ actions?: ReactNode;
15
+ /** A flat strip: `border-b` only and no radius, to sit above a card body. */
16
+ banner?: boolean;
9
17
  children: ReactNode;
10
18
  } & Omit<HTMLAttributes<HTMLDivElement>, "title">): ReactElement;
package/dist/Callout.js CHANGED
@@ -7,7 +7,12 @@ const TONES = {
7
7
  warn: "border-amber-200 bg-amber-50 text-amber-800",
8
8
  danger: "border-red-200 bg-red-50 text-red-800",
9
9
  };
10
- /** A tinted notice box, with an optional bold title above its body. */
11
- export function Callout({ tone = "info", title, children, className, ...rest }) {
12
- return (_jsx("div", { className: cx("rounded-xl border p-4 text-sm", TONES[tone], className), ...rest, children: title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children) }));
10
+ /**
11
+ * A tinted notice box, with an optional bold title above its body. `actions` puts a button or link
12
+ * at the end of the row; `banner` makes it a flat strip (bottom border only, no radius) for the
13
+ * top of a `Card flush` (a padded card would inset it).
14
+ */
15
+ export function Callout({ tone = "info", title, actions, banner = false, children, className, ...rest }) {
16
+ const content = title ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-semibold", children: title }), _jsx("div", { className: "mt-1", children: children })] })) : (children);
17
+ return (_jsx("div", { className: cx(banner ? "border-b px-5 py-3 text-sm" : "rounded-xl border p-4 text-sm", TONES[tone], className), ...rest, children: actions ? (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("div", { className: "min-w-0", children: content }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: actions })] })) : (content) }));
13
18
  }
@@ -1,5 +1,11 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
- /** A native checkbox with its label. `className` goes on the label; every other prop goes on the input. */
3
- export declare function Checkbox({ label, className, ...rest }: {
4
- label: ReactNode;
5
- } & Omit<ComponentPropsWithoutRef<"input">, "type">): ReactElement;
2
+ import type { ControlSize } from "./control.js";
3
+ /**
4
+ * A native checkbox with its label. `className` goes on the label, every other prop on the input.
5
+ * `size="sm"` is `text-xs` and a smaller box, for a dense row. Leave `label` out for a bare box in
6
+ * a table cell, and name it with `aria-label`.
7
+ */
8
+ export declare function Checkbox({ label, size, className, ...rest }: {
9
+ label?: ReactNode;
10
+ size?: ControlSize | undefined;
11
+ } & Omit<ComponentPropsWithoutRef<"input">, "type" | "size" | "width">): ReactElement;
package/dist/Checkbox.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
- /** A native checkbox with its label. `className` goes on the label; every other prop goes on the input. */
4
- export function Checkbox({ label, className, ...rest }) {
5
- return (_jsxs("label", { className: cx("inline-flex cursor-pointer items-center gap-2 text-sm text-slate-700", className), children: [_jsx("input", { type: "checkbox", className: cx("h-4 w-4 accent-violet-600", focusRing), ...rest }), label] }));
3
+ /**
4
+ * A native checkbox with its label. `className` goes on the label, every other prop on the input.
5
+ * `size="sm"` is `text-xs` and a smaller box, for a dense row. Leave `label` out for a bare box in
6
+ * a table cell, and name it with `aria-label`.
7
+ */
8
+ export function Checkbox({ label, size = "md", className, ...rest }) {
9
+ const small = size === "sm";
10
+ return (_jsxs("label", { className: cx("inline-flex cursor-pointer items-center text-slate-700", small ? "gap-1.5 text-xs" : "gap-2 text-sm", className), children: [_jsx("input", { type: "checkbox", className: cx(small ? "h-3.5 w-3.5" : "h-4 w-4", "accent-violet-600", focusRing), ...rest }), label] }));
6
11
  }
@@ -1,7 +1,18 @@
1
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, }: {
2
+ import { type ColourScheme, type ColourSchemeClasses } from "./colourScheme.js";
3
+ /**
4
+ * A sun/moon button that switches `<html>` between light and dark and remembers the choice. It shows the scheme you would switch to. Pair it with `colourSchemeScript` in `<head>`, unless you pass `schemes` or `initial`: the script knows only the `dark` class and the stored choice, so an app that sets the scheme itself leaves it out.
5
+ *
6
+ * An app whose light scheme has a class of its own passes `schemes` (`{ light: "paper", dark: "dark" }`);
7
+ * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
8
+ * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
9
+ * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
10
+ */
11
+ export declare function ColourSchemeToggle({ className, onChange, schemes, initial, }: {
5
12
  className?: string;
6
13
  onChange?: (scheme: ColourScheme) => void;
14
+ /** The class each scheme puts on `<html>`. Default `{ light: "", dark: "dark" }`. */
15
+ schemes?: ColourSchemeClasses | undefined;
16
+ /** The scheme the server rendered. What the server and the hydrating client show; the real state follows right after. Default `"light"`. */
17
+ initial?: ColourScheme | undefined;
7
18
  }): ReactElement;
@@ -1,24 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
- import { applyColourScheme, storeColourScheme } from "./colourScheme.js";
3
+ import { appliedColourScheme, applyColourScheme, storeColourScheme, } from "./colourScheme.js";
4
4
  import { IconButton } from "./IconButton.js";
5
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
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.
7
+ // The scheme's class on <html> is the source of truth: the head script or the server set it before React ran.
8
8
  function subscribe(onChange) {
9
9
  const observer = new MutationObserver(onChange);
10
10
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
11
11
  return () => observer.disconnect();
12
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);
13
+ /**
14
+ * A sun/moon button that switches `<html>` between light and dark and remembers the choice. It shows the scheme you would switch to. Pair it with `colourSchemeScript` in `<head>`, unless you pass `schemes` or `initial`: the script knows only the `dark` class and the stored choice, so an app that sets the scheme itself leaves it out.
15
+ *
16
+ * An app whose light scheme has a class of its own passes `schemes` (`{ light: "paper", dark: "dark" }`);
17
+ * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
18
+ * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
19
+ * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
20
+ */
21
+ export function ColourSchemeToggle({ className, onChange, schemes = { light: "", dark: "dark" }, initial, }) {
22
+ const scheme = useSyncExternalStore(subscribe, () => appliedColourScheme(document.documentElement, schemes), () => initial ?? "light");
19
23
  const next = scheme === "dark" ? "light" : "dark";
20
24
  return (_jsx(IconButton, { label: `Switch to ${next} theme`, className: className, onClick: () => {
21
- applyColourScheme(next);
25
+ applyColourScheme(next, document.documentElement, schemes);
22
26
  storeColourScheme(next);
23
27
  onChange?.(next);
24
28
  }, children: next === "light" ? sun : moon }));
@@ -14,6 +14,10 @@ export type DateFieldProps = {
14
14
  description?: ReactNode;
15
15
  /** Replaces the built-in error message (an impossible date, or one outside min/max). */
16
16
  errorMessage?: ReactNode;
17
+ /** An error from outside the field (a server's, say). Shown in place of the description, in red with `role="alert"`, and marks the field invalid. Like `Field`'s `error`. */
18
+ error?: ReactNode;
19
+ /** Marks the field invalid with no message of its own; pair it with `errorMessage`. */
20
+ isInvalid?: boolean;
17
21
  className?: string;
18
22
  } & Pick<AriaDateFieldProps<CalendarDate>, "value" | "defaultValue" | "onChange" | "minValue" | "maxValue" | "isDisabled" | "isRequired" | "name" | "aria-label">;
19
23
  /** What the segments show, as the day segment reports it to the field. */
@@ -35,15 +39,24 @@ export declare function frameClass({ isInvalid, isFocusWithin, isDisabled, }: {
35
39
  isFocusWithin: boolean;
36
40
  isDisabled: boolean;
37
41
  }): string;
42
+ /** The props that force a field invalid: an impossible date, `isInvalid`, or an `error` from outside. Spread onto the React Aria field after the rest. */
43
+ export declare function forcedInvalid({ impossible, isInvalid, error, }: {
44
+ impossible: string | null;
45
+ isInvalid: boolean | undefined;
46
+ error: ReactNode;
47
+ }): {
48
+ isInvalid?: true;
49
+ };
38
50
  /** The label, the description or the error line: the shared outside of both components. */
39
- export declare function Notes({ description, errorMessage, impossible, isInvalid, }: {
51
+ export declare function Notes({ description, errorMessage, error, impossible, isInvalid, }: {
40
52
  description: ReactNode;
41
53
  errorMessage: ReactNode;
54
+ error: ReactNode;
42
55
  impossible: string | null;
43
56
  isInvalid: boolean;
44
57
  }): ReactElement;
45
58
  export declare const labelClass = "text-sm font-medium text-slate-700";
46
59
  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
60
  /** 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;
61
+ export declare function DateField({ label, description, errorMessage, error, isInvalid: invalidProp, className, value, defaultValue, onChange, name, ...rest }: DateFieldProps): ReactElement;
49
62
  export {};
package/dist/DateField.js CHANGED
@@ -57,14 +57,18 @@ export function frameClass({ isInvalid, isFocusWithin, isDisabled, }) {
57
57
  tone = "border-violet-600 bg-white text-slate-900";
58
58
  return cx("flex w-full items-center rounded-lg border py-2 pl-2.5 text-sm transition", tone, isDisabled && "opacity-50");
59
59
  }
60
+ /** The props that force a field invalid: an impossible date, `isInvalid`, or an `error` from outside. Spread onto the React Aria field after the rest. */
61
+ export function forcedInvalid({ impossible, isInvalid, error, }) {
62
+ return impossible || isInvalid || error ? { isInvalid: true } : {};
63
+ }
60
64
  /** 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(" ") })] }));
65
+ export function Notes({ description, errorMessage, error, impossible, isInvalid, }) {
66
+ 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 }) => error ? (_jsx("span", { role: "alert", children: error })) : ((errorMessage ?? impossible ?? validationErrors.join(" "))) })] }));
63
67
  }
64
68
  export const labelClass = "text-sm font-medium text-slate-700";
65
69
  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
70
  /** 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 }) {
71
+ export function DateField({ label, description, errorMessage, error, isInvalid: invalidProp, className, value, defaultValue, onChange, name, ...rest }) {
68
72
  const { inner, setInner, shown, impossible, onRead, validate, leaving, onBlurCapture, onLeft } = useShownDate({
69
73
  value,
70
74
  defaultValue,
@@ -73,5 +77,5 @@ export function DateField({ label, description, errorMessage, className, value,
73
77
  maxValue: rest.maxValue,
74
78
  isRequired: rest.isRequired,
75
79
  });
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 })] })) }) }));
80
+ return (_jsx(I18nProvider, { locale: LOCALE, children: _jsx(AriaDateField, { ...rest, value: inner, onChange: setInner, validate: validate, ...forcedInvalid({ impossible, isInvalid: invalidProp, error }), 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, error: error, impossible: impossible, isInvalid: isInvalid })] })) }) }));
77
81
  }