@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
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
+ }
package/dist/Input.js CHANGED
@@ -13,7 +13,7 @@ export function Input({ icon, prefix, size = "md", width = "full", align = "star
13
13
  return _jsx(SearchInput, { ...rest, icon: icon, size: size, width: width, align: align });
14
14
  }
15
15
  if (prefix !== undefined) {
16
- return (_jsx(PrefixShell, { prefix: prefix, size: size, width: width, disabled: rest.disabled, children: _jsx("input", { ...rest, className: cx("min-w-0 flex-1 bg-transparent px-1 outline-none placeholder:text-slate-400", CONTROL_ALIGN[align]) }) }));
16
+ return (_jsx(PrefixShell, { prefix: prefix, size: size, width: width, disabled: rest.disabled, children: _jsx("input", { ...rest, className: cx("min-w-0 flex-1 bg-transparent px-1 text-ellipsis outline-none placeholder:text-slate-400", CONTROL_ALIGN[align]) }) }));
17
17
  }
18
18
  if (icon === undefined) {
19
19
  return (_jsx("input", { ...rest, className: cx(controlClass(size, width), CONTROL_PAD[size].x, CONTROL_ALIGN[align]) }));
@@ -1,6 +1,14 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import { type ControlProps } from "./control.js";
3
- /** The relative wrapper of an `Input` that holds more than the field: a leading `icon` and, for search, the clear button. Not exported from the package. */
3
+ /**
4
+ * The relative wrapper of an `Input` that holds more than the field: a leading `icon` and, for search, the clear button. Not exported from the package.
5
+ *
6
+ * Inside a Card or row (`liftInteractive`, which is `isolate`) the field is lifted to `z-10`, and
7
+ * its opaque background would paint over a z-auto icon. So the icon ranks `z-20` there. It is
8
+ * scoped to an `isolate` ancestor so a field elsewhere on the page never outranks AppShell's
9
+ * sticky header (`z-10`). The clear button is a lifted `button` and comes after the field, so it
10
+ * already sits above it.
11
+ */
4
12
  export declare function InputShell({ icon, size, width, children, }: {
5
13
  icon?: ReactNode;
6
14
  children: ReactNode;
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { CONTROL_WIDTH, controlClass } from "./control.js";
3
3
  import { cx } from "./cx.js";
4
- /** Where a leading icon sits, and its size, by the control's size. */
4
+ /**
5
+ * Where a leading icon sits, and its size, by the control's size. The wrapper is a flex box centred
6
+ * on the field, so the icon's own box centres too, and its one child is sized whether it is a bare
7
+ * `<svg>` or an `Icon`.
8
+ */
5
9
  const ICON = {
6
- sm: "left-2 [&>svg]:h-3.5 [&>svg]:w-3.5",
7
- md: "left-3 [&>svg]:h-4 [&>svg]:w-4",
8
- lg: "left-4 [&>svg]:h-5 [&>svg]:w-5",
10
+ sm: "left-2 [&>*]:size-3.5",
11
+ md: "left-3 [&>*]:size-4",
12
+ lg: "left-4 [&>*]:size-5",
9
13
  };
10
14
  /** The prefix text's inset and size, by the control's size. */
11
15
  const PREFIX = {
@@ -13,9 +17,17 @@ const PREFIX = {
13
17
  md: "pl-3 text-sm",
14
18
  lg: "pl-4 text-base",
15
19
  };
16
- /** The relative wrapper of an `Input` that holds more than the field: a leading `icon` and, for search, the clear button. Not exported from the package. */
20
+ /**
21
+ * The relative wrapper of an `Input` that holds more than the field: a leading `icon` and, for search, the clear button. Not exported from the package.
22
+ *
23
+ * Inside a Card or row (`liftInteractive`, which is `isolate`) the field is lifted to `z-10`, and
24
+ * its opaque background would paint over a z-auto icon. So the icon ranks `z-20` there. It is
25
+ * scoped to an `isolate` ancestor so a field elsewhere on the page never outranks AppShell's
26
+ * sticky header (`z-10`). The clear button is a lifted `button` and comes after the field, so it
27
+ * already sits above it.
28
+ */
17
29
  export function InputShell({ icon, size = "md", width = "full", children, }) {
18
- return (_jsxs("div", { className: cx("relative", width === "auto" && "inline-block", width !== "full" && CONTROL_WIDTH[width]), children: [icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cx("pointer-events-none absolute top-1/2 -translate-y-1/2 text-slate-400", ICON[size]), children: icon })), children] }));
30
+ return (_jsxs("div", { className: cx("relative", width === "auto" && "inline-block", width !== "full" && CONTROL_WIDTH[width]), children: [icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cx("pointer-events-none absolute top-1/2 flex -translate-y-1/2 items-center leading-none text-slate-400 in-[.isolate]:z-20", ICON[size]), children: icon })), children] }));
19
31
  }
20
32
  /**
21
33
  * The field box of an `Input` with a `prefix`: the control look and the focus ring sit on the
@@ -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 {};
package/dist/ListRow.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId, } from "react";
3
3
  import { Chevron } from "./Chevron.js";
4
4
  import { cx, focusRing, liftInteractive } from "./cx.js";
@@ -10,6 +10,15 @@ const ROW = `${ROW_LOOK} ${INSET_RING}`;
10
10
  const LINK_ROW = `relative ${ROW_LOOK} ${liftInteractive}`;
11
11
  const OVERLAY = `link-stretch absolute inset-0 ${INSET_RING}`;
12
12
  const STATIC = "group/row flex w-full items-center gap-3 text-sm text-slate-900";
13
+ /**
14
+ * A static row with `detail`: the row's own line (the label, `trailing`, `actions`) over the detail,
15
+ * `leading` in a column of its own beside both, so the detail starts where the label does.
16
+ */
17
+ const STATIC_DETAIL = "group/row grid w-full items-center gap-3 text-sm text-slate-900";
18
+ const DETAIL_COLUMNS = {
19
+ withLeading: "grid-cols-[auto_minmax(0,1fr)]",
20
+ alone: "grid-cols-[minmax(0,1fr)]",
21
+ };
13
22
  const FILL = "hover:bg-slate-50";
14
23
  const SELECTED = "bg-violet-50 hover:bg-violet-50";
15
24
  /**
@@ -32,9 +41,13 @@ function fillOf(props) {
32
41
  return ACTIVE;
33
42
  return props.static && !props.revealOnHover ? undefined : FILL;
34
43
  }
44
+ /** `detail={open && <Panel />}` is a common way to write it, so `false` is as empty as `null`. */
45
+ function hasDetail(detail) {
46
+ return detail != null && detail !== false;
47
+ }
35
48
  function rowBase(props) {
36
49
  if (props.static)
37
- return STATIC;
50
+ return hasDetail(props.detail) ? STATIC_DETAIL : STATIC;
38
51
  return props.href !== undefined || props.render !== undefined ? LINK_ROW : ROW;
39
52
  }
40
53
  function look(props) {
@@ -42,11 +55,17 @@ function look(props) {
42
55
  return cx(rowBase(props), PAD[size], divided && "border-t border-slate-200/70", fillOf(props), focused && FOCUSED);
43
56
  }
44
57
  function StaticRow(props) {
45
- const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active = false, selected, size: _s, divided: _d, static: _st, actions, revealOnHover = false, expanded: _e, href: _h, render: _r, start, label, end, ...rest } = props;
46
- return (_jsxs("div", { ...rest, ...(selected ? { "aria-current": "true" } : {}), className: look(props), children: [start, label, end, actions != null && (_jsx("span", { className: cx("flex shrink-0 items-center gap-2", revealOnHover && !active && REVEAL), children: actions }))] }));
58
+ const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active = false, selected, size: _s, divided: _d, static: _st, actions, revealOnHover = false, detail, expanded: _e, href: _h, render: _r, start, label, end, ...rest } = props;
59
+ const buttons = actions != null && (_jsx("span", { className: cx("flex shrink-0 items-center gap-2", revealOnHover && !active && REVEAL), children: actions }));
60
+ const withDetail = hasDetail(detail);
61
+ return (_jsx("div", { ...rest, ...(selected ? { "aria-current": "true" } : {}), className: cx(look(props), withDetail && DETAIL_COLUMNS[start ? "withLeading" : "alone"]), children: withDetail ? (_jsxs(DetailLines, { start: start, detail: detail, children: [label, end, buttons] })) : (_jsxs(_Fragment, { children: [start, label, end, buttons] })) }));
62
+ }
63
+ /** The cells of a static row with `detail`: `start` beside the row's line (its children), over the detail in the label's column. */
64
+ function DetailLines({ start, detail, children, }) {
65
+ return (_jsxs(_Fragment, { children: [start, _jsx("div", { className: "flex min-w-0 items-center gap-3", children: children }), _jsx("div", { className: cx("min-w-0", Boolean(start) && "col-start-2"), children: detail })] }));
47
66
  }
48
67
  function LinkRow(props) {
49
- const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded: _e, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, start, label, end, onPointerEnter, onPointerLeave, ...rest } = props;
68
+ const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded: _e, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, detail: _dt, start, label, end, onPointerEnter, onPointerLeave, ...rest } = props;
50
69
  const rowId = useId();
51
70
  return (_jsxs("div", { id: rowId, onPointerEnter: onPointerEnter, onPointerLeave: onPointerLeave, className: look(props), children: [renderLink({
52
71
  ...(rest["aria-label"] === undefined ? { "aria-labelledby": rowId } : {}),
@@ -57,7 +76,7 @@ function LinkRow(props) {
57
76
  }), start, label, end] }));
58
77
  }
59
78
  function ButtonRow(props) {
60
- const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, href: _h, render: _r, start, label, end, ...rest } = props;
79
+ const { leading: _l, secondary: _sec, leadingWidth: _lw, trailing: _t, focused: _f, active: _ac, selected, expanded, size: _s, divided: _d, static: _st, actions: _a, revealOnHover: _rh, detail: _dt, href: _h, render: _r, start, label, end, ...rest } = props;
61
80
  return (_jsxs("button", { type: "button", ...rest, ...(selected ? { "aria-current": "true" } : {}), ...(expanded === undefined ? {} : { "aria-expanded": expanded }), className: look(props), children: [expanded !== undefined && _jsx(Chevron, { open: expanded }), start, label, end] }));
62
81
  }
63
82
  /**
@@ -75,6 +94,9 @@ function ButtonRow(props) {
75
94
  * `<div>`, so it can hold block content (a name over a path). `static` makes the
76
95
  * row a plain `<div>` for one that holds its own buttons (a button cannot sit inside a button or a
77
96
  * link): `actions` follow `trailing`, and `revealOnHover` shows them only on hover or focus.
97
+ * A `static` row also takes `detail`, content under the row that starts where the label does (a
98
+ * conversation that opens under its chat); `divided` draws the line above the whole row, detail
99
+ * included, so a list of them is `divided={index > 0}` on each.
78
100
  */
79
101
  export function ListRow(props) {
80
102
  const { leading, leadingWidth, trailing, secondary, children } = props;
@@ -11,7 +11,8 @@ export type MediaTileProps = {
11
11
  /** Muted text under the picture, e.g. when. */
12
12
  caption?: ReactNode;
13
13
  /**
14
- * The shape of the thumbnail, as `Image`'s `aspect`.
14
+ * The shape of the thumbnail, as `Image`'s `aspect`; `natural` keeps the picture's own
15
+ * proportions (a chat image) and crops nothing.
15
16
  * @default 3:4
16
17
  */
17
18
  aspect?: ImageAspect | undefined;
@@ -27,11 +28,18 @@ export type MediaTileProps = {
27
28
  * non-interactive block.
28
29
  */
29
30
  onClick?: (() => void) | undefined;
31
+ /**
32
+ * Adds a round remove button on the picture's top-right corner, named "Remove" and `alt`: a
33
+ * photo attached to a form, until it is sent. It is its own button, beside the tile's, so a tile
34
+ * with `onClick` still opens its `Lightbox`.
35
+ */
36
+ onRemove?: (() => void) | undefined;
30
37
  } & Passthrough;
31
38
  /**
32
39
  * A thumbnail tile: a picture in a fixed shape with an optional label on its bottom edge and a
33
40
  * caption under it. With `onClick` the whole tile is one button, which is how a grid or a
34
41
  * `ScrollStrip` of media opens each item in a `Lightbox`. The label sits on the same near-black
35
- * media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape.
42
+ * media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape, or with
43
+ * `aspect="natural"` shown whole. `onRemove` adds a remove button for a tile that can be taken back out.
36
44
  */
37
- export declare function MediaTile({ src, alt, label, caption, aspect, width, video, onClick, ...rest }: MediaTileProps): ReactElement;
45
+ export declare function MediaTile({ src, alt, label, caption, aspect, width, video, onClick, onRemove, ...rest }: MediaTileProps): ReactElement;
package/dist/MediaTile.js CHANGED
@@ -2,18 +2,21 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { cx, focusRing } from "./cx.js";
3
3
  import { Icon } from "./Icon.js";
4
4
  import { IMAGE_WIDTH, Image } from "./Image.js";
5
+ import { CLOSE } from "./Lightbox.js";
5
6
  const PLAY = "M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.34-5.89a1.5 1.5 0 0 0 0-2.54L6.3 2.84Z";
6
7
  /**
7
8
  * A thumbnail tile: a picture in a fixed shape with an optional label on its bottom edge and a
8
9
  * caption under it. With `onClick` the whole tile is one button, which is how a grid or a
9
10
  * `ScrollStrip` of media opens each item in a `Lightbox`. The label sits on the same near-black
10
- * media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape.
11
+ * media scrim as the Lightbox, in every theme. The thumbnail is cropped to fill its shape, or with
12
+ * `aspect="natural"` shown whole. `onRemove` adds a remove button for a tile that can be taken back out.
11
13
  */
12
- export function MediaTile({ src, alt, label, caption, aspect = "3:4", width = "md", video = false, onClick, ...rest }) {
14
+ export function MediaTile({ src, alt, label, caption, aspect = "3:4", width = "md", video = false, onClick, onRemove, ...rest }) {
13
15
  const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative block overflow-hidden rounded-lg", children: [_jsx(Image, { src: src, alt: alt, aspect: aspect }), video && (_jsx("span", { "aria-hidden": "true", className: "absolute inset-0 grid place-items-center", children: _jsx("span", { className: "grid size-10 place-items-center rounded-full bg-black/90 text-white", children: _jsx(Icon, { size: "lg", children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: PLAY }) }) }) }) })), label && (_jsx("span", { className: "absolute inset-x-0 bottom-0 truncate bg-black/90 px-2 py-1 text-xs font-medium text-white", children: label }))] }), caption && _jsx("span", { className: "mt-1 block truncate text-xs text-slate-600", children: caption })] }));
14
- const box = cx("block min-w-0 text-left", IMAGE_WIDTH[width]);
15
- if (!onClick) {
16
- return (_jsx("div", { ...rest, className: box, children: body }));
17
- }
18
- return (_jsx("button", { ...rest, type: "button", onClick: onClick, className: cx(box, "group rounded-lg", focusRing), children: body }));
16
+ // With a remove button the tile fills a wrapper that carries the width and the button's anchor.
17
+ const box = cx("block min-w-0 text-left", onRemove ? "w-full" : IMAGE_WIDTH[width]);
18
+ const tile = onClick ? (_jsx("button", { ...rest, type: "button", onClick: onClick, className: cx(box, "group rounded-lg", focusRing), children: body })) : (_jsx("div", { ...rest, className: box, children: body }));
19
+ if (!onRemove)
20
+ return tile;
21
+ return (_jsxs("div", { className: cx("relative min-w-0", IMAGE_WIDTH[width]), children: [tile, _jsx("button", { type: "button", "aria-label": `Remove ${alt}`, title: "Remove", onClick: onRemove, className: "absolute top-1 right-1 inline-flex size-8 cursor-pointer items-center justify-center rounded-full bg-black/90 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", children: _jsx(Icon, { children: _jsx("svg", { viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { d: CLOSE }) }) }) })] }));
19
22
  }
@@ -18,9 +18,11 @@ export declare function ListLayout({ filters, empty, isEmpty, children, breadcru
18
18
  * scrolls: it sticks under AppShell's header and, if it is taller than the room left, scrolls
19
19
  * itself so its end stays reachable.
20
20
  */
21
- export declare function DetailLayout({ aside, stickyAside, children, breadcrumbs, title, subtitle, actions, tabs, ...rest }: PageHeaderProps & {
21
+ export declare function DetailLayout({ aside, asideLabel, stickyAside, children, breadcrumbs, title, subtitle, actions, tabs, ...rest }: PageHeaderProps & {
22
22
  /** The side column: one third from `lg`, below `children` on a smaller screen. */
23
23
  aside: ReactNode;
24
+ /** Names the aside's landmark (`aria-label`), so a page with another complementary landmark can tell them apart. It does not fold the aside. Left out, the aside is unnamed. */
25
+ asideLabel?: string | undefined;
24
26
  /**
25
27
  * From `lg`, keeps the aside in view under the header as the page scrolls; an aside taller than
26
28
  * the screen scrolls inside itself. Off by default, since an aside of ordinary cards may be
@@ -117,11 +119,13 @@ export declare function CardGrid({ columns, minWidth, align, as: Tag, children,
117
119
  * a page of ordinary content beside a full-height `panel`. A `bar` is a `StickyBar` above both: the
118
120
  * full width, on the surface with a bottom line, sticking under AppShell's header as the page scrolls.
119
121
  */
120
- export declare function FullBleedLayout({ bar, panel, padded, children, ...rest }: {
122
+ export declare function FullBleedLayout({ bar, panel, panelLabel, padded, children, ...rest }: {
121
123
  /** A full-width row above `children` and `panel` that sticks under the header, e.g. a viewer's title and actions. `padded` does not pad it. */
122
124
  bar?: ReactNode;
123
125
  /** A side column on the right (`lg:w-72`) on a tinted surface; below `lg` it stacks under `children`. */
124
126
  panel?: ReactNode;
127
+ /** Names the panel's `<aside>` landmark (`aria-label`). Left out, the panel is unnamed. */
128
+ panelLabel?: string | undefined;
125
129
  /**
126
130
  * Pads `children` as the Container pads every other page. Off, they run to the viewport's edges.
127
131
  * A notice in the column is a plain `Callout` in a `Stack`, never a `Callout banner`.
@@ -130,7 +134,9 @@ export declare function FullBleedLayout({ bar, panel, padded, children, ...rest
130
134
  children: ReactNode;
131
135
  } & Passthrough): ReactElement;
132
136
  /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
133
- export declare function CenteredLayout({ title, subtitle, children, ...rest }: {
137
+ export declare function CenteredLayout({ mark, title, subtitle, children, ...rest }: {
138
+ /** Drawn first in the card, above the title, e.g. a large status numeral. Not a heading. */
139
+ mark?: ReactNode;
134
140
  /** The card's `h1`. */
135
141
  title: ReactNode;
136
142
  /** A muted line under the title. */
@@ -4,6 +4,7 @@ import { Card } from "./Card.js";
4
4
  import { cx } from "./cx.js";
5
5
  import { PageHeader, PageHeaderContent } from "./PageHeader.js";
6
6
  import { STACK_WIDTH, Stack } from "./Stack.js";
7
+ import { scrollStopProps, useScrollStop } from "./scrollStop.js";
7
8
  import { STICKY_ASIDE_LG, STICKY_SCROLL_LG } from "./stickyColumn.js";
8
9
  import { StickyBar } from "./StickyBar.js";
9
10
  // Compositions of the layout foundation. None sets a page width or outer padding of its own; the
@@ -18,8 +19,10 @@ export function ListLayout({ filters, empty, isEmpty = false, children, breadcru
18
19
  * scrolls: it sticks under AppShell's header and, if it is taller than the room left, scrolls
19
20
  * itself so its end stays reachable.
20
21
  */
21
- export function DetailLayout({ aside, stickyAside = false, children, breadcrumbs, title, subtitle, actions, tabs, ...rest }) {
22
- return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, actions: actions, tabs: tabs }), _jsxs("div", { ...rest, className: "grid gap-6 lg:grid-cols-3", children: [_jsx("div", { className: "min-w-0 lg:col-span-2", children: _jsx(Stack, { children: children }) }), _jsx("aside", { className: cx("min-w-0", stickyAside && STICKY_ASIDE_LG), children: _jsx("div", { className: stickyAside ? STICKY_SCROLL_LG : undefined, children: _jsx(Stack, { children: aside }) }) })] })] }));
22
+ export function DetailLayout({ aside, asideLabel, stickyAside = false, children, breadcrumbs, title, subtitle, actions, tabs, ...rest }) {
23
+ const { attach, stop } = useScrollStop();
24
+ const { props, ring } = scrollStopProps(stickyAside && stop, { label: asideLabel });
25
+ return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { breadcrumbs: breadcrumbs, title: title, subtitle: subtitle, actions: actions, tabs: tabs }), _jsxs("div", { ...rest, className: "grid gap-6 lg:grid-cols-3", children: [_jsx("div", { className: "min-w-0 lg:col-span-2", children: _jsx(Stack, { children: children }) }), _jsx("aside", { className: cx("min-w-0", stickyAside && STICKY_ASIDE_LG), "aria-label": asideLabel, children: _jsx("div", { ref: stickyAside ? attach : undefined, ...props, className: stickyAside ? cx(STICKY_SCROLL_LG, ring) : undefined, children: _jsx(Stack, { children: aside }) }) })] })] }));
23
26
  }
24
27
  /** A form page: header (no header `actions`), then a `max-w-3xl` column that starts at the Container's left edge (it is not re-centred), with an `actions` row at the end. Other props go on the `<form>`. */
25
28
  export function FormLayout({ actions, children, breadcrumbs, title, subtitle, tabs, ...form }) {
@@ -80,14 +83,14 @@ export function CardGrid({ columns = 3, minWidth = "md", align = "stretch", as:
80
83
  * a page of ordinary content beside a full-height `panel`. A `bar` is a `StickyBar` above both: the
81
84
  * full width, on the surface with a bottom line, sticking under AppShell's header as the page scrolls.
82
85
  */
83
- export function FullBleedLayout({ bar, panel, padded = false, children, ...rest }) {
84
- const row = (_jsxs(_Fragment, { children: [_jsx("div", { className: cx("min-w-0 flex-1", padded && "px-4 py-8 sm:px-6"), children: children }), panel && (_jsx("aside", { className: "w-full shrink-0 border-t border-slate-200 bg-slate-50 p-4 lg:w-72 lg:border-t-0 lg:border-l", children: panel }))] }));
86
+ export function FullBleedLayout({ bar, panel, panelLabel, padded = false, children, ...rest }) {
87
+ const row = (_jsxs(_Fragment, { children: [_jsx("div", { className: cx("min-w-0 flex-1", padded && "px-4 py-8 sm:px-6"), children: children }), panel && (_jsx("aside", { className: "w-full shrink-0 border-t border-slate-200 bg-slate-50 p-4 lg:w-72 lg:border-t-0 lg:border-l", "aria-label": panelLabel, children: panel }))] }));
85
88
  if (!bar) {
86
89
  return (_jsx("div", { ...rest, className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: row }));
87
90
  }
88
91
  return (_jsxs("div", { ...rest, className: "flex min-h-0 flex-1 flex-col", children: [_jsx(StickyBar, { children: bar }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: row })] }));
89
92
  }
90
93
  /** A narrow card (`max-w-md`) centred in the Container at a fixed offset from the top, for sign-in, landing, not-found and error pages. The one layout that does not share the left edge. */
91
- export function CenteredLayout({ title, subtitle, children, ...rest }) {
92
- return (_jsx("div", { ...rest, className: "mx-auto w-full max-w-md pt-16", children: _jsx(Card, { as: "div", children: _jsxs(Stack, { children: [_jsx(PageHeaderContent, { title: title, subtitle: subtitle }), children] }) }) }));
94
+ export function CenteredLayout({ mark, title, subtitle, children, ...rest }) {
95
+ return (_jsx("div", { ...rest, className: "mx-auto w-full max-w-md pt-16", children: _jsx(Card, { as: "div", children: _jsxs(Stack, { children: [mark, _jsx(PageHeaderContent, { title: title, subtitle: subtitle }), children] }) }) }));
93
96
  }
@@ -0,0 +1,26 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ export type SideColumnWidth = "sm" | "md";
4
+ /**
5
+ * A main area beside a side column of a fixed width, in the Container the page already has: from
6
+ * `lg` the `aside` is `asideWidth` wide on the right and `children` take the room left; below `lg`
7
+ * the aside stacks under `children`. It sets no page width, padding or header of its own, so it
8
+ * sits inside a layout, a `Card` or a panel. A side column that is a share of the page and sits
9
+ * under a page header is `DetailLayout`'s aside; a rail of links on the left is `SidebarLayout`.
10
+ *
11
+ * `asideLabel` is for an aside that should not take the phone's screen: below `lg` it folds,
12
+ * closed, behind a native disclosure of that name, and from `lg` it is the same column, always
13
+ * shown. The aside stays mounted while folded, so a conversation or a draft inside it is kept.
14
+ */
15
+ export declare function SideColumn({ aside, asideWidth, asideLabel, children, ...rest }: {
16
+ /** The side column: `asideWidth` wide on the right from `lg`, under `children` below it. */
17
+ aside: ReactNode;
18
+ /**
19
+ * The aside's width from `lg`: `sm` 18rem or `md` 24rem.
20
+ * @default md
21
+ */
22
+ asideWidth?: SideColumnWidth | undefined;
23
+ /** Folds the aside behind a disclosure with this name below `lg`, and names the `<aside>` landmark. Left out, the aside is always shown and unnamed. */
24
+ asideLabel?: string | undefined;
25
+ children: ReactNode;
26
+ } & Passthrough): ReactElement;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "./cx.js";
3
+ import { FoldedColumn } from "./foldedColumn.js";
4
+ /** The grid from `lg`: the main area takes the room left, the aside is exactly this wide. */
5
+ const COLUMNS = {
6
+ sm: "lg:grid-cols-[minmax(0,1fr)_18rem]",
7
+ md: "lg:grid-cols-[minmax(0,1fr)_24rem]",
8
+ };
9
+ /**
10
+ * A main area beside a side column of a fixed width, in the Container the page already has: from
11
+ * `lg` the `aside` is `asideWidth` wide on the right and `children` take the room left; below `lg`
12
+ * the aside stacks under `children`. It sets no page width, padding or header of its own, so it
13
+ * sits inside a layout, a `Card` or a panel. A side column that is a share of the page and sits
14
+ * under a page header is `DetailLayout`'s aside; a rail of links on the left is `SidebarLayout`.
15
+ *
16
+ * `asideLabel` is for an aside that should not take the phone's screen: below `lg` it folds,
17
+ * closed, behind a native disclosure of that name, and from `lg` it is the same column, always
18
+ * shown. The aside stays mounted while folded, so a conversation or a draft inside it is kept.
19
+ */
20
+ export function SideColumn({ aside, asideWidth = "md", asideLabel, children, ...rest }) {
21
+ return (_jsxs("div", { ...rest, className: cx("grid grid-cols-1 gap-6", COLUMNS[asideWidth]), children: [_jsx("div", { className: "min-w-0", children: children }), _jsx("aside", { className: "min-w-0", "aria-label": asideLabel, children: asideLabel === undefined ? (aside) : (_jsx(FoldedColumn, { from: "lg", label: asideLabel, children: aside })) })] }));
22
+ }
@@ -9,13 +9,16 @@ import type { Passthrough } from "./props.js";
9
9
  * a rail taller than the screen scrolls itself, so its last link is reachable. Below `md` it stacks
10
10
  * above the canvas and nothing sticks. A long rail sets `railLabel`:
11
11
  * below `md` it then folds behind a disclosure of that name, closed, so the page comes first on a
12
- * phone, and from `md` it is the same rail. Upgrading from 0.2: the rail no longer has a fill, a
12
+ * phone, and from `md` it is the same rail; `railLabel` also names the rail's `<aside>` landmark. Upgrading from 0.2: the rail no longer has a fill, a
13
13
  * border or a fixed height, and it no longer sits beside the canvas on a phone.
14
14
  */
15
15
  export declare function SidebarLayout({ rail, railLabel, children, ...rest }: {
16
16
  /** The links beside the canvas, usually a `Tabs variant="pill" orientation="vertical"`. */
17
17
  rail: ReactNode;
18
- /** Folds the rail behind a disclosure with this name below `md` (a long rail). Left out, the rail is always open. */
18
+ /**
19
+ * Folds the rail behind a disclosure with this name below `md` (a long rail), and names the rail's
20
+ * `<aside>` landmark. Left out, the rail is always open and the aside is unnamed.
21
+ */
19
22
  railLabel?: string | undefined;
20
23
  children: ReactNode;
21
24
  } & Passthrough): ReactElement;