@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,11 @@
1
+ import { type RefObject } from "react";
2
+ /**
3
+ * Opens the `<dialog>` with `showModal()` on mount and closes it on unmount. Closing on unmount
4
+ * (not just on Esc or the backdrop) hands focus back to the opener and drops the inert page behind
5
+ * when the parent unmounts the dialog, and keeps StrictMode's second mount from reopening an open one.
6
+ *
7
+ * Returns the handler for the dialog's `close` event. A closed `<dialog>` fades or slides out with
8
+ * a transition, but only while it is still in the DOM, so the handler calls `onClose` once the exit
9
+ * transition has ended and the parent can then unmount it without cutting the animation short.
10
+ */
11
+ export declare function useModal(dialog: RefObject<HTMLDialogElement | null>, onClose: () => void): () => void;
package/dist/modal.js ADDED
@@ -0,0 +1,41 @@
1
+ import { useLayoutEffect, useRef } from "react";
2
+ /** Resolves when the transitions running on the dialog (and its backdrop) have ended. */
3
+ async function exitTransitions(dialog) {
4
+ // A browser without the Web Animations API (or jsdom) has nothing to wait for.
5
+ if (typeof dialog.getAnimations !== "function")
6
+ return;
7
+ const running = dialog.getAnimations({ subtree: true }).filter((a) => "transitionProperty" in a);
8
+ await Promise.allSettled(running.map((a) => a.finished));
9
+ }
10
+ /**
11
+ * Opens the `<dialog>` with `showModal()` on mount and closes it on unmount. Closing on unmount
12
+ * (not just on Esc or the backdrop) hands focus back to the opener and drops the inert page behind
13
+ * when the parent unmounts the dialog, and keeps StrictMode's second mount from reopening an open one.
14
+ *
15
+ * Returns the handler for the dialog's `close` event. A closed `<dialog>` fades or slides out with
16
+ * a transition, but only while it is still in the DOM, so the handler calls `onClose` once the exit
17
+ * transition has ended and the parent can then unmount it without cutting the animation short.
18
+ */
19
+ export function useModal(dialog, onClose) {
20
+ const mounted = useRef(false);
21
+ useLayoutEffect(() => {
22
+ const el = dialog.current;
23
+ mounted.current = true;
24
+ el?.showModal();
25
+ return () => {
26
+ mounted.current = false;
27
+ el?.close();
28
+ };
29
+ }, [dialog]);
30
+ return () => {
31
+ const el = dialog.current;
32
+ if (!el)
33
+ return;
34
+ void exitTransitions(el).then(() => {
35
+ // Skip a close that an unmount caused, and the one StrictMode's remount leaves behind (the
36
+ // dialog is open again by the time its event fires).
37
+ if (mounted.current && !el.open)
38
+ onClose();
39
+ });
40
+ };
41
+ }
@@ -0,0 +1,14 @@
1
+ import type { AriaAttributes } from "react";
2
+ import type { DataAttributes } from "./link.js";
3
+ /**
4
+ * Drops the two props that would let a consumer restyle a component. Components take no
5
+ * `className` or `style`: a look is a prop or a variant, never a class. Wrap any HTML or ARIA prop
6
+ * type a component spreads in this one.
7
+ */
8
+ export type Unstyled<T> = Omit<T, "className" | "style">;
9
+ /**
10
+ * What a component with no single natural element takes through to its root: `data-*` (an e2e
11
+ * hook, `data-testid`) and `aria-*`. Nothing else, so no handler, `id` or `role` the component
12
+ * would have to merge with its own.
13
+ */
14
+ export type Passthrough = AriaAttributes & DataAttributes;
package/dist/props.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import type { CalendarDate } from "@internationalized/date";
2
+ import { type FocusEvent, type ReactElement } from "react";
3
+ import type { FieldProps, Read } from "./DateField.js";
4
+ type ValueKeys = "value" | "defaultValue" | "onChange" | "minValue" | "maxValue" | "isRequired";
5
+ /** The value props, each of which may be passed as `undefined`. */
6
+ type ValueProps = {
7
+ [K in ValueKeys]?: FieldProps[K] | undefined;
8
+ };
9
+ /**
10
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
11
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
12
+ *
13
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
14
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
15
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
16
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
17
+ * `value` the parent sets that differs from the last report is passed through, and not reported
18
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
19
+ * segments: a partly filled required field blocks the form.
20
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
21
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
22
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
23
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
24
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
25
+ *
26
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
27
+ */
28
+ export declare function useShownDate(props: ValueProps): {
29
+ inner: CalendarDate | null;
30
+ setInner: import("react").Dispatch<import("react").SetStateAction<CalendarDate | null>>;
31
+ shown: string;
32
+ impossible: string | null;
33
+ onRead: (next: Read) => void;
34
+ validate: () => "Enter the whole date." | null;
35
+ leaving: boolean;
36
+ onBlurCapture: (event: FocusEvent<HTMLElement>) => void;
37
+ onLeft: () => void;
38
+ };
39
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
40
+ export declare function ShownInput({ name, shown, isDisabled, }: {
41
+ name: string | undefined;
42
+ shown: string;
43
+ isDisabled: boolean | undefined;
44
+ }): ReactElement | null;
45
+ export {};
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from "react";
3
+ import { flushSync } from "react-dom";
4
+ import { fromISODate, toISODate } from "./isoDate.js";
5
+ /** `iso` when it is a date within min/max, else `""`. */
6
+ function withinRange(iso, { minValue, maxValue }) {
7
+ const date = fromISODate(iso);
8
+ if (!date)
9
+ return "";
10
+ if (minValue && date.compare(minValue) < 0)
11
+ return "";
12
+ if (maxValue && date.compare(maxValue) > 0)
13
+ return "";
14
+ return iso;
15
+ }
16
+ /**
17
+ * The value the field reports is always what it shows: the typed date when it is complete, exists
18
+ * and is within min/max, else `null`. Two parts of React Aria disagree with that, so this owns both:
19
+ *
20
+ * - **The value.** React Aria keeps its last valid date behind an impossible or partly cleared
21
+ * display, and reports an out-of-range one. So the consumer's `onChange` is fired from what the
22
+ * segments read, once per change: a valid date turning invalid reports `null`. React Aria itself
23
+ * is controlled by `inner`, so a parent holding that `null` does not wipe the typed segments; a
24
+ * `value` the parent sets that differs from the last report is passed through, and not reported
25
+ * back. Because React Aria's own value goes stale, `validate` checks `isRequired` against the
26
+ * segments: a partly filled required field blocks the form.
27
+ * - **Keeping an impossible date.** When focus leaves a complete field, React Aria moves an
28
+ * impossible day to the month's last and reports that date (`confirmPlaceholder`); it skips this
29
+ * for a read-only field. So while the typed date is impossible, the field is read-only for exactly
30
+ * the blur that leaves it: `onBlurCapture` on a wrapper of the input turns it on before React
31
+ * Aria's blur handler runs, and the field's own `onBlur`, which runs after it, turns it off again.
32
+ *
33
+ * `shown` (`YYYY-MM-DD` or `""`) is what the hidden `name` input posts.
34
+ */
35
+ export function useShownDate(props) {
36
+ const { value, defaultValue, onChange, isRequired } = props;
37
+ const [inner, setInner] = useState(value ?? defaultValue ?? null);
38
+ const [read, setRead] = useState({
39
+ typed: toISODate(inner),
40
+ impossible: null,
41
+ partial: false,
42
+ });
43
+ const [leaving, setLeaving] = useState(false);
44
+ const shown = withinRange(read.typed, props);
45
+ const reported = useRef(shown);
46
+ const report = useRef(onChange);
47
+ useEffect(() => {
48
+ report.current = onChange;
49
+ });
50
+ const given = value === undefined ? undefined : toISODate(value);
51
+ const givenShown = withinRange(given ?? "", props);
52
+ const lastGiven = useRef(given);
53
+ useEffect(() => {
54
+ if (given === lastGiven.current)
55
+ return;
56
+ lastGiven.current = given;
57
+ if (given === undefined || given === reported.current)
58
+ return;
59
+ reported.current = givenShown;
60
+ setInner(fromISODate(given));
61
+ }, [given, givenShown]);
62
+ const onRead = useCallback((next) => setRead(next), []);
63
+ useEffect(() => {
64
+ if (shown === reported.current)
65
+ return;
66
+ reported.current = shown;
67
+ report.current?.(fromISODate(shown));
68
+ }, [shown]);
69
+ return {
70
+ inner,
71
+ setInner,
72
+ shown,
73
+ impossible: read.impossible,
74
+ onRead,
75
+ validate: () => (isRequired && read.partial ? "Enter the whole date." : null),
76
+ leaving,
77
+ onBlurCapture: (event) => {
78
+ if (read.impossible && !event.currentTarget.contains(event.relatedTarget)) {
79
+ flushSync(() => setLeaving(true));
80
+ }
81
+ },
82
+ onLeft: () => setLeaving(false),
83
+ };
84
+ }
85
+ /** The hidden input a form posts under `name`: the shown date as `YYYY-MM-DD`, or `""`. */
86
+ export function ShownInput({ name, shown, isDisabled, }) {
87
+ return name ? _jsx("input", { type: "hidden", name: name, value: shown, disabled: isDisabled }) : null;
88
+ }
package/dist/size.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The one size scale of the library: `sm`, `md` (the default everywhere) and `lg`. A component
3
+ * extends it outward only where it has a real use (Avatar's `xl` and `2xl`, the gap scale's `xs`
4
+ * and `xl`), and no scale skips `md`.
5
+ */
6
+ export type Size = "sm" | "md" | "lg";
package/dist/size.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ import { type DataAttributes, type LinkRender } from "./link.js";
4
+ type Option = DataAttributes & {
5
+ label: ReactNode;
6
+ /** A small muted count after the label, part of the accessible name ("Shots 12"). */
7
+ count?: number;
8
+ /** A native tooltip; on a disabled option it is the reason. */
9
+ title?: string;
10
+ };
11
+ /** An option that is a button: it calls `onChange(value)`. */
12
+ export type SwitchButton = Option & {
13
+ /** What `value` is set to when this option is current. */
14
+ value: string;
15
+ /**
16
+ * Not chosen by click or arrow key. The button stays in the page as `aria-disabled`, so its
17
+ * `title` still shows on hover and is read out.
18
+ */
19
+ disabled?: boolean;
20
+ /** The id of the element this option controls (a tab's panel). */
21
+ controls?: string;
22
+ href?: undefined;
23
+ render?: undefined;
24
+ external?: undefined;
25
+ };
26
+ /**
27
+ * An option that is a link: `href`, or `render` to draw the app's own router `<Link>`. The router
28
+ * changes the page; `active` on the item, or a `value` equal to the group's, marks the current one.
29
+ * `external` opens a new tab.
30
+ */
31
+ export type SwitchLink = Option & {
32
+ /** Marks this link current when it equals the group's `value`. */
33
+ value?: string;
34
+ /** This link is the current page. */
35
+ active?: boolean;
36
+ disabled?: undefined;
37
+ controls?: undefined;
38
+ external?: boolean;
39
+ } & ({
40
+ href: string;
41
+ render?: LinkRender;
42
+ } | {
43
+ href?: string;
44
+ render: LinkRender;
45
+ });
46
+ /** One option of a Tabs or SegmentedControl: a button or a link, never both in one group. */
47
+ export type SwitchItem = SwitchButton | SwitchLink;
48
+ type Shared = {
49
+ /** The accessible name of the group. */
50
+ label: string;
51
+ } & Passthrough;
52
+ export type SwitchProps = Shared & ({
53
+ /** The options in order: `{ value, label }` buttons, or links (`href` or `render`) with `value` marking the current one. Either may carry a `count`; a button may be `disabled`. */
54
+ items: SwitchButton[];
55
+ /** The `value` of the current option. */
56
+ value: string;
57
+ /** Called with the option's `value` when one is chosen, by click or arrow key. */
58
+ onChange: (value: string) => void;
59
+ /** Every option is disabled, e.g. until the page has hydrated. */
60
+ disabled?: boolean;
61
+ } | {
62
+ /** The options in order: links (`href` or `render`), the current one marked by its `active` or by a `value` equal to the group's. A link may carry a `count`. */
63
+ items: SwitchLink[];
64
+ /** The `value` of the current link, as an alternative to `active` on the item. */
65
+ value?: string;
66
+ onChange?: undefined;
67
+ disabled?: undefined;
68
+ });
69
+ type Parts = {
70
+ /** The roles of a group of buttons; a group with links is a `<nav>` instead. */
71
+ group: "tablist" | "radiogroup";
72
+ item: "tab" | "radio";
73
+ /** The vertical arrows move too, as in a radio group. */
74
+ vertical: boolean;
75
+ /** The options stack in a column: a tablist says so with `aria-orientation`. */
76
+ stacked?: boolean;
77
+ root: string;
78
+ option: (active: boolean, disabled: boolean) => string;
79
+ };
80
+ /**
81
+ * The shared body of Tabs and SegmentedControl: a row of options with one current. Buttons form a
82
+ * `tablist` or `radiogroup` with a single tab stop and arrow-key movement (the arrow focuses and
83
+ * chooses); links form a `<nav>` with `aria-current="page"` on the current one.
84
+ */
85
+ export declare function Switcher({ items, value, onChange, disabled: groupDisabled, label, parts, ...rest }: SwitchProps & {
86
+ parts: Parts;
87
+ }): ReactElement;
88
+ export {};
@@ -0,0 +1,71 @@
1
+ import { createElement as _createElement } from "react";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import {} from "react";
4
+ import { cx } from "./cx.js";
5
+ import { dataAttributes, renderLink } from "./link.js";
6
+ /** The index a key moves to from `at` among `count` enabled options, wrapping; -1 for a key that does not move. */
7
+ function target(key, at, count, vertical) {
8
+ if (key === "ArrowRight" || (vertical && key === "ArrowDown"))
9
+ return (at + 1) % count;
10
+ if (key === "ArrowLeft" || (vertical && key === "ArrowUp"))
11
+ return (at - 1 + count) % count;
12
+ if (key === "Home")
13
+ return 0;
14
+ if (key === "End")
15
+ return count - 1;
16
+ return -1;
17
+ }
18
+ /** The label with its count after it; the plain-space text node keeps the name "Shots 12". */
19
+ function Label({ item }) {
20
+ if (item.count === undefined)
21
+ return _jsx(_Fragment, { children: item.label });
22
+ return (_jsxs(_Fragment, { children: [item.label, " ", _jsx("span", { className: "ml-0.5 text-xs tabular-nums opacity-70", children: item.count })] }));
23
+ }
24
+ const isLinks = (items) => items.some((item) => item.href !== undefined || item.render !== undefined);
25
+ const isCurrent = (item, value) => item.active === true || (item.value !== undefined && item.value === value);
26
+ function SwitchLinkItem({ item, current, parts, }) {
27
+ return renderLink({
28
+ ...dataAttributes(item),
29
+ href: item.href,
30
+ render: item.render,
31
+ external: item.external,
32
+ className: parts.option(current, false),
33
+ "aria-current": current ? "page" : undefined,
34
+ ...(item.title === undefined ? {} : { title: item.title }),
35
+ children: _jsx(Label, { item: item }),
36
+ });
37
+ }
38
+ /**
39
+ * The shared body of Tabs and SegmentedControl: a row of options with one current. Buttons form a
40
+ * `tablist` or `radiogroup` with a single tab stop and arrow-key movement (the arrow focuses and
41
+ * chooses); links form a `<nav>` with `aria-current="page"` on the current one.
42
+ */
43
+ export function Switcher({ items, value, onChange, disabled: groupDisabled, label, parts, ...rest }) {
44
+ const root = parts.root;
45
+ if (isLinks(items)) {
46
+ return (_jsx("nav", { ...rest, "aria-label": label, className: root, children: items.map((item, index) => (_jsx(SwitchLinkItem, { item: item, current: isCurrent(item, value), parts: parts }, item.value ?? item.href ?? index))) }));
47
+ }
48
+ const off = (item) => groupDisabled === true || item.disabled === true;
49
+ const enabled = items.filter((item) => !off(item));
50
+ const stop = enabled.some((item) => item.value === value) ? value : enabled[0]?.value;
51
+ function onKeyDown(event) {
52
+ const buttons = [...event.currentTarget.querySelectorAll("button:not([aria-disabled=true])")];
53
+ const at = buttons.findIndex((button) => button === event.target);
54
+ if (at < 0)
55
+ return;
56
+ const to = target(event.key, at, buttons.length, parts.vertical);
57
+ const button = buttons[to];
58
+ if (!(button instanceof HTMLButtonElement))
59
+ return;
60
+ event.preventDefault();
61
+ button.focus();
62
+ button.click();
63
+ }
64
+ return (_jsx("div", { ...rest, role: parts.group, "aria-label": label, ...(parts.stacked ? { "aria-orientation": "vertical" } : {}), ...(groupDisabled ? { "aria-disabled": true } : {}), onKeyDown: onKeyDown, className: root, children: items.map((item) => (_createElement("button", { ...dataAttributes(item), key: item.value, type: "button", role: parts.item, ...(parts.item === "tab"
65
+ ? { "aria-selected": item.value === value }
66
+ : { "aria-checked": item.value === value }), ...(item.controls === undefined ? {} : { "aria-controls": item.controls }), tabIndex: item.value === stop ? 0 : -1, ...(off(item) ? { "aria-disabled": true } : {}), ...(item.title === undefined ? {} : { title: item.title }), onClick: () => {
67
+ if (!off(item))
68
+ onChange?.(item.value);
69
+ }, className: cx(parts.option(item.value === value, off(item)), off(item) && "cursor-not-allowed opacity-50") },
70
+ _jsx(Label, { item: item })))) }));
71
+ }
@@ -0,0 +1,123 @@
1
+ import { type ComponentPropsWithoutRef, type ElementType, type ReactElement, type ReactNode } from "react";
2
+ import type { Size } from "./size.js";
3
+ export type TypographyColor = "default" | "secondary" | "tertiary" | "muted" | "brand";
4
+ /**
5
+ * The meaning of a run of inline text: the muted tone plus the Callout's, for "Saved." or an error
6
+ * message. Colour alone does not say it, so the words do too. Same names and text colours as a
7
+ * `Table` cell's `tone`.
8
+ */
9
+ export type TextTone = "muted" | "positive" | "warn" | "danger" | "info";
10
+ /** Tone → text utility. Shared with `Table`'s cell tones, so the two never drift. */
11
+ export declare const TEXT_TONE: Record<TextTone, string>;
12
+ /** Own props shared by every typography component; the rest follow the rendered element. */
13
+ type OwnProps = {
14
+ /** Colour token; each component sets a sensible default. */
15
+ color?: TypographyColor;
16
+ children?: ReactNode;
17
+ };
18
+ /** The opt-in props of the inline roles (`Text`, `Caption`, `Overline`). */
19
+ type InlineOwnProps = {
20
+ /** A meaning's colour (see `TextTone`). Wins over `color`. */
21
+ tone?: TextTone | undefined;
22
+ /** Tabular figures, so a column of amounts lines up. */
23
+ numeric?: boolean | undefined;
24
+ /** The monospace face, for an id, a path or a command. */
25
+ mono?: boolean | undefined;
26
+ /** One line, cut with an ellipsis where the container ends; the full text is the `title`. */
27
+ truncate?: boolean | undefined;
28
+ };
29
+ type Own = OwnProps & InlineOwnProps;
30
+ /** Polymorphic props: own props + `as` + the intrinsic props of whichever element `as` selects. */
31
+ export type TypographyProps<E extends ElementType> = OwnProps & {
32
+ /** The element to render (each role has a semantic default); the other props follow it. */
33
+ as?: E;
34
+ } & Omit<ComponentPropsWithoutRef<E>, keyof OwnProps | "as" | "className" | "style">;
35
+ /**
36
+ * The props of an inline role: `TypographyProps` plus `tone`, `numeric`, `mono` and `truncate`.
37
+ * Spelled out like `TypographyProps`, not as an `Omit` of it, so the docs read its real types.
38
+ */
39
+ export type InlineTypographyProps<E extends ElementType> = Own & {
40
+ /** The element to render (each role has a semantic default); the other props follow it. */
41
+ as?: E;
42
+ } & Omit<ComponentPropsWithoutRef<E>, keyof Own | "as" | "className" | "style">;
43
+ /**
44
+ * Marketing-scale headline — the one line at the top of a public page. `h1`,
45
+ * text-4xl/tight semibold, primary text, growing to text-6xl on wider screens.
46
+ * `PageHeading`'s scale is deliberately dev-tool sized; a brochure
47
+ * hero needs a display scale, so it gets its own pinned role rather than callers
48
+ * overriding PageHeading's size.
49
+ */
50
+ export declare const Display: {
51
+ <E extends ElementType = "h1">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
52
+ displayName: string;
53
+ };
54
+ /** The supporting sentence under a `Display`. `p`, text-lg/7 normal, secondary. */
55
+ export declare const Lead: {
56
+ <E extends ElementType = "p">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
57
+ displayName: string;
58
+ };
59
+ /** Page title. `h1`, text-2xl/8 semibold, primary text. */
60
+ export declare const PageHeading: {
61
+ <E extends ElementType = "h1">(props: TypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
62
+ displayName: string;
63
+ };
64
+ /**
65
+ * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
66
+ * a hairline under it (the `Divider` line colour) with the space either side of
67
+ * the line built in.
68
+ */
69
+ export declare function SectionHeading<E extends ElementType = "h2">({ rule, ...props }: TypographyProps<E> & {
70
+ /** A hairline under the heading, with the space either side of it built in. */
71
+ rule?: boolean;
72
+ }): ReactElement;
73
+ export type SubsectionHeadingSize = Size;
74
+ /**
75
+ * Nested header. `h3`, text-base/6 semibold, primary text. `size` steps it down for a dense card
76
+ * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. `truncate` keeps it on
77
+ * one line and cuts it with an ellipsis where its container ends, the full text as the `title`.
78
+ */
79
+ export declare function SubsectionHeading<E extends ElementType = "h3">({ size, ...props }: TypographyProps<E> & {
80
+ /**
81
+ * The heading's type size: `sm` is text-sm/5, `md` text-base/6 and `lg` text-lg/7.
82
+ * @default md
83
+ */
84
+ size?: SubsectionHeadingSize;
85
+ /** One line, cut with an ellipsis where the container ends; the full text becomes the `title`. */
86
+ truncate?: boolean | undefined;
87
+ }): ReactElement;
88
+ /**
89
+ * Small all-caps label above a group. `span`, text-xs semibold uppercase, tertiary. `tone`,
90
+ * `numeric`, `mono` and `truncate` are opt-in, as on `Text`.
91
+ */
92
+ export declare const Overline: {
93
+ <E extends ElementType = "span">(props: InlineTypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
94
+ displayName: string;
95
+ };
96
+ /**
97
+ * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. A block of it
98
+ * stops at a readable line length (`max-w-[72ch]`), wherever it sits. `strong` emphasises it:
99
+ * font-medium in the primary ink, unless `color` or `tone` says otherwise.
100
+ * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
101
+ * `tone` colours it by meaning (`positive` for "Saved.", `danger` for an error; it wins over `color`),
102
+ * `numeric` sets tabular figures, `mono` the monospace face, and `truncate` cuts it to one line with an
103
+ * ellipsis (the full text becomes its `title`).
104
+ */
105
+ export declare function Text<E extends ElementType = "span">({ strong, preserveNewlines, ...props }: InlineTypographyProps<E> & {
106
+ /** Emphasis: font-medium in the primary ink (`color` and `tone` still win). */
107
+ strong?: boolean | undefined;
108
+ /** Keep the line breaks in the text (a user-typed note); runs of spaces still collapse. */
109
+ preserveNewlines?: boolean;
110
+ }): ReactElement;
111
+ /**
112
+ * The supporting line under a `PageHeader` or `Dialog` title: `Text as="p"` without the measure, so
113
+ * it runs the width of its header. Internal; not exported from the package.
114
+ */
115
+ export declare function HeaderText({ children }: {
116
+ children: ReactNode;
117
+ }): ReactElement;
118
+ /** Fine print / metadata. `span` (inline; `as="p"` for a block), text-xs/4 normal, tertiary. Takes `tone`, `numeric`, `mono` and `truncate` like `Text`. */
119
+ export declare const Caption: {
120
+ <E extends ElementType = "span">(props: InlineTypographyProps<E>): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
121
+ displayName: string;
122
+ };
123
+ export {};
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Children, } from "react";
3
+ import { cx } from "./cx.js";
4
+ /** Colour token → slate/violet utility, which a theme remaps. */
5
+ const COLOR = {
6
+ default: "text-slate-900",
7
+ secondary: "text-slate-600",
8
+ tertiary: "text-slate-500",
9
+ muted: "text-slate-400",
10
+ brand: "text-violet-600",
11
+ };
12
+ /** Tone → text utility. Shared with `Table`'s cell tones, so the two never drift. */
13
+ export const TEXT_TONE = {
14
+ muted: "text-slate-500",
15
+ info: "text-blue-700",
16
+ positive: "text-emerald-700",
17
+ warn: "text-amber-700",
18
+ danger: "text-red-700",
19
+ };
20
+ /**
21
+ * Build a semantic component that pins `variantClass` (size + weight + tracking)
22
+ * and a default colour. The scale/weight for each role therefore lives in exactly
23
+ * one place (the call below), and callers can only vary the colour and the element —
24
+ * never the pinned size/weight, and never with a class.
25
+ */
26
+ function typography(displayName, defaultAs, variantClass, defaultColor) {
27
+ function Typography(props) {
28
+ return pinned(defaultAs, variantClass, defaultColor, props);
29
+ }
30
+ Typography.displayName = displayName;
31
+ return Typography;
32
+ }
33
+ /** `typography` for the inline roles, which also take `tone`, `numeric`, `mono` and `truncate`. */
34
+ function inlineRole(displayName, defaultAs, variantClass, defaultColor) {
35
+ function Inline(props) {
36
+ return pinned(defaultAs, variantClass, defaultColor, props);
37
+ }
38
+ Inline.displayName = displayName;
39
+ return Inline;
40
+ }
41
+ /**
42
+ * The one place a pinned role renders. The few roles with an opt-in prop of their own are
43
+ * written by hand, not by `typography`, and call this too.
44
+ */
45
+ function pinned(defaultAs, variantClass, defaultColor, { as, color, tone, numeric, mono, truncate, children, ...rest }) {
46
+ const Tag = as ?? defaultAs;
47
+ return (_jsx(Tag, { ...rest, ...(truncate ? { title: rest.title ?? plainText(children) } : {}), className: cx(variantClass, tone ? TEXT_TONE[tone] : COLOR[color ?? defaultColor], numeric && "tabular-nums", mono && "font-mono", truncate && "inline-block min-w-0 truncate align-bottom"), children: children }));
48
+ }
49
+ /** The text of `children` when it is all strings and numbers: what a truncated run shows on hover. */
50
+ function plainText(children) {
51
+ const parts = Children.toArray(children);
52
+ return parts.every((part) => typeof part === "string" || typeof part === "number")
53
+ ? parts.join("")
54
+ : undefined;
55
+ }
56
+ // --- Structural roles (block by default) -----------------------------------
57
+ /**
58
+ * Marketing-scale headline — the one line at the top of a public page. `h1`,
59
+ * text-4xl/tight semibold, primary text, growing to text-6xl on wider screens.
60
+ * `PageHeading`'s scale is deliberately dev-tool sized; a brochure
61
+ * hero needs a display scale, so it gets its own pinned role rather than callers
62
+ * overriding PageHeading's size.
63
+ */
64
+ export const Display = typography("Display", "h1", "text-4xl/[1.1] font-semibold tracking-tight sm:text-5xl/[1.05] lg:text-6xl/[1.05]", "default");
65
+ /** The supporting sentence under a `Display`. `p`, text-lg/7 normal, secondary. */
66
+ export const Lead = typography("Lead", "p", "text-lg/7 font-normal sm:text-xl/8", "secondary");
67
+ /** Page title. `h1`, text-2xl/8 semibold, primary text. */
68
+ export const PageHeading = typography("PageHeading", "h1", "text-2xl/8 font-semibold tracking-tight", "default");
69
+ /**
70
+ * Top-level section header. `h2`, text-base/6 semibold, primary text. `rule` sets
71
+ * a hairline under it (the `Divider` line colour) with the space either side of
72
+ * the line built in.
73
+ */
74
+ export function SectionHeading({ rule, ...props }) {
75
+ const variant = cx(rule && "mb-4 border-b border-slate-200/70 pb-2", "text-base/6 font-semibold");
76
+ return pinned("h2", variant, "default", props);
77
+ }
78
+ const SUBSECTION_SIZE = {
79
+ sm: "text-sm/5 font-semibold",
80
+ md: "text-base/6 font-semibold",
81
+ lg: "text-lg/7 font-semibold",
82
+ };
83
+ /**
84
+ * Nested header. `h3`, text-base/6 semibold, primary text. `size` steps it down for a dense card
85
+ * or up for a drawer or form-group title: `sm` (text-sm), `md` (default) or `lg`. `truncate` keeps it on
86
+ * one line and cuts it with an ellipsis where its container ends, the full text as the `title`.
87
+ */
88
+ export function SubsectionHeading({ size = "md", ...props }) {
89
+ return pinned("h3", SUBSECTION_SIZE[size], "default", props);
90
+ }
91
+ // --- Inline roles (inline by default) --------------------------------------
92
+ /**
93
+ * Small all-caps label above a group. `span`, text-xs semibold uppercase, tertiary. `tone`,
94
+ * `numeric`, `mono` and `truncate` are opt-in, as on `Text`.
95
+ */
96
+ export const Overline = inlineRole("Overline", "span", "text-xs font-semibold uppercase tracking-wide", "tertiary");
97
+ /**
98
+ * Running text. `span` (inline; `as="p"` for a paragraph), text-sm/5 normal, secondary. A block of it
99
+ * stops at a readable line length (`max-w-[72ch]`), wherever it sits. `strong` emphasises it:
100
+ * font-medium in the primary ink, unless `color` or `tone` says otherwise.
101
+ * `preserveNewlines` keeps the line breaks in the text (a user-typed note); runs of spaces still collapse.
102
+ * `tone` colours it by meaning (`positive` for "Saved.", `danger` for an error; it wins over `color`),
103
+ * `numeric` sets tabular figures, `mono` the monospace face, and `truncate` cuts it to one line with an
104
+ * ellipsis (the full text becomes its `title`).
105
+ */
106
+ export function Text({ strong, preserveNewlines, ...props }) {
107
+ return pinned("span", textVariant(strong, preserveNewlines, true), strong ? "default" : "secondary", props);
108
+ }
109
+ /**
110
+ * The supporting line under a `PageHeader` or `Dialog` title: `Text as="p"` without the measure, so
111
+ * it runs the width of its header. Internal; not exported from the package.
112
+ */
113
+ export function HeaderText({ children }) {
114
+ return pinned("p", textVariant(false, false, false), "secondary", { children });
115
+ }
116
+ function textVariant(strong, preserveNewlines, measure) {
117
+ return cx(preserveNewlines && "whitespace-pre-line", measure && "max-w-[72ch]", "text-sm/5", strong ? "font-medium" : "font-normal");
118
+ }
119
+ /** Fine print / metadata. `span` (inline; `as="p"` for a block), text-xs/4 normal, tertiary. Takes `tone`, `numeric`, `mono` and `truncate` like `Text`. */
120
+ export const Caption = inlineRole("Caption", "span", "text-xs/4 font-normal", "tertiary");
package/dist/ui.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Passthrough } from "./props.js";
2
+ /** A coloured epic header row: a dot in the epic's colour + its label. */
3
+ export declare function EpicHeader({ color, label, ...rest }: {
4
+ /** The dot's colour, any CSS colour (the epic's own). */
5
+ color: string;
6
+ /** The epic's name. */
7
+ label: string;
8
+ } & Passthrough): import("react").JSX.Element;
9
+ /** Group items by their epic ref, ordered with real epics first (by ref) and "No epic" last. */
10
+ export declare function groupByEpic<T>(items: T[], epicOf: (t: T) => string | null): [string | null, T[]][];