@hackcove/ui 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +83 -13
  2. package/dist/AppShell.d.ts +19 -4
  3. package/dist/AppShell.js +45 -3
  4. package/dist/Avatar.d.ts +9 -4
  5. package/dist/Avatar.js +9 -3
  6. package/dist/Badge.d.ts +9 -0
  7. package/dist/Badge.js +10 -0
  8. package/dist/Breadcrumbs.d.ts +22 -3
  9. package/dist/Breadcrumbs.js +23 -2
  10. package/dist/BulletList.d.ts +3 -0
  11. package/dist/BulletList.js +6 -0
  12. package/dist/Button.d.ts +18 -8
  13. package/dist/Button.js +54 -22
  14. package/dist/ButtonGroup.d.ts +14 -0
  15. package/dist/ButtonGroup.js +10 -0
  16. package/dist/ButtonLink.d.ts +20 -0
  17. package/dist/ButtonLink.js +18 -0
  18. package/dist/Callout.d.ts +10 -2
  19. package/dist/Callout.js +8 -3
  20. package/dist/Checkbox.d.ts +10 -4
  21. package/dist/Checkbox.js +8 -3
  22. package/dist/Code.d.ts +3 -0
  23. package/dist/Code.js +6 -0
  24. package/dist/ColourSchemeToggle.d.ts +14 -3
  25. package/dist/ColourSchemeToggle.js +13 -9
  26. package/dist/Container.d.ts +3 -1
  27. package/dist/Container.js +2 -2
  28. package/dist/DateField.d.ts +15 -2
  29. package/dist/DateField.js +8 -4
  30. package/dist/DatePicker.d.ts +1 -1
  31. package/dist/DatePicker.js +3 -3
  32. package/dist/Divider.d.ts +3 -0
  33. package/dist/Divider.js +6 -0
  34. package/dist/Drawer.d.ts +29 -0
  35. package/dist/Drawer.js +26 -0
  36. package/dist/FileDrop.d.ts +10 -0
  37. package/dist/FileDrop.js +11 -0
  38. package/dist/InlineLinks.d.ts +11 -0
  39. package/dist/InlineLinks.js +11 -0
  40. package/dist/Input.d.ts +3 -2
  41. package/dist/Input.js +5 -4
  42. package/dist/KeyValueList.d.ts +11 -4
  43. package/dist/KeyValueList.js +14 -5
  44. package/dist/ListRow.d.ts +36 -0
  45. package/dist/ListRow.js +31 -0
  46. package/dist/Markdown.js +1 -1
  47. package/dist/NavTabs.d.ts +15 -4
  48. package/dist/NavTabs.js +13 -2
  49. package/dist/PageFade.d.ts +25 -0
  50. package/dist/PageFade.js +39 -0
  51. package/dist/PageLayouts.d.ts +22 -1
  52. package/dist/PageLayouts.js +20 -2
  53. package/dist/ProgressBar.d.ts +26 -0
  54. package/dist/ProgressBar.js +32 -0
  55. package/dist/SegmentedControl.d.ts +11 -0
  56. package/dist/SegmentedControl.js +22 -0
  57. package/dist/Select.d.ts +3 -2
  58. package/dist/Select.js +4 -4
  59. package/dist/SplitBar.d.ts +21 -0
  60. package/dist/SplitBar.js +15 -0
  61. package/dist/Stack.d.ts +18 -6
  62. package/dist/Stack.js +20 -6
  63. package/dist/StatTile.d.ts +16 -0
  64. package/dist/StatTile.js +21 -0
  65. package/dist/Table.d.ts +48 -0
  66. package/dist/Table.js +42 -0
  67. package/dist/Tabs.d.ts +13 -0
  68. package/dist/Tabs.js +23 -0
  69. package/dist/TextLink.d.ts +27 -0
  70. package/dist/TextLink.js +23 -0
  71. package/dist/Textarea.d.ts +3 -2
  72. package/dist/Textarea.js +4 -4
  73. package/dist/Timeline.d.ts +21 -0
  74. package/dist/Timeline.js +19 -0
  75. package/dist/Video.d.ts +10 -0
  76. package/dist/Video.js +10 -0
  77. package/dist/colourScheme.d.ts +11 -2
  78. package/dist/colourScheme.js +16 -3
  79. package/dist/control.d.ts +19 -2
  80. package/dist/control.js +25 -2
  81. package/dist/index.d.ts +29 -7
  82. package/dist/index.js +24 -3
  83. package/dist/link.d.ts +22 -0
  84. package/dist/link.js +11 -0
  85. package/dist/switcher.d.ts +66 -0
  86. package/dist/switcher.js +53 -0
  87. package/dist/typography.d.ts +32 -24
  88. package/dist/typography.js +39 -9
  89. package/dist/ui.d.ts +25 -5
  90. package/dist/ui.js +51 -7
  91. package/package.json +19 -1
package/dist/Markdown.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- // A minimal, SAFE markdown renderer for the standup (headings / bullets / bold). No
2
+ // A minimal, SAFE markdown renderer (headings / bullets / bold). No
3
3
  // dangerouslySetInnerHTML — text is rendered as React nodes, so it can't inject HTML.
4
4
  function inline(text) {
5
5
  return text.split(/(\*\*[^*]+\*\*)/g).map((p, i) => p.startsWith("**") && p.endsWith("**") ? (_jsx("strong", { className: "font-semibold text-slate-900", children: p.slice(2, -2) }, i)) : (_jsx("span", { children: p }, i)));
package/dist/NavTabs.d.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
- /** One pill link in a NavTabs row. */
2
+ import { type LinkRender } from "./link.js";
3
+ /**
4
+ * One pill link in a NavTabs row: a plain `<a href>`, or with `render` the app's own router
5
+ * `<Link>` (`render: (p) => <Link to="/stores" {...p} />`, which gets the tab's classes and
6
+ * `aria-current`; `href` is then not used). `external` opens in a new tab.
7
+ */
3
8
  export type NavTab = {
4
9
  label: ReactNode;
5
- href: string;
6
10
  active?: boolean;
7
- };
11
+ external?: boolean;
12
+ } & ({
13
+ href: string;
14
+ render?: LinkRender;
15
+ } | {
16
+ href?: string;
17
+ render: LinkRender;
18
+ });
8
19
  /** The classes of one tab, for a router's own <Link>. */
9
20
  export declare function navTabClass(active: boolean): string;
10
- /** A row of pill links where the active one is filled. Renders plain <a>; use navTabClass with a router's Link. */
21
+ /** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
11
22
  export declare function NavTabs({ items, label, className, }: {
12
23
  items: NavTab[];
13
24
  /** The accessible name of the nav landmark. */
package/dist/NavTabs.js CHANGED
@@ -1,11 +1,22 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx, focusRing } from "./cx.js";
3
+ import { renderLink } from "./link.js";
3
4
  const BASE = `rounded-lg px-3 py-1.5 transition ${focusRing}`;
4
5
  /** The classes of one tab, for a router's own <Link>. */
5
6
  export function navTabClass(active) {
6
7
  return cx(BASE, active ? "bg-slate-100 font-medium text-slate-900" : "text-slate-600 hover:bg-slate-100");
7
8
  }
8
- /** A row of pill links where the active one is filled. Renders plain <a>; use navTabClass with a router's Link. */
9
+ /** A row of pill links where the active one is filled. Plain <a> by default; a tab's `render` draws a router's Link. */
9
10
  export function NavTabs({ items, label, className, }) {
10
- return (_jsx("nav", { "aria-label": label, className: cx("flex flex-wrap gap-1 text-sm", className), children: items.map((item) => (_jsx("a", { href: item.href, className: navTabClass(!!item.active), "aria-current": item.active ? "page" : undefined, children: item.label }, item.href))) }));
11
+ return (_jsx("nav", { "aria-label": label, className: cx("flex flex-wrap gap-1 text-sm", className), children: items.map((item, index) => (_jsx(Tab, { item: item }, item.href ?? index))) }));
12
+ }
13
+ function Tab({ item }) {
14
+ return renderLink({
15
+ href: item.href,
16
+ render: item.render,
17
+ external: item.external,
18
+ className: navTabClass(!!item.active),
19
+ "aria-current": item.active ? "page" : undefined,
20
+ children: item.label,
21
+ });
11
22
  }
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
+ /**
3
+ * The page's content area, named for the View Transitions API so a route change cross-fades it
4
+ * while the frame around it (header, footer) stays put. Wrap what changes between pages, once per
5
+ * page: two on one page need a different `name` each, or the browser skips the transition.
6
+ * The fade runs only when the navigation is wrapped in a view transition: `startPageTransition`,
7
+ * or a router's own option (TanStack Router `viewTransition`, React Router's `viewTransition` prop,
8
+ * Astro's `ClientRouter`). Otherwise, or where the browser has no View Transitions, it renders
9
+ * and behaves as a plain `<div>`. A `view-transition-name` makes the div a stacking context, so a
10
+ * dropdown inside it cannot rise above a sticky header outside it: portal overlays to the body. Under `prefers-reduced-motion: reduce` this content swaps with no fade; a router's own
11
+ * transition of the rest of the page is the router's to quiet.
12
+ */
13
+ export declare function PageFade({ name, className, children, ...rest }: {
14
+ /** The view-transition name, a CSS identifier (letters, digits, `-`, `_`). */
15
+ name?: string;
16
+ children: ReactNode;
17
+ } & HTMLAttributes<HTMLDivElement>): ReactElement;
18
+ /**
19
+ * Run `update` inside a view transition, so a `PageFade` cross-fades. `update` is a synchronous
20
+ * state change, flushed at once as the browser needs the new DOM; for a router's navigation, which
21
+ * is async, use the router's own view-transition option instead. Where the browser has
22
+ * no View Transitions, or the user prefers reduced motion, `update` just runs. Call it from an event
23
+ * handler, not during render.
24
+ */
25
+ export declare function startPageTransition(update: () => void): void;
@@ -0,0 +1,39 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { flushSync } from "react-dom";
3
+ const DEFAULT_NAME = "hc-page";
4
+ // The one place the View Transitions pseudo-elements can be styled: a stylesheet, as Tailwind
5
+ // classes cannot reach `::view-transition-*`. The browser's own cross-fade does the fading; this
6
+ // names the element and, under reduced motion, switches the animations off so the content swaps.
7
+ // No colour in it. Where the API is missing the rules are never matched.
8
+ const css = (name) => `[data-page-fade="${name}"]{view-transition-name:${name}}
9
+ @media (prefers-reduced-motion:reduce){::view-transition-group(${name}),::view-transition-old(${name}),::view-transition-new(${name}){animation:none}}`;
10
+ /**
11
+ * The page's content area, named for the View Transitions API so a route change cross-fades it
12
+ * while the frame around it (header, footer) stays put. Wrap what changes between pages, once per
13
+ * page: two on one page need a different `name` each, or the browser skips the transition.
14
+ * The fade runs only when the navigation is wrapped in a view transition: `startPageTransition`,
15
+ * or a router's own option (TanStack Router `viewTransition`, React Router's `viewTransition` prop,
16
+ * Astro's `ClientRouter`). Otherwise, or where the browser has no View Transitions, it renders
17
+ * and behaves as a plain `<div>`. A `view-transition-name` makes the div a stacking context, so a
18
+ * dropdown inside it cannot rise above a sticky header outside it: portal overlays to the body. Under `prefers-reduced-motion: reduce` this content swaps with no fade; a router's own
19
+ * transition of the rest of the page is the router's to quiet.
20
+ */
21
+ export function PageFade({ name = DEFAULT_NAME, className, children, ...rest }) {
22
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: css(name) }), _jsx("div", { "data-page-fade": name, className: className, ...rest, children: children })] }));
23
+ }
24
+ /**
25
+ * Run `update` inside a view transition, so a `PageFade` cross-fades. `update` is a synchronous
26
+ * state change, flushed at once as the browser needs the new DOM; for a router's navigation, which
27
+ * is async, use the router's own view-transition option instead. Where the browser has
28
+ * no View Transitions, or the user prefers reduced motion, `update` just runs. Call it from an event
29
+ * handler, not during render.
30
+ */
31
+ export function startPageTransition(update) {
32
+ const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
33
+ if (reduce || typeof document.startViewTransition !== "function") {
34
+ update();
35
+ return;
36
+ }
37
+ // A skipped transition (a second one starting, a hidden tab) rejects `ready`; the update still ran.
38
+ document.startViewTransition(() => flushSync(update)).ready.catch(() => { });
39
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
2
  import { type PageHeaderProps } from "./PageHeader.js";
3
+ import { type StackGap } from "./Stack.js";
3
4
  /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
4
5
  export declare function ListLayout({ filters, empty, isEmpty, children, ...header }: PageHeaderProps & {
5
6
  filters?: ReactNode;
@@ -18,6 +19,17 @@ export declare function FormLayout({ actions, children, breadcrumbs, title, subt
18
19
  actions?: ReactNode;
19
20
  children: ReactNode;
20
21
  } & Omit<ComponentPropsWithoutRef<"form">, "title">): ReactElement;
22
+ /**
23
+ * A text page: header, then `children` stacked `gap` apart (`md` by default), all in one `narrow`
24
+ * (`max-w-3xl`) reading column that starts at the Container's left edge (it is not re-centred, so
25
+ * the title does not jump). Unlike `FormLayout` the header sits inside the column, so its `actions`
26
+ * stay beside the text instead of at the Container's far edge. `className` goes on the column.
27
+ */
28
+ export declare function ProseLayout({ gap, className, children, ...header }: PageHeaderProps & {
29
+ gap?: StackGap | undefined;
30
+ className?: string;
31
+ children?: ReactNode;
32
+ }): ReactElement;
21
33
  declare const COLUMNS: {
22
34
  readonly 2: "lg:grid-cols-2";
23
35
  readonly 3: "lg:grid-cols-3";
@@ -33,12 +45,21 @@ export declare function CardGrid({ columns, className, children, }: {
33
45
  * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
34
46
  * the shell's flex column leaves under the header, so no header height is written down. The rail is
35
47
  * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
48
+ *
49
+ * It deliberately breaks the shared left edge from `md` up: a full-height rail needs the viewport's
50
+ * left edge, so the rail starts at x=0 and the canvas at the rail's width plus the Container's
51
+ * padding (264 at 1280), not at the header logo's x. Below `md` every layout agrees.
36
52
  */
37
53
  export declare function SidebarLayout({ rail, children, }: {
38
54
  rail: ReactNode;
39
55
  children: ReactNode;
40
56
  }): 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>`. */
57
+ /**
58
+ * Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller
59
+ * screen. Render it in `<AppShell bleed>`. It deliberately breaks the shared left edge: a viewer
60
+ * that needs the whole width has no Container of its own, so `children` start at the viewport's
61
+ * left edge and add their own padding.
62
+ */
42
63
  export declare function FullBleedLayout({ panel, children, }: {
43
64
  panel?: ReactNode;
44
65
  children: ReactNode;
@@ -3,7 +3,7 @@ import { Card } from "./ui.js";
3
3
  import { Container } from "./Container.js";
4
4
  import { cx } from "./cx.js";
5
5
  import { PageHeader, PageHeaderContent } from "./PageHeader.js";
6
- import { Stack } from "./Stack.js";
6
+ import { STACK_WIDTH, Stack } from "./Stack.js";
7
7
  // Compositions of the layout foundation. None sets a page width or outer padding of its own; the
8
8
  // ones that narrow their content do it inside the Container they are rendered in.
9
9
  /** A list page: header, an optional `filters` slot, then the list, or `empty` when `isEmpty`. */
@@ -18,6 +18,15 @@ export function DetailLayout({ aside, children, ...header }) {
18
18
  export function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, className, ...form }) {
19
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
20
  }
21
+ /**
22
+ * A text page: header, then `children` stacked `gap` apart (`md` by default), all in one `narrow`
23
+ * (`max-w-3xl`) reading column that starts at the Container's left edge (it is not re-centred, so
24
+ * the title does not jump). Unlike `FormLayout` the header sits inside the column, so its `actions`
25
+ * stay beside the text instead of at the Container's far edge. `className` goes on the column.
26
+ */
27
+ export function ProseLayout({ gap, className, children, ...header }) {
28
+ return (_jsxs("div", { className: cx(STACK_WIDTH.narrow, className), children: [_jsx(PageHeader, { ...header }), _jsx(Stack, { gap: gap, children: children })] }));
29
+ }
21
30
  const COLUMNS = {
22
31
  2: "lg:grid-cols-2",
23
32
  3: "lg:grid-cols-3",
@@ -31,11 +40,20 @@ export function CardGrid({ columns = 3, className, children, }) {
31
40
  * A full-height `rail` beside the `children` canvas. Render it in `<AppShell bleed>`: it fills what
32
41
  * the shell's flex column leaves under the header, so no header height is written down. The rail is
33
42
  * `md:w-60` and stacks above the canvas below `md`; the canvas keeps the Container's padding.
43
+ *
44
+ * It deliberately breaks the shared left edge from `md` up: a full-height rail needs the viewport's
45
+ * left edge, so the rail starts at x=0 and the canvas at the rail's width plus the Container's
46
+ * padding (264 at 1280), not at the header logo's x. Below `md` every layout agrees.
34
47
  */
35
48
  export function SidebarLayout({ rail, children, }) {
36
49
  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
50
  }
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>`. */
51
+ /**
52
+ * Edge-to-edge `children` with an optional right `panel` (`lg:w-72`) that stacks below on a smaller
53
+ * screen. Render it in `<AppShell bleed>`. It deliberately breaks the shared left edge: a viewer
54
+ * that needs the whole width has no Container of its own, so `children` start at the viewport's
55
+ * left edge and add their own padding.
56
+ */
39
57
  export function FullBleedLayout({ panel, children, }) {
40
58
  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
59
  }
@@ -0,0 +1,26 @@
1
+ import type { ReactElement } from "react";
2
+ /** The colour of a bar's fill. */
3
+ export type BarTone = "accent" | "positive" | "warn" | "negative";
4
+ /** 6px, 8px or 12px tall. */
5
+ export type BarSize = "sm" | "md" | "lg";
6
+ export declare const BAR_FILL: Record<BarTone, string>;
7
+ export declare const BAR_TRACK: Record<BarSize, string>;
8
+ /** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
9
+ export declare function sharePercent(part: number, whole: number): number;
10
+ /** The share as a whole percent for a screen reader: never 0 or 100 unless it truly is. */
11
+ export declare function announcedPercent(part: number, whole: number): number;
12
+ /**
13
+ * A thin horizontal bar with a filled share, for progress toward a total. `value` is held to
14
+ * 0–`max`. `label` names what it measures, for a screen reader, which also hears the percentage.
15
+ */
16
+ export declare function ProgressBar({ value, max, label, tone, size, className, }: {
17
+ value: number;
18
+ /** Defaults to 100. */
19
+ max?: number;
20
+ label: string;
21
+ /** Defaults to "accent". */
22
+ tone?: BarTone;
23
+ /** Defaults to "sm". */
24
+ size?: BarSize;
25
+ className?: string;
26
+ }): ReactElement;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ export const BAR_FILL = {
4
+ accent: "bg-violet-600",
5
+ positive: "bg-emerald-600",
6
+ warn: "bg-amber-600",
7
+ negative: "bg-red-600",
8
+ };
9
+ export const BAR_TRACK = {
10
+ sm: "h-1.5",
11
+ md: "h-2",
12
+ lg: "h-3",
13
+ };
14
+ /** `part` as a percent of `whole`, held to 0–100; 0 when either is not positive. */
15
+ export function sharePercent(part, whole) {
16
+ if (!(whole > 0) || !(part > 0))
17
+ return 0;
18
+ return Math.min(100, (part / whole) * 100);
19
+ }
20
+ /** The share as a whole percent for a screen reader: never 0 or 100 unless it truly is. */
21
+ export function announcedPercent(part, whole) {
22
+ const share = sharePercent(part, whole);
23
+ return share <= 0 || share >= 100 ? share : Math.min(99, Math.max(1, Math.round(share)));
24
+ }
25
+ /**
26
+ * A thin horizontal bar with a filled share, for progress toward a total. `value` is held to
27
+ * 0–`max`. `label` names what it measures, for a screen reader, which also hears the percentage.
28
+ */
29
+ export function ProgressBar({ value, max = 100, label, tone = "accent", size = "sm", className, }) {
30
+ const share = sharePercent(value, max);
31
+ return (_jsx("div", { role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": announcedPercent(value, max), className: cx("overflow-hidden rounded-full bg-slate-100", BAR_TRACK[size], className), children: _jsx("div", { className: cx("h-full", BAR_FILL[tone]), style: { width: `${share}%` } }) }));
32
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactElement } from "react";
2
+ import { type SwitchItem, type SwitchProps } from "./switcher.js";
3
+ /** One option of a SegmentedControl. See `SwitchItem`. */
4
+ export type Segment = SwitchItem;
5
+ /**
6
+ * A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
7
+ * `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
8
+ * and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
9
+ * `value` marks the current one, which gets `aria-current`). `label` names the group.
10
+ */
11
+ export declare function SegmentedControl(props: SwitchProps): ReactElement;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { Switcher } from "./switcher.js";
4
+ // The focus ring sits inside the option, since the group's rounded edge would clip an outer one.
5
+ const BASE = "border-l border-slate-300 px-3 py-1.5 transition first:rounded-l-full first:border-l-0 last:rounded-r-full focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-violet-600";
6
+ const ON = "bg-violet-600 font-medium text-violet-50";
7
+ const OFF = "bg-white text-slate-600 hover:bg-violet-50 hover:text-violet-700";
8
+ /**
9
+ * A joined pill group where one option is filled: a mode or a range chosen in place. `items` are
10
+ * `{ value, label }` buttons (`value` and `onChange` are the caller's state; the arrow keys, Home
11
+ * and End move the choice) or links (`href`, or `render` to draw the app's own router `<Link>`;
12
+ * `value` marks the current one, which gets `aria-current`). `label` names the group.
13
+ */
14
+ export function SegmentedControl(props) {
15
+ return (_jsx(Switcher, { ...props, parts: {
16
+ group: "radiogroup",
17
+ item: "radio",
18
+ vertical: true,
19
+ root: "inline-flex rounded-full border border-slate-300 text-sm",
20
+ option: (active) => cx(BASE, active ? ON : OFF),
21
+ } }));
22
+ }
package/dist/Select.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
- /** A native `<select>` styled like `Input`, keeping the browser's own arrow. */
3
- export declare function Select({ className, children, ...rest }: ComponentPropsWithoutRef<"select">): ReactElement;
2
+ import { type ControlProps } from "./control.js";
3
+ /** A native `<select>` styled like `Input`, keeping the browser's own arrow. Takes `size` and `width` too. */
4
+ export declare function Select({ size, width, className, children, ...rest }: ControlProps & Omit<ComponentPropsWithoutRef<"select">, "size" | "width">): ReactElement;
package/dist/Select.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { controlClass } from "./control.js";
2
+ import { CONTROL_PAD, controlClass } from "./control.js";
3
3
  import { cx } from "./cx.js";
4
- /** A native `<select>` styled like `Input`, keeping the browser's own arrow. */
5
- export function Select({ className, children, ...rest }) {
6
- return (_jsx("select", { ...rest, className: cx(controlClass, "px-3", className), children: children }));
4
+ /** A native `<select>` styled like `Input`, keeping the browser's own arrow. Takes `size` and `width` too. */
5
+ export function Select({ size = "md", width = "full", className, children, ...rest }) {
6
+ return (_jsx("select", { ...rest, className: cx(controlClass(size, width), CONTROL_PAD[size].x, className), children: children }));
7
7
  }
@@ -0,0 +1,21 @@
1
+ import type { ReactElement } from "react";
2
+ import { type BarSize, type BarTone } from "./ProgressBar.js";
3
+ /**
4
+ * A bar split between two parties, `a` on the left and `b` on the right, each sized by its share of
5
+ * `a + b`. `label` names the split for a screen reader, which hears both percentages; give `names`
6
+ * (left, right) so it hears whose is whose. Put the parties' names under the bar yourself.
7
+ */
8
+ export declare function SplitBar({ a, b, label, names, toneA, toneB, size, className, }: {
9
+ a: number;
10
+ b: number;
11
+ label: string;
12
+ /** The left and right parties, spoken before their percentages. */
13
+ names?: readonly [string, string];
14
+ /** Defaults to "negative". */
15
+ toneA?: BarTone;
16
+ /** Defaults to "positive". */
17
+ toneB?: BarTone;
18
+ /** Defaults to "lg". */
19
+ size?: BarSize;
20
+ className?: string;
21
+ }): ReactElement;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { BAR_FILL, BAR_TRACK, announcedPercent, sharePercent, } from "./ProgressBar.js";
4
+ /**
5
+ * A bar split between two parties, `a` on the left and `b` on the right, each sized by its share of
6
+ * `a + b`. `label` names the split for a screen reader, which hears both percentages; give `names`
7
+ * (left, right) so it hears whose is whose. Put the parties' names under the bar yourself.
8
+ */
9
+ export function SplitBar({ a, b, label, names, toneA = "negative", toneB = "positive", size = "lg", className, }) {
10
+ const shareA = sharePercent(a, a + b);
11
+ const shareB = a + b > 0 ? 100 - shareA : 0;
12
+ const percentA = announcedPercent(a, a + b);
13
+ const percentB = a + b > 0 ? 100 - percentA : 0;
14
+ return (_jsxs("div", { role: "img", "aria-label": `${label}: ${names ? `${names[0]} ${percentA}%, ${names[1]} ${percentB}%` : `${percentA}% / ${percentB}%`}`, className: cx("flex overflow-hidden rounded-full bg-slate-100", BAR_TRACK[size], className), children: [_jsx("div", { className: BAR_FILL[toneA], style: { width: `${shareA}%` } }), _jsx("div", { className: BAR_FILL[toneB], style: { width: `${shareB}%` } })] }));
15
+ }
package/dist/Stack.d.ts CHANGED
@@ -1,18 +1,30 @@
1
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;
2
+ export type StackGap = "xs" | "sm" | "md" | "lg";
3
+ export type StackWidth = "narrow" | "default" | "wide" | "full";
4
+ /** `default` adds nothing: the Stack is as wide as its parent. */
5
+ export declare const STACK_WIDTH: Record<StackWidth, string | undefined>;
6
+ /**
7
+ * A vertical column of blocks a fixed `gap` apart: `xs` between lines of text (a heading and the
8
+ * line under it), `sm` inside a group, `md` (default) between blocks, `lg` between groups.
9
+ *
10
+ * `width` caps the column: `narrow` is a reading measure (`max-w-3xl`), `wide` is `max-w-5xl`, `full`
11
+ * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
12
+ * edge (it is never re-centred), so its first line lines up with every other block on the page.
13
+ */
14
+ export declare function Stack({ gap, width, as: Tag, className, children, ...rest }: {
15
+ gap?: StackGap | undefined;
16
+ width?: StackWidth | undefined;
6
17
  as?: "div" | "section";
7
18
  className?: string | undefined;
8
19
  children?: ReactNode;
9
20
  "aria-labelledby"?: string | undefined;
10
21
  }): ReactElement;
11
22
  /** 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, }: {
23
+ export declare function Section({ title, actions, gap, width, className, children, }: {
13
24
  title?: ReactNode;
14
25
  actions?: ReactNode;
15
- gap?: StackGap;
26
+ gap?: StackGap | undefined;
27
+ width?: StackWidth | undefined;
16
28
  className?: string;
17
29
  children?: ReactNode;
18
30
  }): ReactElement;
package/dist/Stack.js CHANGED
@@ -2,13 +2,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
3
  import { cx } from "./cx.js";
4
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 }));
5
+ const GAP = { xs: "gap-2", sm: "gap-4", md: "gap-6", lg: "gap-8" };
6
+ /** `default` adds nothing: the Stack is as wide as its parent. */
7
+ export const STACK_WIDTH = {
8
+ narrow: "max-w-3xl",
9
+ default: undefined,
10
+ wide: "max-w-5xl",
11
+ full: "w-full",
12
+ };
13
+ /**
14
+ * A vertical column of blocks a fixed `gap` apart: `xs` between lines of text (a heading and the
15
+ * line under it), `sm` inside a group, `md` (default) between blocks, `lg` between groups.
16
+ *
17
+ * `width` caps the column: `narrow` is a reading measure (`max-w-3xl`), `wide` is `max-w-5xl`, `full`
18
+ * is the parent's whole width and `default` sets nothing. A capped Stack starts at its parent's left
19
+ * edge (it is never re-centred), so its first line lines up with every other block on the page.
20
+ */
21
+ export function Stack({ gap = "md", width = "default", as: Tag = "div", className, children, ...rest }) {
22
+ return (_jsx(Tag, { className: cx("flex flex-col", GAP[gap], STACK_WIDTH[width], className), ...rest, children: children }));
9
23
  }
10
24
  /** 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, }) {
25
+ export function Section({ title, actions, gap = "sm", width, className, children, }) {
12
26
  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] }));
27
+ return (_jsxs(Stack, { as: "section", gap: gap, width: width, 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
28
  }
@@ -0,0 +1,16 @@
1
+ import type { HTMLAttributes, ReactElement, ReactNode } from "react";
2
+ /** The colour of a tile's value (and of its accent bar): plain ink, green or red. */
3
+ export type StatTileTone = "neutral" | "positive" | "negative";
4
+ /**
5
+ * One headline figure: an uppercase caption over a large tabular-nums value, with an optional
6
+ * hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
7
+ * tone. Pass the value already formatted.
8
+ */
9
+ export declare function StatTile({ label, value, hint, tone, accent, className, ...rest }: {
10
+ label: ReactNode;
11
+ value: ReactNode;
12
+ hint?: ReactNode;
13
+ /** Defaults to "neutral". */
14
+ tone?: StatTileTone;
15
+ accent?: boolean;
16
+ } & Omit<HTMLAttributes<HTMLDivElement>, "children">): ReactElement;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { BAR_FILL } from "./ProgressBar.js";
4
+ const VALUE = {
5
+ neutral: "text-slate-900",
6
+ positive: "text-emerald-700",
7
+ negative: "text-red-700",
8
+ };
9
+ const BAR = {
10
+ neutral: BAR_FILL.accent,
11
+ positive: BAR_FILL.positive,
12
+ negative: BAR_FILL.negative,
13
+ };
14
+ /**
15
+ * One headline figure: an uppercase caption over a large tabular-nums value, with an optional
16
+ * hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
17
+ * tone. Pass the value already formatted.
18
+ */
19
+ export function StatTile({ label, value, hint, tone = "neutral", accent = false, className, ...rest }) {
20
+ return (_jsxs("div", { className: cx("relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", className), ...rest, children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx("p", { className: "text-xs font-semibold uppercase tracking-wider text-slate-500", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
21
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ type Align = "start" | "end";
3
+ declare const TONE: {
4
+ readonly default: "text-slate-700";
5
+ readonly strong: "font-medium text-slate-900";
6
+ readonly muted: "text-slate-500";
7
+ };
8
+ /**
9
+ * The classes of the one link in a row whose hit area covers the row, so the whole row is the
10
+ * click target. For a router's own `<Link>`; `TableRow` is the `relative` ancestor it stretches to.
11
+ */
12
+ export declare const rowLinkClass: string;
13
+ /**
14
+ * A list of records in a rounded, bordered card that scrolls sideways on a narrow screen. Compose it
15
+ * from `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell` and `TableCell`. `caption` names the
16
+ * table for assistive tech and is not drawn.
17
+ */
18
+ export declare function Table({ caption, className, children, }: {
19
+ caption?: string | undefined;
20
+ className?: string | undefined;
21
+ children: ReactNode;
22
+ }): ReactElement;
23
+ /** The `<thead>`: small uppercase column titles on a tinted strip. Holds one `TableRow` of `TableHeaderCell`s. */
24
+ export declare function TableHead({ children }: {
25
+ children: ReactNode;
26
+ }): ReactElement;
27
+ /** The `<tbody>`: the rows, each with a line above it and a fill on hover. */
28
+ export declare function TableBody({ children }: {
29
+ children: ReactNode;
30
+ }): ReactElement;
31
+ /** A `<tr>`, `relative`: the anchor a `rowLinkClass` link stretches to. Use it in the head and the body. */
32
+ export declare function TableRow({ children }: {
33
+ children: ReactNode;
34
+ }): ReactElement;
35
+ /** A column title, `<th scope="col">`. */
36
+ export declare function TableHeaderCell({ align, children, }: {
37
+ align?: Align | undefined;
38
+ children?: ReactNode;
39
+ }): ReactElement;
40
+ /** A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono face. */
41
+ export declare function TableCell({ align, tone, numeric, mono, children, }: {
42
+ align?: Align | undefined;
43
+ tone?: keyof typeof TONE | undefined;
44
+ numeric?: boolean | undefined;
45
+ mono?: boolean | undefined;
46
+ children?: ReactNode;
47
+ }): ReactElement;
48
+ export {};
package/dist/Table.js ADDED
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { linkClass, linkStretchClass } from "./TextLink.js";
4
+ const ALIGN = { start: "text-left", end: "text-right" };
5
+ const TONE = {
6
+ default: "text-slate-700",
7
+ strong: "font-medium text-slate-900",
8
+ muted: "text-slate-500",
9
+ };
10
+ /**
11
+ * The classes of the one link in a row whose hit area covers the row, so the whole row is the
12
+ * click target. For a router's own `<Link>`; `TableRow` is the `relative` ancestor it stretches to.
13
+ */
14
+ export const rowLinkClass = cx(linkClass, linkStretchClass);
15
+ /**
16
+ * A list of records in a rounded, bordered card that scrolls sideways on a narrow screen. Compose it
17
+ * from `TableHead`, `TableBody`, `TableRow`, `TableHeaderCell` and `TableCell`. `caption` names the
18
+ * table for assistive tech and is not drawn.
19
+ */
20
+ export function Table({ caption, className, children, }) {
21
+ return (_jsx("div", { className: cx("overflow-x-auto rounded-xl border border-slate-200/70 bg-white shadow-sm", className), children: _jsxs("table", { className: "w-full text-left text-sm", children: [caption && _jsx("caption", { className: "sr-only", children: caption }), children] }) }));
22
+ }
23
+ /** The `<thead>`: small uppercase column titles on a tinted strip. Holds one `TableRow` of `TableHeaderCell`s. */
24
+ export function TableHead({ children }) {
25
+ return (_jsx("thead", { className: "bg-slate-50 text-xs uppercase tracking-wide text-slate-500", children: children }));
26
+ }
27
+ /** The `<tbody>`: the rows, each with a line above it and a fill on hover. */
28
+ export function TableBody({ children }) {
29
+ return (_jsx("tbody", { className: "[&>tr:hover]:bg-slate-50 [&>tr]:border-t [&>tr]:border-slate-200/70", children: children }));
30
+ }
31
+ /** A `<tr>`, `relative`: the anchor a `rowLinkClass` link stretches to. Use it in the head and the body. */
32
+ export function TableRow({ children }) {
33
+ return _jsx("tr", { className: "relative", children: children });
34
+ }
35
+ /** A column title, `<th scope="col">`. */
36
+ export function TableHeaderCell({ align = "start", children, }) {
37
+ return (_jsx("th", { scope: "col", className: cx("whitespace-nowrap px-4 py-2.5 font-medium", ALIGN[align]), children: children }));
38
+ }
39
+ /** A `<td>`. `tone` is the text weight and colour; `numeric` sets tabular figures, `mono` a small mono face. */
40
+ export function TableCell({ align = "start", tone = "default", numeric = false, mono = false, children, }) {
41
+ return (_jsx("td", { className: cx("whitespace-nowrap px-4 py-3", ALIGN[align], TONE[tone], numeric && "tabular-nums", mono && "font-mono text-xs"), children: children }));
42
+ }
package/dist/Tabs.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { ReactElement } from "react";
2
+ import { type SwitchItem, type SwitchProps } from "./switcher.js";
3
+ /** One tab of a Tabs row. See `SwitchItem`. */
4
+ export type Tab = SwitchItem;
5
+ /**
6
+ * Underline tabs for switching a view in place: a row over a bottom rule, the current tab
7
+ * underlined in the accent. `items` are `{ value, label }` buttons (`value` and `onChange` are the
8
+ * caller's state; the arrow keys, Home and End move between tabs) or links (`href`, or `render` to
9
+ * draw the app's own router `<Link>`; `value` marks the current one, which gets `aria-current`).
10
+ * `label` names the tab list. The panel is the caller's: give it `role="tabpanel"`, and a tab
11
+ * `controls` to point at it.
12
+ */
13
+ export declare function Tabs(props: SwitchProps): ReactElement;