@hackcove/ui-react 0.9.1 → 0.10.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 (60) hide show
  1. package/MIGRATING.md +21 -0
  2. package/README.md +31 -11
  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/Checkbox.d.ts +1 -1
  13. package/dist/Checkbox.js +1 -13
  14. package/dist/ColourSchemeToggle.d.ts +4 -2
  15. package/dist/ColourSchemeToggle.js +23 -10
  16. package/dist/Dialog.d.ts +2 -1
  17. package/dist/Dialog.js +44 -3
  18. package/dist/Disclosure.d.ts +10 -1
  19. package/dist/Disclosure.js +13 -3
  20. package/dist/Field.d.ts +9 -1
  21. package/dist/Field.js +5 -3
  22. package/dist/IconButton.d.ts +2 -0
  23. package/dist/IconButton.js +2 -2
  24. package/dist/Image.d.ts +14 -4
  25. package/dist/Image.js +22 -6
  26. package/dist/InlineEdit.d.ts +22 -0
  27. package/dist/InlineEdit.js +60 -0
  28. package/dist/KeyValueList.d.ts +9 -2
  29. package/dist/KeyValueList.js +8 -3
  30. package/dist/Lightbox.d.ts +2 -0
  31. package/dist/Lightbox.js +2 -1
  32. package/dist/ListRow.d.ts +11 -0
  33. package/dist/ListRow.js +28 -6
  34. package/dist/MediaTile.d.ts +11 -3
  35. package/dist/MediaTile.js +10 -7
  36. package/dist/PageLayouts.d.ts +22 -21
  37. package/dist/PageLayouts.js +17 -21
  38. package/dist/SideColumn.d.ts +26 -0
  39. package/dist/SideColumn.js +22 -0
  40. package/dist/SidebarLayout.d.ts +24 -0
  41. package/dist/SidebarLayout.js +22 -0
  42. package/dist/Stack.d.ts +10 -2
  43. package/dist/Stack.js +5 -3
  44. package/dist/StatusPages.d.ts +13 -3
  45. package/dist/StatusPages.js +8 -5
  46. package/dist/Switch.d.ts +42 -0
  47. package/dist/Switch.js +56 -0
  48. package/dist/TableEditCell.d.ts +9 -7
  49. package/dist/TableEditCell.js +12 -53
  50. package/dist/foldedColumn.d.ts +18 -0
  51. package/dist/foldedColumn.js +45 -0
  52. package/dist/index.d.ts +5 -1
  53. package/dist/index.js +5 -1
  54. package/dist/labelWords.d.ts +12 -0
  55. package/dist/labelWords.js +14 -0
  56. package/dist/scrollStop.d.ts +27 -0
  57. package/dist/scrollStop.js +68 -0
  58. package/dist/stickyColumn.d.ts +17 -0
  59. package/dist/stickyColumn.js +17 -0
  60. package/package.json +2 -2
@@ -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
  }
@@ -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;
package/dist/Image.js CHANGED
@@ -9,7 +9,10 @@ const ASPECT = {
9
9
  "16:9": "aspect-video",
10
10
  "3:4": "aspect-[3/4]",
11
11
  "9:16": "aspect-[9/16]",
12
+ natural: "h-auto",
12
13
  };
14
+ /** What stands in for a `natural` picture that has none (yet): it has no proportions to keep. */
15
+ const PLACEHOLDER_ASPECT = "aspect-[4/3]";
13
16
  /**
14
17
  * `full` is the parent's width; a size is a real width that a narrower parent still caps. It is
15
18
  * a width and not a `max-width`, so it holds inside a shrink-to-fit parent, where a placeholder
@@ -22,21 +25,34 @@ export const IMAGE_WIDTH = {
22
25
  full: "w-full",
23
26
  };
24
27
  /** Every image sits on the inset surface with a hairline, so a letterbox or a pale picture has an edge. */
25
- const FRAME = "block rounded-lg border border-slate-200 bg-slate-100";
28
+ const FRAME = "block rounded-lg border border-slate-200";
29
+ const INSET = "bg-slate-100";
30
+ /**
31
+ * The white mat of `framed`. `bg-white/100` is the palette's media-matte role: a plain `bg-white` is
32
+ * the surface, which a dark theme darkens, and a scanner needs the code on white. Every theme keeps
33
+ * this one white, as it keeps the Lightbox scrim black.
34
+ */
35
+ const MATTE = "bg-white/100 p-3";
26
36
  const PICTURE = "M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.22a.75.75 0 0 0-1.06 0l-1.91 1.91.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.09a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z";
37
+ /** The fill and the fit of a picture: `framed` is a white mat around all of it. */
38
+ function pictureLook(fit, framed) {
39
+ if (framed)
40
+ return cx(MATTE, "object-contain");
41
+ return cx(INSET, fit === "cover" ? "object-cover" : "object-contain");
42
+ }
27
43
  /**
28
44
  * A picture in a fixed shape: `aspect` reserves the space before it loads, so the page does not
29
45
  * jump, and `fit` crops it to the shape or shows all of it. The corners are rounded and the frame
30
46
  * has a hairline. If the picture is missing or fails to load, a same-sized placeholder with a
31
- * picture mark stands in, still named by `alt`. A user's photo is an `Avatar`; a clickable
47
+ * 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
32
48
  * thumbnail with a label is a `MediaTile`.
33
49
  */
34
- export function Image({ src, alt, aspect = "4:3", fit = "cover", width = "full", ...rest }) {
50
+ export function Image({ src, alt, aspect = "4:3", fit = "cover", width = "full", framed = false, ...rest }) {
35
51
  // The `src` that failed to load: a new `src` gets its own try, so a broken one never sticks.
36
52
  const [failedSrc, setFailedSrc] = useState();
37
- const box = cx(FRAME, ASPECT[aspect], IMAGE_WIDTH[width]);
53
+ const box = cx(FRAME, IMAGE_WIDTH[width]);
38
54
  if (src && src !== failedSrc) {
39
- return (_jsx("img", { ...rest, src: src, alt: alt, className: cx(box, fit === "cover" ? "object-cover" : "object-contain"), onError: () => setFailedSrc(src) }));
55
+ return (_jsx("img", { ...rest, src: src, alt: alt, className: cx(box, ASPECT[aspect], pictureLook(fit, framed)), onError: () => setFailedSrc(src) }));
40
56
  }
41
- return (_jsx("span", { ...rest, ...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true }), className: cx(box, "grid place-items-center text-slate-400"), children: _jsx(Icon, { size: "lg", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: PICTURE }) }) }) }));
57
+ return (_jsx("span", { ...rest, ...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true }), className: cx(box, aspect === "natural" ? PLACEHOLDER_ASPECT : ASPECT[aspect], INSET, "grid place-items-center text-slate-400"), children: _jsx(Icon, { size: "lg", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: PICTURE }) }) }) }));
42
58
  }
@@ -0,0 +1,22 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ /**
4
+ * A value that opens an editor in place, as a part inside a `TableCell` that holds more than the
5
+ * value (an edit trigger in a column cell with other content, say). It shows `children` as a button with a dotted underline; a click, Enter
6
+ * or Space swaps it for `renderEditor(close)` and moves the focus into the editor. The part keeps
7
+ * its own open state. The editor calls `close` when it has saved, and Esc closes it: both put the
8
+ * focus back on the value. A press outside it or Tab out of it closes it too and leaves the focus
9
+ * where it went, so the editor saves its value on its own change or submit, never on a close. Put
10
+ * the control in the part itself, not in a popover of its own, which counts as outside. It takes
11
+ * the alignment of the cell around it. It is a block that bleeds half a rem into the padding of
12
+ * the cell it sits in, so it belongs inside a `TableCell`; a cell that holds only the value is a
13
+ * `TableEditCell`. `data-*` and `aria-*` go on the button, and on the editor's group while open.
14
+ */
15
+ export declare function InlineEdit({ label, renderEditor, children, ...rest }: {
16
+ /** What the button does, for a screen reader, heard after the value: "Edit category". It also names the editor. */
17
+ label: string;
18
+ /** The control that edits the value, drawn while open. `close` shuts it and returns the focus to the value. */
19
+ renderEditor: (close: () => void) => ReactNode;
20
+ /** The value, shown while closed. */
21
+ children: ReactNode;
22
+ } & Omit<Passthrough, "aria-label" | "aria-labelledby">): ReactElement;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { cx, focusRing } from "./cx.js";
4
+ /** What takes the focus when the editor opens, unless the editor already took it (`autoFocus`). */
5
+ const FOCUSABLE = "input, select, textarea, button, a[href]";
6
+ /** Whether `target` is a node outside `box`. */
7
+ function isOutside(box, target) {
8
+ return target instanceof Node && box !== null && !box.contains(target);
9
+ }
10
+ /** Closes the editor when a press lands outside `box`, for as long as `open`. */
11
+ function useDismissOnOutsidePress(box, open, dismiss) {
12
+ useEffect(() => {
13
+ if (!open)
14
+ return undefined;
15
+ const onPress = (event) => {
16
+ if (isOutside(box.current, event.target))
17
+ dismiss();
18
+ };
19
+ document.addEventListener("pointerdown", onPress);
20
+ return () => document.removeEventListener("pointerdown", onPress);
21
+ }, [box, open, dismiss]);
22
+ }
23
+ /**
24
+ * A value that opens an editor in place, as a part inside a `TableCell` that holds more than the
25
+ * value (an edit trigger in a column cell with other content, say). It shows `children` as a button with a dotted underline; a click, Enter
26
+ * or Space swaps it for `renderEditor(close)` and moves the focus into the editor. The part keeps
27
+ * its own open state. The editor calls `close` when it has saved, and Esc closes it: both put the
28
+ * focus back on the value. A press outside it or Tab out of it closes it too and leaves the focus
29
+ * where it went, so the editor saves its value on its own change or submit, never on a close. Put
30
+ * the control in the part itself, not in a popover of its own, which counts as outside. It takes
31
+ * the alignment of the cell around it. It is a block that bleeds half a rem into the padding of
32
+ * the cell it sits in, so it belongs inside a `TableCell`; a cell that holds only the value is a
33
+ * `TableEditCell`. `data-*` and `aria-*` go on the button, and on the editor's group while open.
34
+ */
35
+ export function InlineEdit({ label, renderEditor, children, ...rest }) {
36
+ const [phase, setPhase] = useState("closed");
37
+ const open = phase === "open";
38
+ const box = useRef(null);
39
+ const trigger = useRef(null);
40
+ const dismiss = useCallback(() => setPhase("closed"), []);
41
+ const close = () => setPhase("returning");
42
+ useDismissOnOutsidePress(box, open, dismiss);
43
+ useEffect(() => {
44
+ if (phase === "returning")
45
+ trigger.current?.focus();
46
+ if (phase === "open" && !box.current?.contains(document.activeElement))
47
+ box.current?.querySelector(FOCUSABLE)?.focus();
48
+ }, [phase]);
49
+ const onKeyDown = (event) => {
50
+ if (event.key !== "Escape")
51
+ return;
52
+ event.preventDefault();
53
+ event.stopPropagation();
54
+ close();
55
+ };
56
+ return open ? (_jsx("div", { ...rest, ref: box, role: "group", "aria-label": label, className: "-mx-2 -my-1", onKeyDown: onKeyDown, onBlur: (event) => {
57
+ if (isOutside(event.currentTarget, event.relatedTarget))
58
+ dismiss();
59
+ }, children: renderEditor(close) })) : (_jsxs("button", { ...rest, ref: trigger, type: "button", onClick: () => setPhase("open"), className: cx("-mx-2 -my-1 block w-[calc(100%+1rem)] cursor-pointer rounded-md px-2 py-1 [text-align:inherit] underline decoration-dotted underline-offset-4 transition hover:bg-slate-100", focusRing), children: [children, _jsxs("span", { className: "sr-only", children: [" ", label] })] }));
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { type HTMLAttributes, type ReactElement, type ReactNode } from "react";
2
2
  import type { Unstyled } from "./props.js";
3
+ import { type TextTone } from "./typography.js";
3
4
  type KeyValueAlign = "end" | "start";
4
5
  /**
5
6
  * A definition list of label/value rows. By default each label is on the left and its value
@@ -14,12 +15,18 @@ export declare function KeyValueList({ align, children, ...rest }: {
14
15
  align?: KeyValueAlign;
15
16
  children: ReactNode;
16
17
  } & Unstyled<HTMLAttributes<HTMLDListElement>>): ReactElement;
17
- /** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). `total` makes it the sum row: a rule above, strong text. */
18
- export declare function KeyValue({ label, total, children, ...rest }: {
18
+ /**
19
+ * One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an
20
+ * `align="start"` list). `total` makes it the sum row: a rule above, strong text. `tone` colours the
21
+ * value, on any row: a total that does not reconcile is `total tone="warn"`.
22
+ */
23
+ export declare function KeyValue({ label, total, tone, children, ...rest }: {
19
24
  /** The term, on the left. */
20
25
  label: ReactNode;
21
26
  /** The sum the rows above add up to: a firm rule above it and the label and value in strong ink. */
22
27
  total?: boolean | undefined;
28
+ /** The meaning of the value, in the colours of a `Table` cell's `tone` (`muted`, `positive`, `warn`, `danger`, `info`); the label keeps its ink. Colour alone does not say it, so the value or the label says it too: a sign, a word. */
29
+ tone?: TextTone | undefined;
23
30
  children: ReactNode;
24
31
  } & Unstyled<HTMLAttributes<HTMLDivElement>>): ReactElement;
25
32
  export {};
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext, } from "react";
3
3
  import { cx } from "./cx.js";
4
+ import { TEXT_TONE } from "./typography.js";
4
5
  const AlignContext = createContext("end");
5
6
  /**
6
7
  * A definition list of label/value rows. By default each label is on the left and its value
@@ -12,8 +13,12 @@ export function KeyValueList({ align = "end", children, ...rest }) {
12
13
  ? "grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1.5"
13
14
  : "flex flex-col gap-1.5", "text-sm"), children: _jsx(AlignContext.Provider, { value: align, children: children }) }));
14
15
  }
15
- /** One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an `align="start"` list). `total` makes it the sum row: a rule above, strong text. */
16
- export function KeyValue({ label, total = false, children, ...rest }) {
16
+ /**
17
+ * One row of a KeyValueList: a label on the left, a tabular value on the right (or beside it, in an
18
+ * `align="start"` list). `total` makes it the sum row: a rule above, strong text. `tone` colours the
19
+ * value, on any row: a total that does not reconcile is `total tone="warn"`.
20
+ */
21
+ export function KeyValue({ label, total = false, tone, children, ...rest }) {
17
22
  const start = useContext(AlignContext) === "start";
18
- return (_jsxs("div", { ...rest, className: cx(start ? "col-span-2 grid grid-cols-subgrid" : "flex justify-between gap-3", total && "border-t border-slate-300 pt-1.5 font-semibold"), children: [_jsx("dt", { className: total ? "text-slate-900" : "text-slate-600", children: label }), _jsx("dd", { className: cx(!start && "text-right", "tabular-nums"), children: children })] }));
23
+ return (_jsxs("div", { ...rest, className: cx(start ? "col-span-2 grid grid-cols-subgrid" : "flex justify-between gap-3", total && "border-t border-slate-300 pt-1.5 font-semibold"), children: [_jsx("dt", { className: total ? "text-slate-900" : "text-slate-600", children: label }), _jsx("dd", { className: cx(!start && "text-right", "tabular-nums", tone && TEXT_TONE[tone]), children: children })] }));
19
24
  }
@@ -1,5 +1,7 @@
1
1
  import { type ReactElement, type ReactNode } from "react";
2
2
  import type { Passthrough } from "./props.js";
3
+ /** The cross glyph on a 20px grid; `MediaTile`'s remove button draws the same one. */
4
+ export declare const CLOSE = "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z";
3
5
  export type LightboxProps = {
4
6
  /** The viewer's accessible name, e.g. "Photo from Anna". */
5
7
  label: string;
package/dist/Lightbox.js CHANGED
@@ -9,7 +9,8 @@ import { useModal } from "./modal.js";
9
9
  const VIEWER = "fixed inset-0 m-0 size-full max-h-none max-w-none flex-col border-0 bg-black/90 p-0 text-white opacity-0 transition-[opacity,overlay,display] transition-discrete duration-200 open:flex open:opacity-100 starting:open:opacity-0 motion-reduce:transition-none";
10
10
  /** A round control on the scrim, with a light focus ring: the accent is too dark on near-black in a light theme. */
11
11
  const CONTROL = "inline-flex size-11 shrink-0 items-center justify-center rounded-full text-white transition hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white";
12
- const CLOSE = "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z";
12
+ /** The cross glyph on a 20px grid; `MediaTile`'s remove button draws the same one. */
13
+ export const CLOSE = "M5.28 4.22a.75.75 0 0 0-1.06 1.06L8.94 10l-4.72 4.72a.75.75 0 1 0 1.06 1.06L10 11.06l4.72 4.72a.75.75 0 1 0 1.06-1.06L11.06 10l4.72-4.72a.75.75 0 0 0-1.06-1.06L10 8.94 5.28 4.22Z";
13
14
  const PREVIOUS = "M12.78 4.22a.75.75 0 0 1 0 1.06L8.06 10l4.72 4.72a.75.75 0 1 1-1.06 1.06l-5.25-5.25a.75.75 0 0 1 0-1.06l5.25-5.25a.75.75 0 0 1 1.06 0Z";
14
15
  const NEXT = "M7.22 4.22a.75.75 0 0 1 1.06 0l5.25 5.25a.75.75 0 0 1 0 1.06l-5.25 5.25a.75.75 0 1 1-1.06-1.06L11.94 10 7.22 5.28a.75.75 0 0 1 0-1.06Z";
15
16
  /** A round icon button on the scrim, named by `label`, which is also its tooltip. */
package/dist/ListRow.d.ts CHANGED
@@ -45,6 +45,7 @@ type NotStatic = {
45
45
  static?: undefined;
46
46
  actions?: undefined;
47
47
  revealOnHover?: undefined;
48
+ detail?: undefined;
48
49
  };
49
50
  type ListRowButtonProps = Shared & NotStatic & {
50
51
  /**
@@ -86,6 +87,13 @@ type ListRowStaticProps = Shared & {
86
87
  * wall of buttons. A touch screen has no hover, so they are always shown there.
87
88
  */
88
89
  revealOnHover?: boolean | undefined;
90
+ /**
91
+ * Content under the row, lined up with the label rather than the row's edge (past `leading`): the
92
+ * panel of a row that opens in place, such as a conversation under its chat. It sits inside the
93
+ * row, so `divided`, the fill and `revealOnHover` cover it too. Only a `static` row takes it: a
94
+ * button or a link row cannot hold controls.
95
+ */
96
+ detail?: ReactNode | undefined;
89
97
  expanded?: undefined;
90
98
  href?: undefined;
91
99
  render?: undefined;
@@ -106,6 +114,9 @@ export type ListRowProps = ListRowButtonProps | ListRowLinkProps | ListRowStatic
106
114
  * `<div>`, so it can hold block content (a name over a path). `static` makes the
107
115
  * row a plain `<div>` for one that holds its own buttons (a button cannot sit inside a button or a
108
116
  * link): `actions` follow `trailing`, and `revealOnHover` shows them only on hover or focus.
117
+ * A `static` row also takes `detail`, content under the row that starts where the label does (a
118
+ * conversation that opens under its chat); `divided` draws the line above the whole row, detail
119
+ * included, so a list of them is `divided={index > 0}` on each.
109
120
  */
110
121
  export declare function ListRow(props: ListRowProps): ReactElement;
111
122
  export {};