@hackcove/ui-react 0.9.2 → 0.10.1

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 (74) hide show
  1. package/MIGRATING.md +21 -0
  2. package/README.md +24 -9
  3. package/dist/Avatar.d.ts +12 -5
  4. package/dist/Avatar.js +17 -12
  5. package/dist/AvatarStack.js +2 -1
  6. package/dist/Button.d.ts +15 -1
  7. package/dist/Button.js +17 -6
  8. package/dist/Card.d.ts +8 -1
  9. package/dist/Card.js +15 -6
  10. package/dist/CardParts.d.ts +2 -1
  11. package/dist/CardParts.js +6 -2
  12. package/dist/ChatComposer.d.ts +35 -0
  13. package/dist/ChatComposer.js +50 -0
  14. package/dist/ChatMessage.d.ts +26 -0
  15. package/dist/ChatMessage.js +21 -0
  16. package/dist/ChatThread.d.ts +25 -0
  17. package/dist/ChatThread.js +59 -0
  18. package/dist/Checkbox.d.ts +1 -1
  19. package/dist/Checkbox.js +1 -13
  20. package/dist/ColourSchemeToggle.d.ts +4 -2
  21. package/dist/ColourSchemeToggle.js +23 -10
  22. package/dist/Dialog.d.ts +2 -1
  23. package/dist/Dialog.js +44 -3
  24. package/dist/Disclosure.d.ts +10 -1
  25. package/dist/Disclosure.js +13 -3
  26. package/dist/Field.d.ts +9 -1
  27. package/dist/Field.js +5 -3
  28. package/dist/Icon.d.ts +4 -2
  29. package/dist/Icon.js +4 -2
  30. package/dist/IconButton.d.ts +2 -0
  31. package/dist/IconButton.js +2 -2
  32. package/dist/Image.d.ts +14 -4
  33. package/dist/Image.js +22 -6
  34. package/dist/InlineEdit.d.ts +22 -0
  35. package/dist/InlineEdit.js +60 -0
  36. package/dist/Input.js +1 -1
  37. package/dist/InputShell.d.ts +9 -1
  38. package/dist/InputShell.js +18 -6
  39. package/dist/KeyValueList.d.ts +9 -2
  40. package/dist/KeyValueList.js +8 -3
  41. package/dist/Lightbox.d.ts +2 -0
  42. package/dist/Lightbox.js +2 -1
  43. package/dist/ListRow.d.ts +11 -0
  44. package/dist/ListRow.js +28 -6
  45. package/dist/MediaTile.d.ts +11 -3
  46. package/dist/MediaTile.js +10 -7
  47. package/dist/PageLayouts.d.ts +9 -3
  48. package/dist/PageLayouts.js +9 -6
  49. package/dist/SideColumn.d.ts +26 -0
  50. package/dist/SideColumn.js +22 -0
  51. package/dist/SidebarLayout.d.ts +5 -2
  52. package/dist/SidebarLayout.js +7 -3
  53. package/dist/Stack.d.ts +10 -2
  54. package/dist/Stack.js +5 -3
  55. package/dist/StatusPages.d.ts +13 -3
  56. package/dist/StatusPages.js +8 -5
  57. package/dist/Switch.d.ts +42 -0
  58. package/dist/Switch.js +56 -0
  59. package/dist/TableEditCell.d.ts +9 -7
  60. package/dist/TableEditCell.js +12 -53
  61. package/dist/ToolCallCard.d.ts +16 -0
  62. package/dist/ToolCallCard.js +16 -0
  63. package/dist/control.js +1 -1
  64. package/dist/foldedColumn.d.ts +18 -0
  65. package/dist/foldedColumn.js +45 -0
  66. package/dist/icons.d.ts +3 -3
  67. package/dist/icons.js +28 -1
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.js +7 -0
  70. package/dist/labelWords.d.ts +12 -0
  71. package/dist/labelWords.js +14 -0
  72. package/dist/scrollStop.d.ts +27 -0
  73. package/dist/scrollStop.js +68 -0
  74. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef, useState, } from "react";
3
+ import { Button } from "./Button.js";
4
+ import { Cluster, ClusterGrow } from "./Cluster.js";
5
+ import { Field } from "./Field.js";
6
+ import { Textarea } from "./Textarea.js";
7
+ /**
8
+ * An IME is still composing. Safari ends the composition before the Enter's keydown, so there
9
+ * `isComposing` is already false and only the 229 keyCode tells the commit from a real Enter.
10
+ */
11
+ function isComposing({ nativeEvent }) {
12
+ return nativeEvent.isComposing || nativeEvent.keyCode === 229;
13
+ }
14
+ /**
15
+ * The box a message is typed into, with its button beside it. It keeps its own text. Enter sends
16
+ * the trimmed text and clears the box; Shift+Enter is a newline; empty or whitespace-only text does
17
+ * not send. While `busy` a reply is on its way: the button is Stop, Enter does not send, and typing
18
+ * carries on with the text kept for the next send.
19
+ */
20
+ export function ChatComposer({ label, placeholder, disabled = false, busy = false, onSend, onStop, sendLabel = "Send", stopLabel = "Stop", ...rest }) {
21
+ const id = useId();
22
+ const box = useRef(null);
23
+ const [text, setText] = useState("");
24
+ const trimmed = text.trim();
25
+ const stop = busy && onStop !== undefined;
26
+ const send = () => {
27
+ if (busy || trimmed === "")
28
+ return;
29
+ onSend(trimmed);
30
+ setText("");
31
+ };
32
+ const onKeyDown = (event) => {
33
+ if (event.key !== "Enter" || event.shiftKey || isComposing(event))
34
+ return;
35
+ event.preventDefault();
36
+ send();
37
+ };
38
+ // One submit button that is Send or Stop, so it is the same element across the swap and keeps its
39
+ // focus. Send goes back to the box: the empty box has just disabled the button under the focus.
40
+ const onSubmit = (event) => {
41
+ event.preventDefault();
42
+ if (busy) {
43
+ onStop?.();
44
+ return;
45
+ }
46
+ send();
47
+ box.current?.focus();
48
+ };
49
+ return (_jsx("form", { ...rest, onSubmit: onSubmit, children: _jsxs(Cluster, { align: "end", gap: "sm", children: [_jsx(ClusterGrow, { children: _jsx(Field, { label: label, htmlFor: id, hideLabel: true, children: _jsx(Textarea, { ref: box, id: id, rows: 2, value: text, placeholder: placeholder, disabled: disabled, onChange: (event) => setText(event.target.value), onKeyDown: onKeyDown }) }) }), _jsx(Button, { type: "submit", variant: stop ? "outline" : "primary", disabled: disabled || (busy ? !stop : trimmed === ""), children: stop ? stopLabel : sendLabel })] }) }));
50
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /**
4
+ * One message in a chat: a bubble on the sender's side, with an optional line above it and a row of
5
+ * actions under it. `children` is the body as the caller wants it (plain text, which keeps its line
6
+ * breaks, a `Markdown`, an `Image`). `streaming` marks a reply still arriving; with no children it
7
+ * draws the caret alone, the pending state.
8
+ */
9
+ export declare function ChatMessage({ role, author, meta, actions, streaming, children, ...rest }: {
10
+ /** Whose side it sits on: `user` on the right on the inset fill, `assistant` on the left on the surface. */
11
+ role: "user" | "assistant";
12
+ /** Who sent it, read first by a screen reader and drawn nowhere: alignment alone tells a screen reader nothing. Show a sender with `meta`. */
13
+ author: string;
14
+ /** A small line above the bubble: a sender and a time, already formatted. */
15
+ meta?: ReactNode;
16
+ /** A row under the bubble on the message's own side: copy, retry, a row of reaction `Pill`s. */
17
+ actions?: ReactNode;
18
+ /**
19
+ * The reply is still arriving: sets `aria-busy` and draws a caret after the body, which pulses
20
+ * unless the reader asks for reduced motion.
21
+ * @default false
22
+ */
23
+ streaming?: boolean | undefined;
24
+ /** The body. It may be empty while `streaming`. */
25
+ children?: ReactNode;
26
+ } & Passthrough): ReactElement;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ const BUBBLE = "max-w-[90%] rounded-xl px-3 py-2 text-sm text-slate-900";
4
+ const ROLE = {
5
+ user: { side: "items-end", row: "justify-end", bubble: "bg-slate-100" },
6
+ assistant: {
7
+ side: "items-start",
8
+ row: "justify-start",
9
+ bubble: "border border-slate-200 bg-white",
10
+ },
11
+ };
12
+ /**
13
+ * One message in a chat: a bubble on the sender's side, with an optional line above it and a row of
14
+ * actions under it. `children` is the body as the caller wants it (plain text, which keeps its line
15
+ * breaks, a `Markdown`, an `Image`). `streaming` marks a reply still arriving; with no children it
16
+ * draws the caret alone, the pending state.
17
+ */
18
+ export function ChatMessage({ role, author, meta, actions, streaming = false, children, ...rest }) {
19
+ const look = ROLE[role];
20
+ return (_jsxs("article", { ...rest, "aria-busy": streaming || undefined, className: cx("flex w-full min-w-0 flex-col gap-1", look.side), children: [_jsx("span", { className: "sr-only", children: author }), meta && _jsx("div", { className: "text-xs text-slate-500", children: meta }), _jsxs("div", { className: cx(BUBBLE, look.bubble, "wrap-anywhere whitespace-pre-wrap"), children: [children, streaming && (_jsx("span", { "aria-hidden": "true", className: "text-slate-400 motion-safe:animate-pulse", children: "\u258D" }))] }), actions && (_jsx("div", { className: cx("flex flex-wrap items-center gap-1", look.row), children: actions }))] }));
21
+ }
@@ -0,0 +1,25 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /** Whether a box scrolled this far is at its bottom, give or take `BOTTOM_SLACK`. */
4
+ export declare function isAtBottom(scrollTop: number, clientHeight: number, scrollHeight: number): boolean;
5
+ /**
6
+ * The scrolling box of a chat: its children are the messages, tool cards and anything else, a gap
7
+ * apart. It follows the newest content unless the reader has scrolled up. It is a named region and
8
+ * a tab stop, so a keyboard can scroll it. It is deliberately not a live region, which would read
9
+ * every streamed token aloud: set `announce` when a message is finished.
10
+ */
11
+ export declare function ChatThread({ label, height, empty, announce, children, ...rest }: {
12
+ /** Names the region. */
13
+ label: string;
14
+ /**
15
+ * The tallest it gets before it scrolls: `sm` 20rem, `md` 28rem, `lg` 40rem. `fill` takes the
16
+ * height of a parent that sets one.
17
+ * @default md
18
+ */
19
+ height?: "sm" | "md" | "lg" | "fill" | undefined;
20
+ /** Drawn instead when there are no children: an `EmptyState`. */
21
+ empty?: ReactNode;
22
+ /** Text a screen reader reads out politely when it changes. Set it when a message is finished, not on each token. */
23
+ announce?: string | undefined;
24
+ children?: ReactNode;
25
+ } & Passthrough): ReactElement;
@@ -0,0 +1,59 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, useLayoutEffect, useRef } from "react";
3
+ import { cx, focusRing, themedScrollbar } from "./cx.js";
4
+ const HEIGHT = {
5
+ sm: "max-h-80",
6
+ md: "max-h-[28rem]",
7
+ lg: "max-h-[40rem]",
8
+ fill: "h-full min-h-0",
9
+ };
10
+ /** How far above the very bottom, in pixels, still counts as reading the latest message. */
11
+ const BOTTOM_SLACK = 32;
12
+ /** Whether a box scrolled this far is at its bottom, give or take `BOTTOM_SLACK`. */
13
+ export function isAtBottom(scrollTop, clientHeight, scrollHeight) {
14
+ return scrollHeight - scrollTop - clientHeight <= BOTTOM_SLACK;
15
+ }
16
+ /**
17
+ * Starts the box at the bottom and keeps it there as the content grows, until the reader scrolls
18
+ * up; scrolling back to the bottom re-arms it. The box is watched as well as the content: a box
19
+ * that gets shorter (a phone's keyboard opening under `fill`) has moved the bottom out of view too.
20
+ */
21
+ function useStickToBottom() {
22
+ const box = useRef(null);
23
+ const content = useRef(null);
24
+ const following = useRef(true);
25
+ useLayoutEffect(() => {
26
+ const scroller = box.current;
27
+ if (!scroller || !content.current)
28
+ return undefined;
29
+ const follow = () => {
30
+ if (following.current)
31
+ scroller.scrollTop = scroller.scrollHeight;
32
+ };
33
+ follow();
34
+ // jsdom (a consumer's own tests) has no ResizeObserver.
35
+ if (typeof ResizeObserver === "undefined")
36
+ return undefined;
37
+ const observer = new ResizeObserver(follow);
38
+ observer.observe(content.current);
39
+ observer.observe(scroller);
40
+ return () => observer.disconnect();
41
+ }, []);
42
+ const onScroll = () => {
43
+ const scroller = box.current;
44
+ if (!scroller)
45
+ return;
46
+ following.current = isAtBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
47
+ };
48
+ return { box, content, onScroll };
49
+ }
50
+ /**
51
+ * The scrolling box of a chat: its children are the messages, tool cards and anything else, a gap
52
+ * apart. It follows the newest content unless the reader has scrolled up. It is a named region and
53
+ * a tab stop, so a keyboard can scroll it. It is deliberately not a live region, which would read
54
+ * every streamed token aloud: set `announce` when a message is finished.
55
+ */
56
+ export function ChatThread({ label, height = "md", empty, announce, children, ...rest }) {
57
+ const { box, content, onScroll } = useStickToBottom();
58
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-live": "polite", className: "sr-only", children: announce }), _jsx("div", { ...rest, ref: box, role: "region", "aria-label": label, tabIndex: 0, onScroll: onScroll, className: cx("overflow-y-auto rounded-lg", HEIGHT[height], themedScrollbar, focusRing), children: _jsx("div", { ref: content, className: "flex flex-col gap-3 text-slate-900", children: Children.toArray(children).length === 0 ? empty : children }) })] }));
59
+ }
@@ -1,4 +1,4 @@
1
- import { type ComponentPropsWithoutRef, type ReactElement, type ReactNode } from "react";
1
+ import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
2
2
  import type { Unstyled } from "./props.js";
3
3
  import type { Size } from "./size.js";
4
4
  /**
package/dist/Checkbox.js CHANGED
@@ -1,24 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
3
2
  import { cx, focusRing } from "./cx.js";
3
+ import { useDescription, Words } from "./labelWords.js";
4
4
  /** The label's gap and text, the box, and the box's drop onto the first line, by `size`. */
5
5
  const SIZE = {
6
6
  sm: { label: "gap-1.5 text-xs", box: "h-3.5 w-3.5", start: "mt-px" },
7
7
  md: { label: "gap-2 text-sm", box: "h-4 w-4", start: "mt-0.5" },
8
8
  lg: { label: "gap-2.5 text-base", box: "h-5 w-5", start: "mt-0.5" },
9
9
  };
10
- /** The label, with its muted description under it when there is one. */
11
- function Words({ label, description, id, }) {
12
- if (id === undefined)
13
- return label;
14
- return (_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { children: label }), _jsx("span", { id: id, className: "text-xs text-slate-500", children: description })] }));
15
- }
16
- /** The description's id (none without a description) and the box's whole `aria-describedby`. */
17
- function useDescription(description, describedBy) {
18
- const generatedId = useId();
19
- const descriptionId = description != null && description !== false ? generatedId : undefined;
20
- return { descriptionId, describedIds: [describedBy, descriptionId].filter(Boolean).join(" ") };
21
- }
22
10
  /**
23
11
  * A native checkbox with its label. Every prop but the label goes on the input.
24
12
  * `size="sm"` is `text-xs` and a smaller box, for a dense row; `size="lg"` is `text-base` and a
@@ -9,8 +9,10 @@ import type { Size } from "./size.js";
9
9
  * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
10
10
  * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
11
11
  * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
12
- * The icon follows the class on `<html>` through CSS, so even without `initial` a dark page shows
13
- * the sun from the first frame; the button's name catches up once the client has hydrated.
12
+ * The icon and the button's accessible name follow the class on `<html>` through CSS (the name is
13
+ * two visually hidden "Switch to …" spans, one hidden by `display:none`), so even without `initial`
14
+ * a dark page shows the sun and is named "Switch to light theme" from the first frame, with no
15
+ * script. Only the tooltip catches up once the client has hydrated.
14
16
  * `store={false}` skips `localStorage`, for an app that keeps the choice in a server cookie and
15
17
  * writes it from `onChange`. Every other button prop (`id`, `data-*`, `aria-*`) goes on the button.
16
18
  */
@@ -1,24 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
3
  import { appliedColourScheme, applyColourScheme, storeColourScheme, } from "./colourScheme.js";
4
- import { IconButton } from "./IconButton.js";
4
+ import { look } from "./IconButton.js";
5
5
  const sun = (_jsxs("svg", { "data-scheme-icon": "sun", viewBox: "0 0 24 24", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "4" }), _jsx("path", { d: "M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" })] }));
6
6
  const moon = (_jsx("svg", { "data-scheme-icon": "moon", viewBox: "0 0 24 24", className: "h-4 w-4", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" }) }));
7
7
  // Both icons are always drawn and the page's own class picks one, so the first frame is right before
8
8
  // React runs: a dark visitor with no cookie has the dark class on <html> from the head script, but the
9
9
  // server rendered "light". The icon shows the scheme you would switch to: the moon on a light page,
10
- // the sun on a dark one. The classes are the app's (`schemes`), so the rule is written here: a dark
11
- // page has the dark class, or, when dark has none, lacks the light one.
12
- function iconRules(schemes) {
10
+ // the sun on a dark one. The button's name is picked the same way: both "Switch to … theme" spans are
11
+ // always drawn and `display:none` takes the wrong one out of the accessible name too. The classes are
12
+ // the app's (`schemes`), so the rule is written here: a dark page has the dark class, or, when dark
13
+ // has none, lacks the light one.
14
+ function schemeRules(schemes) {
13
15
  const sunIcon = '[data-scheme-icon="sun"]';
14
16
  const moonIcon = '[data-scheme-icon="moon"]';
17
+ const toLight = '[data-scheme-name="light"]';
18
+ const toDark = '[data-scheme-name="dark"]';
15
19
  let dark = "";
16
20
  if (schemes.dark !== "")
17
21
  dark = `:root[class~=${JSON.stringify(schemes.dark)}]`;
18
22
  else if (schemes.light !== "")
19
23
  dark = `:root:not([class~=${JSON.stringify(schemes.light)}])`;
20
- return (`${sunIcon}{display:none}` +
21
- (dark === "" ? "" : `${dark} ${sunIcon}{display:inline}${dark} ${moonIcon}{display:none}`));
24
+ return (`${sunIcon}{display:none}${toLight}{display:none}` +
25
+ (dark === ""
26
+ ? ""
27
+ : `${dark} ${sunIcon}{display:inline}${dark} ${moonIcon}{display:none}` +
28
+ `${dark} ${toLight}{display:inline}${dark} ${toDark}{display:none}`));
22
29
  }
23
30
  // The scheme's class on <html> is the source of truth: the head script or the server set it before React ran.
24
31
  function subscribe(onChange) {
@@ -26,6 +33,9 @@ function subscribe(onChange) {
26
33
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
27
34
  return () => observer.disconnect();
28
35
  }
36
+ // False in the server markup and while the client hydrates, true after: `title` is the one thing CSS
37
+ // cannot choose, so it is rendered only once the client knows the scheme.
38
+ const never = () => () => { };
29
39
  /**
30
40
  * A sun/moon button that switches `<html>` between light and dark and remembers the choice. It shows the scheme you would switch to. Pair it with `colourSchemeScript` in `<head>`, unless you pass `schemes` or `initial`: the script knows only the `dark` class and the stored choice, so an app that sets the scheme itself leaves it out.
31
41
  *
@@ -33,18 +43,21 @@ function subscribe(onChange) {
33
43
  * the toggle swaps those classes, and `schemeClass` gives the same one for the server to render on `<html>`.
34
44
  * An app that knows the scheme on the server (from a cookie) passes it as `initial`, so the server
35
45
  * markup, the hydrating client and the `<html>` class agree; persist a change from `onChange`.
36
- * The icon follows the class on `<html>` through CSS, so even without `initial` a dark page shows
37
- * the sun from the first frame; the button's name catches up once the client has hydrated.
46
+ * The icon and the button's accessible name follow the class on `<html>` through CSS (the name is
47
+ * two visually hidden "Switch to …" spans, one hidden by `display:none`), so even without `initial`
48
+ * a dark page shows the sun and is named "Switch to light theme" from the first frame, with no
49
+ * script. Only the tooltip catches up once the client has hydrated.
38
50
  * `store={false}` skips `localStorage`, for an app that keeps the choice in a server cookie and
39
51
  * writes it from `onChange`. Every other button prop (`id`, `data-*`, `aria-*`) goes on the button.
40
52
  */
41
53
  export function ColourSchemeToggle({ onChange, schemes = { light: "", dark: "dark" }, initial, store = true, size = "md", ...rest }) {
42
54
  const scheme = useSyncExternalStore(subscribe, () => appliedColourScheme(document.documentElement, schemes), () => initial ?? "light");
55
+ const hydrated = useSyncExternalStore(never, () => true, () => false);
43
56
  const next = scheme === "dark" ? "light" : "dark";
44
- return (_jsxs(IconButton, { ...rest, size: size, label: `Switch to ${next} theme`, onClick: () => {
57
+ return (_jsxs("button", { type: "button", ...rest, title: hydrated ? `Switch to ${next} theme` : undefined, className: look({ size }), onClick: () => {
45
58
  applyColourScheme(next, document.documentElement, schemes);
46
59
  if (store)
47
60
  storeColourScheme(next);
48
61
  onChange?.(next);
49
- }, children: [_jsx("style", { children: iconRules(schemes) }), sun, moon] }));
62
+ }, children: [_jsx("style", { children: schemeRules(schemes) }), sun, moon, _jsx("span", { className: "sr-only", "data-scheme-name": "dark", children: "Switch to dark theme" }), _jsx("span", { className: "sr-only", "data-scheme-name": "light", children: "Switch to light theme" })] }));
50
63
  }
package/dist/Dialog.d.ts CHANGED
@@ -23,6 +23,7 @@ export type DialogProps = {
23
23
  /**
24
24
  * A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
25
25
  * only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
26
- * on the backdrop (the `<dialog>` element itself, not its contents) closes it too.
26
+ * on the backdrop (the `<dialog>` element itself, not its contents) closes it too. A windowed dialog is
27
+ * never taller than the screen: a tall body scrolls and the title and footer stay on screen.
27
28
  */
28
29
  export declare function Dialog({ title, description, children, footer, size, onClose, ...rest }: DialogProps): ReactElement;
package/dist/Dialog.js CHANGED
@@ -1,24 +1,65 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId, useRef } from "react";
2
+ import { useId, useLayoutEffect, useRef, } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  import { useModal } from "./modal.js";
5
+ import { scrollStopProps, TABBABLE, useScrollStop } from "./scrollStop.js";
5
6
  import { HeaderText, SectionHeading } from "./typography.js";
6
7
  /** Shared by every size: the surface, its colours and the fade (and the scrim's) in and out. */
7
8
  const BASE = "bg-white p-0 text-slate-900 opacity-0 transition-[opacity,translate,overlay,display] transition-discrete duration-200 open:opacity-100 starting:open:opacity-0 backdrop:bg-slate-900/35 backdrop:opacity-0 backdrop:transition-[opacity,overlay,display] backdrop:transition-discrete backdrop:duration-200 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 motion-reduce:transition-none motion-reduce:backdrop:transition-none";
8
9
  /** A floating panel under the top edge: it also slides down 8px as it fades in. */
9
10
  const WINDOW = "mx-auto mt-28 max-w-[calc(100vw-2rem)] overflow-visible rounded-2xl border border-slate-200 shadow-2xl -translate-y-2 open:translate-y-0 starting:open:-translate-y-2";
10
11
  const WIDTH = { sm: "w-80", md: "w-110", lg: "w-160" };
12
+ /**
13
+ * A windowed dialog is as tall as its content up to the room under its top margin (7rem) with 1rem
14
+ * to spare and its 1px borders, and no taller: the body scrolls inside that cap, so the title and
15
+ * the footer stay on screen. The cap sits on the panel inside the `<dialog>`, which stays
16
+ * `overflow-visible` so a `DateField` calendar portalled into it is not clipped.
17
+ */
18
+ const CAP = "max-h-[calc(100dvh-8rem-2px)]";
19
+ /**
20
+ * The scrolling body clips what paints outside it, a field's focus ring included. Its padding keeps
21
+ * the ring whole (4px: a 2px outline at a 2px offset), and the negative margins give that room back,
22
+ * so the body sits where an unpadded one would.
23
+ */
24
+ const SCROLL_BODY = "mt-3 -mb-1 -mx-1 min-h-0 flex-1 scroll-py-1 overflow-y-auto px-1 pt-1 pb-1";
11
25
  /** The whole viewport, edge to edge: no margin, radius, border or shadow, and a column so the body can fill it. */
12
26
  const FULL = "fixed inset-0 m-0 size-full max-h-none max-w-none flex-col overflow-hidden rounded-none border-0 open:flex";
27
+ /**
28
+ * The body's keyboard side. In a windowed dialog Chromium's `showModal()` focuses a scrolling box
29
+ * ahead of the field inside it, though the other engines pick the field: when the open took the
30
+ * body, the focus goes to the first thing in the dialog the keyboard can reach, where it lands in a
31
+ * dialog that does not scroll. And a body that overflows and holds nothing the keyboard can reach
32
+ * takes a tab stop, so the arrow keys can scroll it. Every other body has none: `showModal()`
33
+ * focuses the first focusable element, and a tab stop on the body would take that from the field
34
+ * inside it. `size="full"` is left as it was.
35
+ */
36
+ function useScrollingBody(dialog, titleId, full) {
37
+ const { element: body, attach, stop } = useScrollStop();
38
+ const tabStop = stop && !full;
39
+ // After `useModal`'s own layout effect (the caller's earlier hook), so the dialog is open.
40
+ useLayoutEffect(() => {
41
+ if (!full && body && document.activeElement === body) {
42
+ dialog.current?.querySelector(TABBABLE)?.focus();
43
+ }
44
+ }, [body, dialog, full]);
45
+ const { props, ring } = scrollStopProps(tabStop, { labelledBy: titleId });
46
+ return {
47
+ attach,
48
+ props,
49
+ className: cx(full ? "mt-4 min-h-0 flex-1 overflow-auto" : SCROLL_BODY, ring),
50
+ };
51
+ }
13
52
  /**
14
53
  * A modal on the browser's own `<dialog>`: it is opened with `showModal()` on mount, so render it
15
54
  * only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click
16
- * on the backdrop (the `<dialog>` element itself, not its contents) closes it too.
55
+ * on the backdrop (the `<dialog>` element itself, not its contents) closes it too. A windowed dialog is
56
+ * never taller than the screen: a tall body scrolls and the title and footer stay on screen.
17
57
  */
18
58
  export function Dialog({ title, description, children, footer, size = "md", onClose, ...rest }) {
19
59
  const dialog = useRef(null);
20
60
  const titleId = useId();
21
61
  const closed = useModal(dialog, onClose);
22
62
  const full = size === "full";
23
- return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("p-5.5", full && "flex min-h-0 flex-1 flex-col"), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { className: cx("mt-4", full && "min-h-0 flex-1 overflow-auto"), children: children })), footer && (_jsx("div", { className: cx("mt-5 flex justify-end gap-2", full && "shrink-0"), children: footer }))] }) }));
63
+ const { attach, props: bodyProps, className: bodyClass, } = useScrollingBody(dialog, titleId, full);
64
+ return (_jsx("dialog", { ...rest, ref: dialog, "aria-labelledby": titleId, onClose: closed, onClick: (event) => event.target === dialog.current && dialog.current?.close(), className: cx(BASE, size === "full" ? FULL : `${WINDOW} ${WIDTH[size]}`), children: _jsxs("div", { className: cx("flex flex-col p-5.5", full ? "min-h-0 flex-1" : CAP), children: [_jsx(SectionHeading, { id: titleId, children: title }), description && (_jsx("div", { className: "mt-1", children: _jsx(HeaderText, { children: description }) })), children && (_jsx("div", { ref: attach, ...bodyProps, className: bodyClass, children: children })), footer && _jsx("div", { className: "mt-5 flex shrink-0 justify-end gap-2", children: footer })] }) }));
24
65
  }
@@ -4,12 +4,21 @@ import type { Passthrough } from "./props.js";
4
4
  * A summary line with a chevron over a body that opens and closes under it. The summary is a
5
5
  * button with `aria-expanded`, and Enter and Space toggle it; the body is hidden while closed.
6
6
  * It starts closed; `defaultOpen` starts it open. It keeps its own state.
7
+ *
8
+ * `bordered` draws it as a panel: a bordered box with the summary as a roomy row across the top
9
+ * (the hover fill of a `ListRow`) and the body padded under a hairline, for a section that opens in
10
+ * place in a page of cards. Plain, it is a line of text with the body indented under it.
7
11
  */
8
- export declare function Disclosure({ summary, defaultOpen, children, ...rest }: {
12
+ export declare function Disclosure({ summary, defaultOpen, bordered, children, ...rest }: {
9
13
  /** The always-visible line: the button that toggles the body. */
10
14
  summary: ReactNode;
11
15
  /** Starts with the body showing. */
12
16
  defaultOpen?: boolean | undefined;
17
+ /**
18
+ * Draws it as a bordered panel: the summary a full-width row, the body padded under a hairline.
19
+ * @default false
20
+ */
21
+ bordered?: boolean | undefined;
13
22
  /** The body, shown while open. */
14
23
  children: ReactNode;
15
24
  } & Passthrough): ReactElement;
@@ -1,12 +1,22 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Disclosure as AriaDisclosure, DisclosurePanel } from "react-aria-components";
3
3
  import { Chevron } from "./Chevron.js";
4
- import { focusRing } from "./cx.js";
4
+ import { cx, focusRing } from "./cx.js";
5
+ /** The panel form's box: the Card's border, fill and shadow, clipped to its radius. */
6
+ const FRAME = "overflow-hidden rounded-xl border border-slate-200/70 bg-white shadow-sm";
7
+ /** The body's own box, not the panel's: a collapsed panel still draws its border and padding. */
8
+ const PANEL = "border-t border-slate-200/70 p-4";
5
9
  /**
6
10
  * A summary line with a chevron over a body that opens and closes under it. The summary is a
7
11
  * button with `aria-expanded`, and Enter and Space toggle it; the body is hidden while closed.
8
12
  * It starts closed; `defaultOpen` starts it open. It keeps its own state.
13
+ *
14
+ * `bordered` draws it as a panel: a bordered box with the summary as a roomy row across the top
15
+ * (the hover fill of a `ListRow`) and the body padded under a hairline, for a section that opens in
16
+ * place in a page of cards. Plain, it is a line of text with the body indented under it.
9
17
  */
10
- export function Disclosure({ summary, defaultOpen = false, children, ...rest }) {
11
- return (_jsx("div", { ...rest, children: _jsx(AriaDisclosure, { defaultExpanded: defaultOpen, children: ({ isExpanded }) => (_jsxs(_Fragment, { children: [_jsxs(Button, { slot: "trigger", className: `flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 text-left text-sm font-medium text-slate-900 hover:text-violet-700 ${focusRing}`, children: [_jsx(Chevron, { open: isExpanded }), summary] }), _jsx(DisclosurePanel, { className: "pt-1 pb-2 pl-6 text-sm text-slate-700", children: children })] })) }) }));
18
+ export function Disclosure({ summary, defaultOpen = false, bordered = false, children, ...rest }) {
19
+ return (_jsx("div", { ...rest, className: bordered ? FRAME : undefined, children: _jsx(AriaDisclosure, { defaultExpanded: defaultOpen, children: ({ isExpanded }) => (_jsxs(_Fragment, { children: [_jsxs(Button, { slot: "trigger", className: cx("flex w-full cursor-pointer items-center gap-2 text-left text-sm font-medium text-slate-900", focusRing, bordered
20
+ ? "px-4 py-3 hover:bg-slate-50 focus-visible:-outline-offset-2"
21
+ : "rounded-md py-1.5 hover:text-violet-700"), children: [_jsx(Chevron, { open: isExpanded }), summary] }), _jsx(DisclosurePanel, { className: cx("text-sm text-slate-700", !bordered && "pt-1 pb-2 pl-6"), children: bordered ? _jsx("div", { className: PANEL, children: children }) : children })] })) }) }));
12
22
  }
package/dist/Field.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare function fieldNote(hint: ReactNode, error: ReactNode): ReactEleme
6
6
  * A label above a control, with a hint or an error line under it. An error replaces the hint.
7
7
  * Inside a `FormGrid`, `span="full"` runs the field across both columns.
8
8
  */
9
- export declare function Field({ label, htmlFor, hint, error, span, layout, children, ...rest }: {
9
+ export declare function Field({ label, htmlFor, hint, error, span, layout, hideLabel, children, ...rest }: {
10
10
  /** The text above the control. */
11
11
  label: ReactNode;
12
12
  /** The id of the control inside; the label's htmlFor. */
@@ -24,5 +24,13 @@ export declare function Field({ label, htmlFor, hint, error, span, layout, child
24
24
  * @default column
25
25
  */
26
26
  layout?: "column" | "row" | undefined;
27
+ /**
28
+ * Keeps the `label` in the page for assistive tech and draws no text, for a control whose purpose
29
+ * is plain from what surrounds it (a search box in a toolbar). `label` stays required, so the
30
+ * control always has a name. With `layout="row"` the field draws as a column: an empty label
31
+ * column helps nobody.
32
+ * @default false
33
+ */
34
+ hideLabel?: boolean | undefined;
27
35
  children: ReactNode;
28
36
  } & Passthrough): ReactElement;
package/dist/Field.js CHANGED
@@ -14,8 +14,10 @@ export function fieldNote(hint, error) {
14
14
  * A label above a control, with a hint or an error line under it. An error replaces the hint.
15
15
  * Inside a `FormGrid`, `span="full"` runs the field across both columns.
16
16
  */
17
- export function Field({ label, htmlFor, hint, error, span, layout = "column", children, ...rest }) {
18
- const row = layout === "row";
17
+ export function Field({ label, htmlFor, hint, error, span, layout = "column", hideLabel = false, children, ...rest }) {
18
+ const row = layout === "row" && !hideLabel;
19
19
  const note = fieldNote(hint, error);
20
- return (_jsxs("div", { ...rest, className: cx(row ? "grid grid-cols-[8rem_1fr] items-center gap-x-3 gap-y-1.5" : "flex flex-col gap-1.5", span === "full" && "col-span-full"), children: [_jsx("label", { htmlFor: htmlFor, className: cx("text-sm font-medium text-slate-700", row && "min-w-0 wrap-anywhere"), children: label }), children, note && row ? _jsx("div", { className: "col-start-2", children: note }) : note] }));
20
+ return (_jsxs("div", { ...rest, className: cx(row ? "grid grid-cols-[8rem_1fr] items-center gap-x-3 gap-y-1.5" : "flex flex-col gap-1.5", span === "full" && "col-span-full"), children: [_jsx("label", { htmlFor: htmlFor, className: hideLabel
21
+ ? "sr-only"
22
+ : cx("text-sm font-medium text-slate-700", row && "min-w-0 wrap-anywhere"), children: label }), children, note && row ? _jsx("div", { className: "col-start-2", children: note }) : note] }));
21
23
  }
package/dist/Icon.d.ts CHANGED
@@ -14,8 +14,10 @@ type IconProps = Passthrough & {
14
14
  /**
15
15
  * One inline icon, sized to a fixed box (sm 14px, md 16px, lg 20px, `feature` 32px) and drawn in
16
16
  * `currentColor`, so it takes the text colour around it unless a `tone` says otherwise. `name`
17
- * picks one of the library's own icons, all drawn on one grid with one stroke (`diff`,
18
- * `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`);
17
+ * picks one of the library's own icons, all drawn on one grid with one stroke: feature ideas
18
+ * (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`,
19
+ * `speed`, `search`) and UI glyphs (`chevron-right`, `chevron-down`, `close`, `external-link`,
20
+ * `settings`, `bell`, `check`, `plus`);
19
21
  * without a name, a bare `<svg>` child fills the box. It is decorative (`aria-hidden`) by
20
22
  * default; a `label` makes it an image with that name, for an icon that carries meaning no text
21
23
  * beside it says.
package/dist/Icon.js CHANGED
@@ -11,8 +11,10 @@ const BOX = {
11
11
  /**
12
12
  * One inline icon, sized to a fixed box (sm 14px, md 16px, lg 20px, `feature` 32px) and drawn in
13
13
  * `currentColor`, so it takes the text colour around it unless a `tone` says otherwise. `name`
14
- * picks one of the library's own icons, all drawn on one grid with one stroke (`diff`,
15
- * `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`, `speed`);
14
+ * picks one of the library's own icons, all drawn on one grid with one stroke: feature ideas
15
+ * (`diff`, `baseline`, `review`, `ci`, `self-host`, `privacy`, `capture`, `branch`, `theme`,
16
+ * `speed`, `search`) and UI glyphs (`chevron-right`, `chevron-down`, `close`, `external-link`,
17
+ * `settings`, `bell`, `check`, `plus`);
16
18
  * without a name, a bare `<svg>` child fills the box. It is decorative (`aria-hidden`) by
17
19
  * default; a `label` makes it an image with that name, for an icon that carries meaning no text
18
20
  * beside it says.
@@ -45,6 +45,8 @@ type IconButtonAsLink = IconButtonProps & {
45
45
  /** Draws the link with the app's own router `<Link>`: `(p) => <Link to="/settings" {...p} />`. */
46
46
  render: LinkRender;
47
47
  });
48
+ /** The classes for a variant, tone and size, each at its default when left out. Shared with `ColourSchemeToggle`, which draws its own `<button>`; not exported from `index.ts`. */
49
+ export declare function look({ variant, tone, size, }: Pick<IconButtonProps, "variant" | "tone" | "size">): string;
48
50
  /**
49
51
  * A square button holding one icon. `label` is its accessible name and tooltip. `variant="ghost"`
50
52
  * drops the border for a quiet action in a row or a toolbar; `size` matches the height of a
@@ -15,8 +15,8 @@ const LOOK = {
15
15
  ghost: "border-transparent text-red-700 hover:bg-red-50",
16
16
  },
17
17
  };
18
- /** The classes for a variant, tone and size, each at its default when left out. */
19
- function look({ variant = "outline", tone = "default", size = "md", }) {
18
+ /** The classes for a variant, tone and size, each at its default when left out. Shared with `ColourSchemeToggle`, which draws its own `<button>`; not exported from `index.ts`. */
19
+ export function look({ variant = "outline", tone = "default", size = "md", }) {
20
20
  return cx(BASE, SIZE[size], LOOK[tone][variant]);
21
21
  }
22
22
  function isLink(props) {
package/dist/Image.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { Passthrough } from "./props.js";
3
3
  import type { Size } from "./size.js";
4
- /** The shapes an image is cropped or letterboxed to. */
5
- export type ImageAspect = "square" | "4:3" | "3:2" | "16:9" | "3:4" | "9:16";
4
+ /**
5
+ * The shapes an image is cropped or letterboxed to, or `natural`: no shape of its own, so the
6
+ * picture keeps its own proportions.
7
+ */
8
+ export type ImageAspect = "square" | "4:3" | "3:2" | "16:9" | "3:4" | "9:16" | "natural";
6
9
  /** How wide an image is: `full` fills its parent; the others are 96, 160 or 256px, capped at the parent's width. */
7
10
  export type ImageWidth = Size | "full";
8
11
  /**
@@ -21,6 +24,8 @@ export type ImageProps = {
21
24
  alt: string;
22
25
  /**
23
26
  * The shape it is cropped to: `square`, `4:3`, `3:2`, `16:9`, or the portrait `3:4` and `9:16`.
27
+ * `natural` keeps the picture's own proportions instead, so nothing is cropped or letterboxed;
28
+ * the space is not reserved before it loads.
24
29
  * @default 4:3
25
30
  */
26
31
  aspect?: ImageAspect | undefined;
@@ -35,12 +40,17 @@ export type ImageProps = {
35
40
  * @default full
36
41
  */
37
42
  width?: ImageWidth | undefined;
43
+ /**
44
+ * Sets the picture on a white mat with padding, always white, in every theme: a QR code is
45
+ * scanned off a dark page. It shows the whole picture, as `fit="contain"`, whatever `fit` says.
46
+ */
47
+ framed?: boolean | undefined;
38
48
  } & Passthrough;
39
49
  /**
40
50
  * A picture in a fixed shape: `aspect` reserves the space before it loads, so the page does not
41
51
  * jump, and `fit` crops it to the shape or shows all of it. The corners are rounded and the frame
42
52
  * has a hairline. If the picture is missing or fails to load, a same-sized placeholder with a
43
- * picture mark stands in, still named by `alt`. A user's photo is an `Avatar`; a clickable
53
+ * picture mark stands in, still named by `alt`. `framed` mats a code or a logo in white. A user's photo is an `Avatar`; a clickable
44
54
  * thumbnail with a label is a `MediaTile`.
45
55
  */
46
- export declare function Image({ src, alt, aspect, fit, width, ...rest }: ImageProps): ReactElement;
56
+ export declare function Image({ src, alt, aspect, fit, width, framed, ...rest }: ImageProps): ReactElement;