@hackcove/ui-react 0.0.0-stage → 0.8.0

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 (172) hide show
  1. package/LICENSE +21 -0
  2. package/MIGRATING.md +116 -0
  3. package/README.md +400 -2
  4. package/dist/AppShell.d.ts +97 -0
  5. package/dist/AppShell.js +106 -0
  6. package/dist/Avatar.d.ts +49 -0
  7. package/dist/Avatar.js +65 -0
  8. package/dist/AvatarStack.d.ts +29 -0
  9. package/dist/AvatarStack.js +26 -0
  10. package/dist/Badge.d.ts +14 -0
  11. package/dist/Badge.js +11 -0
  12. package/dist/Breadcrumbs.d.ts +28 -0
  13. package/dist/Breadcrumbs.js +30 -0
  14. package/dist/BulletList.d.ts +4 -0
  15. package/dist/BulletList.js +5 -0
  16. package/dist/Button.d.ts +78 -0
  17. package/dist/Button.js +133 -0
  18. package/dist/ButtonGroup.d.ts +15 -0
  19. package/dist/ButtonGroup.js +10 -0
  20. package/dist/Callout.d.ts +24 -0
  21. package/dist/Callout.js +28 -0
  22. package/dist/Card.d.ts +95 -0
  23. package/dist/Card.js +134 -0
  24. package/dist/CardBadge.d.ts +7 -0
  25. package/dist/CardBadge.js +12 -0
  26. package/dist/CardParts.d.ts +25 -0
  27. package/dist/CardParts.js +25 -0
  28. package/dist/Checkbox.d.ts +29 -0
  29. package/dist/Checkbox.js +36 -0
  30. package/dist/Chevron.d.ts +5 -0
  31. package/dist/Chevron.js +6 -0
  32. package/dist/Cluster.d.ts +51 -0
  33. package/dist/Cluster.js +38 -0
  34. package/dist/Code.d.ts +4 -0
  35. package/dist/Code.js +5 -0
  36. package/dist/CodeBlock.d.ts +27 -0
  37. package/dist/CodeBlock.js +12 -0
  38. package/dist/CodeExample.d.ts +17 -0
  39. package/dist/CodeExample.js +13 -0
  40. package/dist/CodeText.d.ts +7 -0
  41. package/dist/CodeText.js +21 -0
  42. package/dist/ColourSchemeToggle.d.ts +34 -0
  43. package/dist/ColourSchemeToggle.js +50 -0
  44. package/dist/Container.d.ts +22 -0
  45. package/dist/Container.js +13 -0
  46. package/dist/CopyButton.d.ts +17 -0
  47. package/dist/CopyButton.js +13 -0
  48. package/dist/CopyLine.d.ts +9 -0
  49. package/dist/CopyLine.js +7 -0
  50. package/dist/DateCalendar.d.ts +10 -0
  51. package/dist/DateCalendar.js +45 -0
  52. package/dist/DateField.d.ts +87 -0
  53. package/dist/DateField.js +100 -0
  54. package/dist/DateFieldCalendar.d.ts +6 -0
  55. package/dist/DateFieldCalendar.js +35 -0
  56. package/dist/Dialog.d.ts +26 -0
  57. package/dist/Dialog.js +18 -0
  58. package/dist/Disclosure.d.ts +15 -0
  59. package/dist/Disclosure.js +12 -0
  60. package/dist/Divider.d.ts +4 -0
  61. package/dist/Divider.js +5 -0
  62. package/dist/Drawer.d.ts +29 -0
  63. package/dist/Drawer.js +22 -0
  64. package/dist/EmptyState.d.ts +18 -0
  65. package/dist/EmptyState.js +14 -0
  66. package/dist/Field.d.ts +21 -0
  67. package/dist/Field.js +19 -0
  68. package/dist/FieldDialog.d.ts +28 -0
  69. package/dist/FieldDialog.js +34 -0
  70. package/dist/FileDrop.d.ts +17 -0
  71. package/dist/FileDrop.js +12 -0
  72. package/dist/FilterGroup.d.ts +7 -0
  73. package/dist/FilterGroup.js +6 -0
  74. package/dist/FormRow.d.ts +42 -0
  75. package/dist/FormRow.js +33 -0
  76. package/dist/Icon.d.ts +20 -0
  77. package/dist/Icon.js +13 -0
  78. package/dist/IconButton.d.ts +57 -0
  79. package/dist/IconButton.js +51 -0
  80. package/dist/Input.d.ts +22 -0
  81. package/dist/Input.js +22 -0
  82. package/dist/InputShell.d.ts +16 -0
  83. package/dist/InputShell.js +26 -0
  84. package/dist/Kbd.d.ts +6 -0
  85. package/dist/Kbd.js +5 -0
  86. package/dist/KeyValueList.d.ts +23 -0
  87. package/dist/KeyValueList.js +19 -0
  88. package/dist/Lightbox.d.ts +28 -0
  89. package/dist/Lightbox.js +44 -0
  90. package/dist/ListRow.d.ts +109 -0
  91. package/dist/ListRow.js +92 -0
  92. package/dist/Markdown.d.ts +9 -0
  93. package/dist/Markdown.js +43 -0
  94. package/dist/MetaList.d.ts +23 -0
  95. package/dist/MetaList.js +14 -0
  96. package/dist/OutlineRegion.d.ts +43 -0
  97. package/dist/OutlineRegion.js +27 -0
  98. package/dist/PageFade.d.ts +26 -0
  99. package/dist/PageFade.js +39 -0
  100. package/dist/PageHeader.d.ts +24 -0
  101. package/dist/PageHeader.js +14 -0
  102. package/dist/PageLayouts.d.ts +130 -0
  103. package/dist/PageLayouts.js +86 -0
  104. package/dist/Pill.d.ts +53 -0
  105. package/dist/Pill.js +34 -0
  106. package/dist/ProgressBar.d.ts +32 -0
  107. package/dist/ProgressBar.js +32 -0
  108. package/dist/RadioGroup.d.ts +48 -0
  109. package/dist/RadioGroup.js +39 -0
  110. package/dist/SearchInput.d.ts +8 -0
  111. package/dist/SearchInput.js +40 -0
  112. package/dist/SegmentedControl.d.ts +20 -0
  113. package/dist/SegmentedControl.js +31 -0
  114. package/dist/Select.d.ts +5 -0
  115. package/dist/Select.js +9 -0
  116. package/dist/SplitBar.d.ts +27 -0
  117. package/dist/SplitBar.js +15 -0
  118. package/dist/Stack.d.ts +70 -0
  119. package/dist/Stack.js +47 -0
  120. package/dist/StackedBar.d.ts +56 -0
  121. package/dist/StackedBar.js +35 -0
  122. package/dist/StatTile.d.ts +21 -0
  123. package/dist/StatTile.js +22 -0
  124. package/dist/StatusPages.d.ts +38 -0
  125. package/dist/StatusPages.js +57 -0
  126. package/dist/Steps.d.ts +17 -0
  127. package/dist/Steps.js +14 -0
  128. package/dist/StickyBar.d.ts +12 -0
  129. package/dist/StickyBar.js +10 -0
  130. package/dist/Table.d.ts +142 -0
  131. package/dist/Table.js +138 -0
  132. package/dist/Tabs.d.ts +40 -0
  133. package/dist/Tabs.js +49 -0
  134. package/dist/TextLink.d.ts +51 -0
  135. package/dist/TextLink.js +37 -0
  136. package/dist/Textarea.d.ts +5 -0
  137. package/dist/Textarea.js +7 -0
  138. package/dist/Timeline.d.ts +27 -0
  139. package/dist/Timeline.js +29 -0
  140. package/dist/Video.d.ts +13 -0
  141. package/dist/Video.js +9 -0
  142. package/dist/colourScheme.d.ts +21 -0
  143. package/dist/colourScheme.js +53 -0
  144. package/dist/control.d.ts +31 -0
  145. package/dist/control.js +38 -0
  146. package/dist/cx.d.ts +10 -0
  147. package/dist/cx.js +12 -0
  148. package/dist/highlight.d.ts +16 -0
  149. package/dist/highlight.js +292 -0
  150. package/dist/index.d.ts +70 -0
  151. package/dist/index.js +66 -0
  152. package/dist/isoDate.d.ts +5 -0
  153. package/dist/isoDate.js +16 -0
  154. package/dist/lift.harness.d.ts +2 -0
  155. package/dist/lift.harness.js +3 -0
  156. package/dist/link.d.ts +50 -0
  157. package/dist/link.js +25 -0
  158. package/dist/modal.d.ts +11 -0
  159. package/dist/modal.js +41 -0
  160. package/dist/props.d.ts +14 -0
  161. package/dist/props.js +1 -0
  162. package/dist/shownDate.d.ts +45 -0
  163. package/dist/shownDate.js +88 -0
  164. package/dist/size.d.ts +6 -0
  165. package/dist/size.js +1 -0
  166. package/dist/switcher.d.ts +88 -0
  167. package/dist/switcher.js +71 -0
  168. package/dist/typography.d.ts +123 -0
  169. package/dist/typography.js +120 -0
  170. package/dist/ui.d.ts +10 -0
  171. package/dist/ui.js +27 -0
  172. package/package.json +91 -4
@@ -0,0 +1,97 @@
1
+ import { type HTMLAttributes, type ReactElement, type ReactNode } from "react";
2
+ import { type ColourScheme, type ColourSchemeClasses } from "./colourScheme.js";
3
+ import { type LinkRender } from "./link.js";
4
+ import type { Passthrough, Unstyled } from "./props.js";
5
+ import type { Size } from "./size.js";
6
+ /**
7
+ * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
8
+ * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
9
+ */
10
+ export declare const appShellRootClass = "overflow-y-scroll";
11
+ /**
12
+ * Everything an app puts on its own `<html>` from the scheme the server knows: that scheme's class
13
+ * (`schemeClass`) and `appShellRootClass`, joined. `<html className={appShellRootClassFor(scheme,
14
+ * schemes)}>` replaces `schemeClass(scheme, schemes) + " " + appShellRootClass`.
15
+ */
16
+ export declare function appShellRootClassFor(scheme: ColourScheme, schemes?: ColourSchemeClasses): string;
17
+ /**
18
+ * The `brand` slot as one link: a logo and a label inside the same `<a>`, so the whole brand is the
19
+ * way home. The label names the link; the logo is decoration and hidden from assistive tech.
20
+ * `collapse` shows only the logo below `sm`, the label staying in the accessible name. With
21
+ * `render` it draws the app's own router `<Link>` (`render={(p) => <Link to="/" {...p} />}`).
22
+ */
23
+ export declare function AppShellBrand({ logo, logoSize, label, collapse, href, render, ...rest }: Passthrough & {
24
+ /** The name beside the logo. It is the link's accessible name. */
25
+ label: ReactNode;
26
+ /** The logo slot's box: sm 20px, md 24px (the default, an `Avatar size="sm"`), lg 32px. An `<img>` or `<svg>` logo fills it. */
27
+ logoSize?: Size;
28
+ } & ({
29
+ /** The mark before the label, e.g. an `Avatar` or an `<img>`. */
30
+ logo?: ReactNode;
31
+ collapse?: false | undefined;
32
+ } | {
33
+ logo: ReactNode;
34
+ /** Below `sm` only the logo shows; the label is visually hidden and still names the link. Needs a `logo`. */
35
+ collapse: true;
36
+ }) & ({
37
+ /** Where the brand links, usually the app's home (`/`). */
38
+ href: string;
39
+ /** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/" {...p} />`. */
40
+ render?: LinkRender | undefined;
41
+ } | {
42
+ /** Where the brand links, usually the app's home (`/`). */
43
+ href?: string | undefined;
44
+ /** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/" {...p} />`. */
45
+ render: LinkRender;
46
+ })): ReactElement;
47
+ /** The breakpoint below which an `AppShellHideBelow` item is hidden. */
48
+ export type AppShellBreakpoint = "sm" | "md" | "lg";
49
+ /**
50
+ * An inline header item that only shows from a breakpoint up, such as the viewer's avatar and name
51
+ * in `controls`: below `breakpoint` it is gone (`display: none`, so assistive tech skips it too).
52
+ * Its children sit in a row with the header's gap.
53
+ */
54
+ export declare function AppShellHideBelow({ breakpoint, children, ...rest }: Passthrough & {
55
+ /**
56
+ * Hidden below `sm` (640px), `md` (768px) or `lg` (1024px).
57
+ * @default sm
58
+ */
59
+ breakpoint?: AppShellBreakpoint;
60
+ children: ReactNode;
61
+ }): ReactElement;
62
+ /**
63
+ * The page frame: it paints the page canvas and sets the type face, ink and font smoothing
64
+ * (`antialiased`), so an app writes no page-background or font rule of its own (a theme remaps
65
+ * the canvas and the font stacks). A sticky one-row header, an optional second `subnav` row, the main area and an
66
+ * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
67
+ * width or padding. `bleed` drops main's Container (the header keeps its own), for a layout that
68
+ * needs the whole width (`FullBleedLayout`). Main is a flex column. `SidebarLayout` needs no
69
+ * `bleed`: its rail and canvas share the header's Container.
70
+ *
71
+ * The header row is laid out by its slots, left to right: `brand`, `nav`, then `header` (anything
72
+ * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
73
+ * styling for any of them: an item that only fits a wide screen goes in `AppShellHideBelow`. A bar
74
+ * that sticks under the header as the page scrolls is a `StickyBar`.
75
+ */
76
+ export declare function AppShell({ brand, nav, controls, header, subnav, footer, bleed, hideChromeInPrint, children, ...rest }: {
77
+ /** The brand: a logo and a name, in the header's brand type, with the gap between them. `AppShellBrand` makes both one link. */
78
+ brand?: ReactNode;
79
+ /** The main navigation, e.g. a `Tabs variant="pill"` of links (it is already a `<nav>`). */
80
+ nav?: ReactNode;
81
+ /** The end of the row: a picker, a theme toggle. Gap-2, vertically centred; no child shrinks, and a full-width control (a `Select` at its default) sizes to its content. */
82
+ controls?: ReactNode;
83
+ /** The escape hatch: a free node in the header row, after `brand` and `nav`. */
84
+ header?: ReactNode;
85
+ /** A second fixed-height row under the header, e.g. a `Tabs variant="pill"` of links. */
86
+ subnav?: ReactNode;
87
+ /** The page footer: small muted text in a `Container`, under a hairline. Hidden in print with `hideChromeInPrint`. */
88
+ footer?: ReactNode;
89
+ /** Main without a Container, for a full-bleed layout. */
90
+ bleed?: boolean;
91
+ /**
92
+ * Print only the page: the header and footer are hidden, and main's Container drops its padding
93
+ * (the page margin is then `@page`'s). Off by default, so a print is unchanged unless an app asks.
94
+ */
95
+ hideChromeInPrint?: boolean;
96
+ children: ReactNode;
97
+ } & Unstyled<Omit<HTMLAttributes<HTMLDivElement>, "children">>): ReactElement;
@@ -0,0 +1,106 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { ContainerFrame } from "./Container.js";
4
+ import { schemeClass } from "./colourScheme.js";
5
+ import { cx, focusRing } from "./cx.js";
6
+ import { renderLink } from "./link.js";
7
+ /**
8
+ * The class an app puts on its own `<html>`: it always reserves the scrollbar, so centred content
9
+ * does not shift between a short page and a long one. AppShell never touches `<html>` itself.
10
+ */
11
+ export const appShellRootClass = "overflow-y-scroll";
12
+ /**
13
+ * Everything an app puts on its own `<html>` from the scheme the server knows: that scheme's class
14
+ * (`schemeClass`) and `appShellRootClass`, joined. `<html className={appShellRootClassFor(scheme,
15
+ * schemes)}>` replaces `schemeClass(scheme, schemes) + " " + appShellRootClass`.
16
+ */
17
+ export function appShellRootClassFor(scheme, schemes) {
18
+ return cx(schemeClass(scheme, schemes), appShellRootClass);
19
+ }
20
+ const LOGO_BOX = { sm: "size-5", md: "size-6", lg: "size-8" };
21
+ /**
22
+ * The `brand` slot as one link: a logo and a label inside the same `<a>`, so the whole brand is the
23
+ * way home. The label names the link; the logo is decoration and hidden from assistive tech.
24
+ * `collapse` shows only the logo below `sm`, the label staying in the accessible name. With
25
+ * `render` it draws the app's own router `<Link>` (`render={(p) => <Link to="/" {...p} />}`).
26
+ */
27
+ export function AppShellBrand({ logo, logoSize = "md", label, collapse = false, href, render, ...rest }) {
28
+ return renderLink({
29
+ ...rest,
30
+ href,
31
+ render,
32
+ className: cx("flex items-center gap-2 rounded", focusRing),
33
+ children: (_jsxs(_Fragment, { children: [logo && (_jsx("span", { "aria-hidden": "true", className: cx("flex shrink-0 items-center justify-center [&>img]:size-full [&>svg]:size-full", LOGO_BOX[logoSize]), children: logo })), _jsx("span", { className: collapse ? "sr-only sm:not-sr-only" : undefined, children: label })] })),
34
+ });
35
+ }
36
+ const SHOWN_FROM = {
37
+ sm: "hidden sm:flex",
38
+ md: "hidden md:flex",
39
+ lg: "hidden lg:flex",
40
+ };
41
+ /**
42
+ * An inline header item that only shows from a breakpoint up, such as the viewer's avatar and name
43
+ * in `controls`: below `breakpoint` it is gone (`display: none`, so assistive tech skips it too).
44
+ * Its children sit in a row with the header's gap.
45
+ */
46
+ export function AppShellHideBelow({ breakpoint = "sm", children, ...rest }) {
47
+ return (_jsx("span", { ...rest, className: cx(SHOWN_FROM[breakpoint], "items-center gap-2"), children: children }));
48
+ }
49
+ // One fixed-height row that scrolls sideways and never wraps; its children keep their width.
50
+ const ROW = "flex items-center gap-4 overflow-x-auto whitespace-nowrap *:shrink-0";
51
+ // While a row has more to scroll to on a side, that edge fades out (a mask, so it follows any theme).
52
+ // `ScrollRow` sets `data-more-start` / `data-more-end` (and `data-more` for either); a row that fits
53
+ // has none and is untouched.
54
+ const FADE = "data-more-start:[--fade-start:2rem] data-more-end:[--fade-end:2rem] data-more:[mask-image:linear-gradient(to_right,transparent,#000_var(--fade-start,0px),#000_calc(100%_-_var(--fade-end,0px)),transparent)]";
55
+ /** A row in a Container, with the fade on whichever side still has content to scroll to. */
56
+ function ScrollRow({ className, children }) {
57
+ const row = useRef(null);
58
+ // Every render: the children may have changed, and each needs observing.
59
+ useEffect(() => {
60
+ const el = row.current;
61
+ if (!el)
62
+ return undefined;
63
+ const update = () => {
64
+ const start = el.scrollLeft > 1;
65
+ const end = el.scrollWidth - el.clientWidth - el.scrollLeft > 1;
66
+ el.toggleAttribute("data-more-start", start);
67
+ el.toggleAttribute("data-more-end", end);
68
+ el.toggleAttribute("data-more", start || end);
69
+ };
70
+ update();
71
+ el.addEventListener("scroll", update, { passive: true });
72
+ // jsdom (a consumer's own tests) has no ResizeObserver.
73
+ const resize = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(update);
74
+ resize?.observe(el);
75
+ for (const child of el.children)
76
+ resize?.observe(child);
77
+ return () => {
78
+ el.removeEventListener("scroll", update);
79
+ resize?.disconnect();
80
+ };
81
+ });
82
+ return (_jsx(ContainerFrame, { elementRef: (el) => void (row.current = el), className: cx(ROW, FADE, className), children: children }));
83
+ }
84
+ /** The header row's slots in order: brand, nav, the free `children`, then controls at the end. */
85
+ function HeaderSlots({ brand, nav, controls, children, }) {
86
+ return (_jsxs(_Fragment, { children: [brand && (_jsx("div", { className: "flex items-center gap-2 text-base font-bold tracking-tight text-slate-900", children: brand })), nav, children, controls && (_jsx("div", { className: "ml-auto flex items-center gap-2 *:shrink-0 [&>.w-full]:w-auto", children: controls }))] }));
87
+ }
88
+ /**
89
+ * The page frame: it paints the page canvas and sets the type face, ink and font smoothing
90
+ * (`antialiased`), so an app writes no page-background or font rule of its own (a theme remaps
91
+ * the canvas and the font stacks). A sticky one-row header, an optional second `subnav` row, the main area and an
92
+ * optional footer. Header, main and footer each sit in a `Container`, so pages never set their own
93
+ * width or padding. `bleed` drops main's Container (the header keeps its own), for a layout that
94
+ * needs the whole width (`FullBleedLayout`). Main is a flex column. `SidebarLayout` needs no
95
+ * `bleed`: its rail and canvas share the header's Container.
96
+ *
97
+ * The header row is laid out by its slots, left to right: `brand`, `nav`, then `header` (anything
98
+ * the slots do not cover), then `controls`, pushed to the end. An app writes no wrapper or
99
+ * styling for any of them: an item that only fits a wide screen goes in `AppShellHideBelow`. A bar
100
+ * that sticks under the header as the page scrolls is a `StickyBar`.
101
+ */
102
+ export function AppShell({ brand, nav, controls, header, subnav, footer, bleed = false, hideChromeInPrint = false, children, ...rest }) {
103
+ return (_jsxs("div", { ...rest, className: cx("flex min-h-dvh flex-col bg-gray-50 font-sans text-slate-900 antialiased",
104
+ // The sticky header's height (its rows and the bottom border), for a bar that sticks under it.
105
+ subnav ? "[--app-header:calc(6.5rem+1px)]" : "[--app-header:calc(3.5rem+1px)]"), children: [_jsxs("header", { className: cx("sticky top-0 z-10 border-b border-slate-200 bg-white", hideChromeInPrint && "print:hidden"), children: [_jsx(ScrollRow, { className: "h-14", children: _jsx(HeaderSlots, { brand: brand, nav: nav, controls: controls, children: header }) }), subnav && _jsx(ScrollRow, { className: "h-12", children: subnav })] }), _jsx("main", { className: "flex flex-1 flex-col", children: bleed ? (children) : (_jsx(ContainerFrame, { className: cx("py-8", hideChromeInPrint && "print:p-0"), children: children })) }), footer && (_jsx("footer", { className: cx("border-t border-slate-200", hideChromeInPrint && "print:hidden"), children: _jsx(ContainerFrame, { className: "py-6 text-sm text-slate-500", children: footer }) }))] }));
106
+ }
@@ -0,0 +1,49 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ import type { Unstyled } from "./props.js";
3
+ declare const SIZE_CLASSES: {
4
+ readonly sm: "size-6 text-xs";
5
+ readonly md: "size-8 text-xs";
6
+ readonly lg: "size-10 text-base";
7
+ readonly xl: "size-14 text-xl";
8
+ readonly "2xl": "size-24 text-3xl";
9
+ };
10
+ export type AvatarSize = keyof typeof SIZE_CLASSES;
11
+ /** `round` for a person, `square` (rounded corners that grow with the size) for an org or a team. */
12
+ export type AvatarShape = "round" | "square";
13
+ /** The corner classes for a shape at a size; `AvatarStack`'s "+N" chip uses the same. */
14
+ export declare function avatarShapeClass(shape: AvatarShape, size: AvatarSize): string;
15
+ /** The size classes of an Avatar, shared with `AvatarStack`'s "+N" chip. */
16
+ export declare function avatarSizeClass(size: AvatarSize): string;
17
+ /** The background class for a name: the same name gets the same colour everywhere. */
18
+ export declare function avatarColourClass(name: string): string;
19
+ /** Up to two letters or digits of a name, upper-cased; `?` when it has none. */
20
+ export declare function initialsOf(name: string): string;
21
+ type AvatarProps = {
22
+ /** Whose avatar: its initials, its colour (a hash of the name) and its title or `alt`. */
23
+ name: string;
24
+ /** The person's photo, a local path. It replaces the initials and is announced as `name`. */
25
+ src?: string | undefined;
26
+ /**
27
+ * `sm` 24px, `md` 32px, `lg` 40px, `xl` 56px or `2xl` 96px.
28
+ * @default md
29
+ */
30
+ size?: AvatarSize;
31
+ /**
32
+ * `round` for a person; `square` (rounded corners) for an org or a team, so the two never read
33
+ * as each other.
34
+ * @default round
35
+ */
36
+ shape?: AvatarShape;
37
+ } & Unstyled<Omit<HTMLAttributes<HTMLElement>, "children">>;
38
+ /**
39
+ * A person as a round badge of initials, or as their photo when `src` is given; `shape="square"`
40
+ * marks an org or a team instead. It never loads a remote image on its own: that is a request to
41
+ * another server for decoration, and it makes a screenshot depend on the network. `src` is the
42
+ * app's own local path. A row of overlapping avatars is an `AvatarStack`.
43
+ */
44
+ export declare function Avatar(props: AvatarProps): ReactElement;
45
+ /** Avatar plus the stack's ring: internal, so the ring only ever comes from `AvatarStack`. */
46
+ export declare function AvatarMark({ name, src, size, shape, ring, ...rest }: AvatarProps & {
47
+ ring?: string | false;
48
+ }): ReactElement;
49
+ export {};
package/dist/Avatar.js ADDED
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ /** The identity colours, in hash order. Each is a stock background utility; a theme remaps them. */
4
+ const COLOUR_CLASSES = [
5
+ "bg-violet-600",
6
+ "bg-orange-600",
7
+ "bg-cyan-600",
8
+ "bg-emerald-600",
9
+ "bg-sky-600",
10
+ "bg-pink-600",
11
+ "bg-amber-600",
12
+ ];
13
+ const SIZE_CLASSES = {
14
+ sm: "size-6 text-xs",
15
+ md: "size-8 text-xs",
16
+ lg: "size-10 text-base",
17
+ xl: "size-14 text-xl",
18
+ "2xl": "size-24 text-3xl",
19
+ };
20
+ const SQUARE_CLASSES = {
21
+ sm: "rounded",
22
+ md: "rounded-md",
23
+ lg: "rounded-md",
24
+ xl: "rounded-lg",
25
+ "2xl": "rounded-xl",
26
+ };
27
+ /** The corner classes for a shape at a size; `AvatarStack`'s "+N" chip uses the same. */
28
+ export function avatarShapeClass(shape, size) {
29
+ return shape === "square" ? SQUARE_CLASSES[size] : "rounded-full";
30
+ }
31
+ /** The size classes of an Avatar, shared with `AvatarStack`'s "+N" chip. */
32
+ export function avatarSizeClass(size) {
33
+ return SIZE_CLASSES[size];
34
+ }
35
+ /** The background class for a name: the same name gets the same colour everywhere. */
36
+ export function avatarColourClass(name) {
37
+ let hash = 0;
38
+ for (const char of name)
39
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
40
+ return COLOUR_CLASSES[hash % COLOUR_CLASSES.length] ?? COLOUR_CLASSES[0];
41
+ }
42
+ /** Up to two letters or digits of a name, upper-cased; `?` when it has none. */
43
+ export function initialsOf(name) {
44
+ return (name
45
+ .replace(/[^a-z0-9]/gi, "")
46
+ .slice(0, 2)
47
+ .toUpperCase() || "?");
48
+ }
49
+ /**
50
+ * A person as a round badge of initials, or as their photo when `src` is given; `shape="square"`
51
+ * marks an org or a team instead. It never loads a remote image on its own: that is a request to
52
+ * another server for decoration, and it makes a screenshot depend on the network. `src` is the
53
+ * app's own local path. A row of overlapping avatars is an `AvatarStack`.
54
+ */
55
+ export function Avatar(props) {
56
+ return _jsx(AvatarMark, { ...props });
57
+ }
58
+ /** Avatar plus the stack's ring: internal, so the ring only ever comes from `AvatarStack`. */
59
+ export function AvatarMark({ name, src, size = "md", shape = "round", ring = false, ...rest }) {
60
+ const corners = avatarShapeClass(shape, size);
61
+ if (src) {
62
+ return (_jsx("img", { src: src, alt: name, title: name, className: cx("shrink-0 object-cover", corners, SIZE_CLASSES[size], ring), ...rest }));
63
+ }
64
+ return (_jsx("span", { "aria-hidden": "true", title: name, className: cx("inline-grid shrink-0 place-items-center font-semibold text-violet-50", corners, SIZE_CLASSES[size], avatarColourClass(name), ring), ...rest, children: initialsOf(name) }));
65
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactElement } from "react";
2
+ import { type AvatarShape, type AvatarSize } from "./Avatar.js";
3
+ import type { Passthrough } from "./props.js";
4
+ /** One member of an `AvatarStack`: the same `name` and optional local `src` an `Avatar` takes. */
5
+ export type AvatarStackPerson = {
6
+ name: string;
7
+ src?: string | undefined;
8
+ };
9
+ /**
10
+ * Members as a row of overlapping avatars, each in a ring in the page colour that separates it from
11
+ * the next. With `max`, the first `max` show and a "+N" chip of the same size and shape stands for
12
+ * the rest. The row is one image to assistive tech, named by the people in it.
13
+ */
14
+ export declare function AvatarStack({ people, max, size, shape, ...rest }: {
15
+ /** Who is in the stack, in order. Give each person's full name, as for `Avatar`. */
16
+ people: readonly AvatarStackPerson[];
17
+ /** How many avatars show before a "+N" chip takes the rest. Without it every person shows. */
18
+ max?: number | undefined;
19
+ /**
20
+ * The size of every avatar and of the chip, as `Avatar`'s `size`.
21
+ * @default md
22
+ */
23
+ size?: AvatarSize;
24
+ /**
25
+ * `square` for a stack of orgs or teams, as `Avatar`'s `shape`.
26
+ * @default round
27
+ */
28
+ shape?: AvatarShape;
29
+ } & Passthrough): ReactElement;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AvatarMark, avatarShapeClass, avatarSizeClass, } from "./Avatar.js";
3
+ import { cx } from "./cx.js";
4
+ // Each avatar after the first overlaps the one before it by about a quarter of its width.
5
+ const OVERLAP = {
6
+ sm: "-ml-1.5",
7
+ md: "-ml-2",
8
+ lg: "-ml-2.5",
9
+ xl: "-ml-3.5",
10
+ "2xl": "-ml-6",
11
+ };
12
+ // The ring in the page colour that keeps two overlapping avatars apart.
13
+ const RING = "border-2 border-slate-50";
14
+ /**
15
+ * Members as a row of overlapping avatars, each in a ring in the page colour that separates it from
16
+ * the next. With `max`, the first `max` show and a "+N" chip of the same size and shape stands for
17
+ * the rest. The row is one image to assistive tech, named by the people in it.
18
+ */
19
+ export function AvatarStack({ people, max, size = "md", shape = "round", ...rest }) {
20
+ const shown = max === undefined ? people : people.slice(0, Math.max(max, 0));
21
+ const hidden = people.slice(shown.length);
22
+ const overlap = OVERLAP[size];
23
+ const names = shown.map((person) => person.name);
24
+ const label = hidden.length > 0 ? `${names.join(", ")} and ${hidden.length} more` : names.join(", ");
25
+ return (_jsxs("span", { role: "img", "aria-label": label, className: "inline-flex items-center", ...rest, children: [shown.map((person, index) => (_jsx(AvatarMark, { name: person.name, src: person.src, size: size, shape: shape, ring: cx(RING, index > 0 && overlap) }, `${index}:${person.name}`))), hidden.length > 0 && (_jsxs("span", { "aria-hidden": "true", title: hidden.map((person) => person.name).join(", "), className: cx("inline-grid shrink-0 place-items-center bg-slate-100 font-semibold text-slate-700 tabular-nums", RING, avatarSizeClass(size), avatarShapeClass(shape, size), shown.length > 0 && overlap), children: ["+", hidden.length] }))] }));
26
+ }
@@ -0,0 +1,14 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import type { Unstyled } from "./props.js";
3
+ /**
4
+ * A small red count, 10px, for sitting on an icon. `corner` pins it to the top-right corner of its
5
+ * `relative` parent (wrap the icon button and the badge in a `relative inline-flex` element); a
6
+ * count above 99 reads `99+`. Name the control it sits on (`aria-label` on the button) for a
7
+ * screen reader. An `IconButton` takes `badge` itself and does all of this.
8
+ */
9
+ export declare function Badge({ count, corner, ...rest }: {
10
+ /** The number shown; above 99 it reads `99+`. */
11
+ count: number;
12
+ /** Pin it to the top-right corner of its `relative` parent, over the icon. */
13
+ corner?: boolean;
14
+ } & Unstyled<Omit<HTMLAttributes<HTMLSpanElement>, "children">>): import("react").JSX.Element;
package/dist/Badge.js ADDED
@@ -0,0 +1,11 @@
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. `corner` pins it to the top-right corner of its
5
+ * `relative` parent (wrap the icon button and the badge in a `relative inline-flex` element); a
6
+ * count above 99 reads `99+`. Name the control it sits on (`aria-label` on the button) for a
7
+ * screen reader. An `IconButton` takes `badge` itself and does all of this.
8
+ */
9
+ export function Badge({ count, corner = false, ...rest }) {
10
+ return (_jsx("span", { ...rest, 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", corner && "absolute -right-1.5 -top-1.5"), children: count > 99 ? "99+" : count }));
11
+ }
@@ -0,0 +1,28 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { type LinkRender } from "./link.js";
3
+ import type { Passthrough } from "./props.js";
4
+ /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
5
+ export declare const crumbLinkClass: string;
6
+ /**
7
+ * A crumb that links: a plain `<a href>`, or with `render` the app's own router `<Link>`
8
+ * (`render: (p) => <Link to="/quotes" {...p} />`; `href` is then not used). `external` opens in a
9
+ * new tab. The last crumb is the current page and is never a link.
10
+ */
11
+ export type Crumb = {
12
+ label: ReactNode;
13
+ external?: boolean;
14
+ } & ({
15
+ href: string;
16
+ render?: LinkRender;
17
+ } | {
18
+ href?: string;
19
+ render: LinkRender;
20
+ });
21
+ /**
22
+ * `org / project / build / snapshot`: each crumb truncates, and the last is the current page. A
23
+ * crumb is a node, or a `Crumb` object when it links.
24
+ */
25
+ export declare function Breadcrumbs({ crumbs, ...rest }: {
26
+ /** The trail, first to current: a node, or a `Crumb` (`{ label, href | render, external? }`) for one that links. The last is the current page and never a link. */
27
+ crumbs: Array<Crumb | ReactNode>;
28
+ } & Passthrough): ReactElement;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
3
+ import { cx, focusRing } from "./cx.js";
4
+ import { renderLink } from "./link.js";
5
+ /** The classes of a crumb that links somewhere, for a router's own <Link>. The current page is plain text, not a link. */
6
+ export const crumbLinkClass = cx("rounded text-slate-500 hover:underline", focusRing);
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
+ */
27
+ export function Breadcrumbs({ crumbs, ...rest }) {
28
+ const last = crumbs.length - 1;
29
+ return (_jsx("nav", { ...rest, "aria-label": "Breadcrumb", className: "min-w-0", 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))) }) }));
30
+ }
@@ -0,0 +1,4 @@
1
+ import type { HTMLAttributes, ReactElement } from "react";
2
+ import type { Unstyled } from "./props.js";
3
+ /** A bulleted `<ul>`: discs, indented, a small gap between items. Its children are `<li>`s (e.g. `Text as="li"`). */
4
+ export declare function BulletList(props: Unstyled<HTMLAttributes<HTMLUListElement>>): ReactElement;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** A bulleted `<ul>`: discs, indented, a small gap between items. Its children are `<li>`s (e.g. `Text as="li"`). */
3
+ export function BulletList(props) {
4
+ return _jsx("ul", { ...props, className: "list-disc space-y-1 pl-5" });
5
+ }
@@ -0,0 +1,78 @@
1
+ import type { ComponentPropsWithoutRef, ReactElement } from "react";
2
+ import { type LinkRender } from "./link.js";
3
+ import type { Unstyled } from "./props.js";
4
+ import type { Size } from "./size.js";
5
+ export type ButtonVariant = "primary" | "outline" | "ghost" | "link" | "dashed";
6
+ export type ButtonSize = Size;
7
+ export type ButtonTone = "default" | "positive" | "danger";
8
+ /** Size, tone and variant: the look of a button, apart from its state. */
9
+ export type ButtonLook = {
10
+ /**
11
+ * `primary` is filled, for the one main action; `outline` and `ghost` are quieter; `link` is
12
+ * text with an underline on hover; `dashed` is an "add another" row that fills its container.
13
+ * @default primary
14
+ */
15
+ variant?: ButtonVariant | undefined;
16
+ /**
17
+ * `sm` is compact, for table rows and toolbars; `lg` is larger, for a hero call to action.
18
+ * @default md
19
+ */
20
+ size?: ButtonSize | undefined;
21
+ /**
22
+ * `positive` is green, for accept and approve; `danger` is red, for delete and clear. Not for
23
+ * `dashed`, which has the one look.
24
+ * @default default
25
+ */
26
+ tone?: ButtonTone | undefined;
27
+ };
28
+ /** The classes of an idle Button: what `href` draws, and what the tests compare against. */
29
+ export declare function buttonClass(variant?: ButtonVariant, { size, tone }?: Omit<ButtonLook, "variant">): string;
30
+ type ButtonAsButton = ButtonLook & {
31
+ /** Why the button cannot be pressed: disables it, sets it as the `title`, and greys it flat. */
32
+ disabledReason?: string | undefined;
33
+ /** The work is in flight: disabled, `aria-busy`, keeps its colour. Wins over `disabledReason`. */
34
+ busy?: boolean | undefined;
35
+ /** The label while `busy`, in place of the children. */
36
+ busyLabel?: string | undefined;
37
+ /**
38
+ * A toggle: sets `aria-pressed`. On `outline` and `ghost`, `false` draws the neutral off look
39
+ * and `true` the tone's primary fill, so a row of filter chips shows the selected one plainly.
40
+ * Left out, the button is not a toggle, has no `aria-pressed` and keeps its variant's look.
41
+ */
42
+ pressed?: boolean | undefined;
43
+ href?: undefined;
44
+ render?: undefined;
45
+ external?: undefined;
46
+ } & Unstyled<ComponentPropsWithoutRef<"button">>;
47
+ type ButtonAsLink = ButtonLook & {
48
+ /** Opens in a new tab: adds `target="_blank"` and `rel="noreferrer"`. */
49
+ external?: boolean | undefined;
50
+ disabledReason?: undefined;
51
+ busy?: undefined;
52
+ busyLabel?: undefined;
53
+ pressed?: undefined;
54
+ } & Unstyled<Omit<ComponentPropsWithoutRef<"a">, "href">> & ({
55
+ /** Makes the button a link to this URL. */
56
+ href: string;
57
+ /** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/quotes" {...p} />`. */
58
+ render?: LinkRender | undefined;
59
+ } | {
60
+ /** Makes the button a link to this URL. */
61
+ href?: string | undefined;
62
+ /** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/quotes" {...p} />`. */
63
+ render: LinkRender;
64
+ });
65
+ type ButtonProps = ButtonAsButton | ButtonAsLink;
66
+ /**
67
+ * A button in the primary, outline, ghost, link or dashed style (the link has no box: text with an
68
+ * underline on hover, for an in-line action; `dashed` is the full-width "add" row, a dashed
69
+ * outline with muted text that fills its container, with an optional leading icon in the
70
+ * children). `size="sm"` is compact and `size="lg"` larger; `tone="positive"` is green and `tone="danger"` is red;
71
+ * `pressed` makes it a toggle: neutral when off, the tone's fill when on.
72
+ * Defaults to `type="button"`. `disabledReason` and `busy` are the two disabled states that say
73
+ * more than a plain `disabled`, which keeps its half-opacity look. `href` makes it a link instead,
74
+ * with the same look (a plain `<a>`, or with `render` the app's own router `<Link>`:
75
+ * `render={(p) => <Link to="/quotes" {...p} />}`; `external` opens a new tab).
76
+ */
77
+ export declare function Button(props: ButtonProps): ReactElement;
78
+ export {};