shelving 1.285.7 → 1.286.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.285.7",
3
+ "version": "1.286.1",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,12 +9,12 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.5.14",
12
+ "@biomejs/biome": "^2.5.15",
13
13
  "@heroicons/react": "^2.2.0",
14
14
  "@types/bun": "^1.4.2",
15
15
  "@types/react": "^19.3.0",
16
16
  "@types/react-dom": "^19.3.0",
17
- "stylelint": "^17.15.0",
17
+ "stylelint": "^17.16.0",
18
18
  "stylelint-config-standard": "^40.0.0",
19
19
  "typescript": "^7.0.2"
20
20
  },
@@ -1,22 +1,27 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import { type BlockVariants } from "../style/Block.js";
3
3
  import type { ClassProps } from "../util/props.js";
4
- /** Props for `DetailsItem` — a single collapsible disclosure. */
4
+ /**
5
+ * Props for `<Details>` — the summary title, the revealed content, and the open state.
6
+ *
7
+ * @see https://shelving.cc/ui/DetailsProps
8
+ */
5
9
  export interface DetailsProps extends BlockVariants, ClassProps {
6
10
  /** Content of the always-visible summary (e.g. a question). */
7
11
  title: ReactNode;
8
- /** Whether the item starts expanded. */
12
+ /** Whether the panel starts expanded. */
9
13
  open?: boolean | undefined;
10
- /** Shared group name — items with the same `name` open exclusively (only one at a time). */
14
+ /** Shared group name — panels with the same `name` open exclusively (only one at a time). */
11
15
  name?: string | undefined;
12
- /** Content revealed when the item is expanded. */
16
+ /** Content revealed when the panel is expanded. */
13
17
  children: ReactNode;
14
18
  }
15
19
  /**
16
- * A single collapsible panel within an `Details`, built on native `<details>` and `<summary>`
17
- * - Panel animates to its true height (where `interpolate-size` + `::details-content` are supported),
18
- * - Give sibling items a shared `name` to make them open exclusively.
20
+ * A collapsible panel with a title that is always visible, built on native `<details>` and `<summary>`.
21
+ * - The panel animates to its true height (where `interpolate-size` and `::details-content` are supported).
22
+ * - Give sibling panels a shared `name` to make them open exclusively.
19
23
  *
20
24
  * @kind component
25
+ * @see https://shelving.cc/ui/Details
21
26
  */
22
27
  export declare function Details({ title, open, name, children, className, ...props }: DetailsProps): ReactElement;
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronUpIcon } from "@heroicons/react/24/outline";
2
+ import { ChevronUpIcon } from "@heroicons/react/24/solid";
3
3
  import { getBlockClass } from "../style/Block.js";
4
- import { getClass } from "../util/css.js";
4
+ import { getFlexClass } from "../style/Flex.js";
5
+ import { getClass, getModuleClass } from "../util/css.js";
5
6
  import DETAILS_CSS from "./Details.module.css";
6
- const DETAILS_CLASS = DETAILS_CSS.details;
7
- const DETAILS_SUMMARY_CLASS = DETAILS_CSS.summary;
7
+ const DETAILS_CLASS = getModuleClass(DETAILS_CSS, "details");
8
+ const DETAILS_SUMMARY_CLASS = getClass(getModuleClass(DETAILS_CSS, "summary"), getFlexClass({ between: true, gap: "normal" }));
8
9
  /**
9
- * A single collapsible panel within an `Details`, built on native `<details>` and `<summary>`
10
- * - Panel animates to its true height (where `interpolate-size` + `::details-content` are supported),
11
- * - Give sibling items a shared `name` to make them open exclusively.
10
+ * A collapsible panel with a title that is always visible, built on native `<details>` and `<summary>`.
11
+ * - The panel animates to its true height (where `interpolate-size` and `::details-content` are supported).
12
+ * - Give sibling panels a shared `name` to make them open exclusively.
12
13
  *
13
14
  * @kind component
15
+ * @see https://shelving.cc/ui/Details
14
16
  */
15
17
  export function Details({ title, open = false, name, children, className, ...props }) {
16
18
  return (_jsxs("details", { className: getClass(DETAILS_CLASS, getBlockClass(props), className), open: open, name: name, children: [_jsxs("summary", { className: DETAILS_SUMMARY_CLASS, children: [_jsx("span", { children: title }), _jsx(ChevronUpIcon, {})] }), children] }));
@@ -0,0 +1,63 @@
1
+ # Details
2
+
3
+ A collapsible panel. The title is always visible. The content shows when the user opens the panel. Built on the native `<details>` and `<summary>` elements, so it works with the keyboard and without JavaScript.
4
+
5
+ **Things to know:**
6
+
7
+ - A chevron on the right of the title points down when the panel is closed and up when it is open.
8
+ - The panel animates to its true height where the browser supports `interpolate-size` and `::details-content`. Other browsers open it at once.
9
+ - Give sibling panels the same `name` to make them open exclusively: when one opens, the others close.
10
+ - Two or more panels next to each other get a divider line between them.
11
+ - A raw `<details>` inside `.prose` gets the same spacing and divider, but it keeps the browser's own marker in place of the chevron.
12
+
13
+ ## Usage
14
+
15
+ ### Single panel
16
+
17
+ ```tsx
18
+ import { Details, Paragraph } from "shelving/ui";
19
+
20
+ <Details title="What is shelving?">
21
+ <Paragraph>A TypeScript data toolkit.</Paragraph>
22
+ </Details>
23
+ ```
24
+
25
+ ### Open by default
26
+
27
+ ```tsx
28
+ import { Details, Paragraph } from "shelving/ui";
29
+
30
+ <Details title="Release notes" open>
31
+ <Paragraph>Bug fixes and small improvements.</Paragraph>
32
+ </Details>
33
+ ```
34
+
35
+ ### Exclusive group (FAQ)
36
+
37
+ ```tsx
38
+ import { Details, Paragraph } from "shelving/ui";
39
+
40
+ // Only one answer is open at a time.
41
+ <Details name="faq" title="Is it free?">
42
+ <Paragraph>Yes.</Paragraph>
43
+ </Details>
44
+ <Details name="faq" title="Does it work with React?">
45
+ <Paragraph>Yes, through `shelving/ui`.</Paragraph>
46
+ </Details>
47
+ ```
48
+
49
+ ## Styling
50
+
51
+ `Details` paints from the [tint ladder](/ui/TINT_CLASS). Override these hooks at `:root` (or any ancestor scope) to retheme. Apply `color=` / `status=` to an ancestor scope to recolour the chevron and divider together.
52
+
53
+ | Variable | Styles | Default |
54
+ |---|---|---|
55
+ | `--details-space` | Outer block margin, and the padding above a divider | `var(--space-paragraph)` (16px) |
56
+ | `--details-border` | Divider between panels next to each other | `var(--stroke-normal) solid var(--tint-90)` |
57
+ | `--details-marker-color` | Colour of the browser's marker on a raw `<details>` in `.prose` | `var(--tint-80)` |
58
+ | `--details-radius` | Corner radius of the title's focus ring | `var(--radius-xsmall)` (8px) |
59
+ | `--details-icon-color` | Chevron colour | `var(--tint-50)` |
60
+ | `--details-transition` | Open/close animation and chevron turn | `all var(--duration-fast)` (150ms) |
61
+ | `--details-gap` | Space between the title and the content | `var(--space-paragraph)` (16px) |
62
+
63
+ **Global tokens it reads:** the tint ladder `--tint-50` / `--tint-80` / `--tint-90`, plus `--space-paragraph`, `--space-normal`, `--radius-xsmall`, `--stroke-normal`, `--stroke-focus`, `--color-focus`, `--duration-fast` and `--size-icon` (chevron size, from `getFlexClass`).
@@ -48,26 +48,11 @@
48
48
  }
49
49
  }
50
50
 
51
- /* Title (with chevron icon) */
52
- .summary {
53
- /* Box */
54
- display: flex;
55
-
56
- /* Contents */
57
- align-items: center;
58
- justify-content: space-between;
59
- gap: var(--space-normal);
60
-
61
- /* Children */
62
- [data-slot="icon"] {
63
- width: var(--size-icon);
64
- height: var(--size-icon);
65
- color: var(--color-gray);
66
- transform: rotate(180deg);
67
- transition: var(--details-transition, all var(--duration-fast));
68
- border-radius: var(--radius-xxsmall);
69
- flex: none;
70
- }
51
+ /* Title chevron (the summary's flex class sets its size). Points down while closed. */
52
+ .summary [data-slot="icon"] {
53
+ color: var(--details-icon-color, var(--tint-50));
54
+ transform: rotate(180deg);
55
+ transition: var(--details-transition, all var(--duration-fast));
71
56
  }
72
57
 
73
58
  /* Content */
@@ -96,10 +81,8 @@
96
81
 
97
82
  /* States */
98
83
  &[open] {
99
- .summary {
100
- [data-slot="icon"] {
101
- transform: rotate(0deg);
102
- }
84
+ .summary [data-slot="icon"] {
85
+ transform: rotate(0deg);
103
86
  }
104
87
 
105
88
  &::details-content {
@@ -1,31 +1,37 @@
1
- import { ChevronUpIcon } from "@heroicons/react/24/outline";
1
+ import { ChevronUpIcon } from "@heroicons/react/24/solid";
2
2
  import type { ReactElement, ReactNode } from "react";
3
3
  import { type BlockVariants, getBlockClass } from "../style/Block.js";
4
- import { getClass } from "../util/css.js";
4
+ import { getFlexClass } from "../style/Flex.js";
5
+ import { getClass, getModuleClass } from "../util/css.js";
5
6
  import type { ClassProps } from "../util/props.js";
6
7
  import DETAILS_CSS from "./Details.module.css";
7
8
 
8
- const DETAILS_CLASS = DETAILS_CSS.details;
9
- const DETAILS_SUMMARY_CLASS = DETAILS_CSS.summary;
9
+ const DETAILS_CLASS = getModuleClass(DETAILS_CSS, "details");
10
+ const DETAILS_SUMMARY_CLASS = getClass(getModuleClass(DETAILS_CSS, "summary"), getFlexClass({ between: true, gap: "normal" }));
10
11
 
11
- /** Props for `DetailsItem` — a single collapsible disclosure. */
12
+ /**
13
+ * Props for `<Details>` — the summary title, the revealed content, and the open state.
14
+ *
15
+ * @see https://shelving.cc/ui/DetailsProps
16
+ */
12
17
  export interface DetailsProps extends BlockVariants, ClassProps {
13
18
  /** Content of the always-visible summary (e.g. a question). */
14
19
  title: ReactNode;
15
- /** Whether the item starts expanded. */
20
+ /** Whether the panel starts expanded. */
16
21
  open?: boolean | undefined;
17
- /** Shared group name — items with the same `name` open exclusively (only one at a time). */
22
+ /** Shared group name — panels with the same `name` open exclusively (only one at a time). */
18
23
  name?: string | undefined;
19
- /** Content revealed when the item is expanded. */
24
+ /** Content revealed when the panel is expanded. */
20
25
  children: ReactNode;
21
26
  }
22
27
 
23
28
  /**
24
- * A single collapsible panel within an `Details`, built on native `<details>` and `<summary>`
25
- * - Panel animates to its true height (where `interpolate-size` + `::details-content` are supported),
26
- * - Give sibling items a shared `name` to make them open exclusively.
29
+ * A collapsible panel with a title that is always visible, built on native `<details>` and `<summary>`.
30
+ * - The panel animates to its true height (where `interpolate-size` and `::details-content` are supported).
31
+ * - Give sibling panels a shared `name` to make them open exclusively.
27
32
  *
28
33
  * @kind component
34
+ * @see https://shelving.cc/ui/Details
29
35
  */
30
36
  export function Details({ title, open = false, name, children, className, ...props }: DetailsProps): ReactElement {
31
37
  return (
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/16/solid";
2
+ import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/24/solid";
3
3
  import { useEffect, useState } from "react";
4
4
  import { Button } from "./Button.js";
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/16/solid";
1
+ import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/24/solid";
2
2
  import { type ReactElement, useEffect, useState } from "react";
3
3
  import type { ClassProps } from "../util/props.js";
4
4
  import { Button, type ButtonVariants } from "./Button.js";
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowPathIcon } from "@heroicons/react/16/solid";
2
+ import { ArrowPathIcon } from "@heroicons/react/24/solid";
3
3
  import { createContext, use } from "react";
4
4
  import { Button } from "./Button.js";
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { ArrowPathIcon } from "@heroicons/react/16/solid";
1
+ import { ArrowPathIcon } from "@heroicons/react/24/solid";
2
2
  import { createContext, type ReactElement, use } from "react";
3
3
  import type { Callback } from "../../util/function.js";
4
4
  import type { ClassProps, OptionalChildProps } from "../util/index.js";
@@ -2,18 +2,23 @@ import { type ReactElement } from "react";
2
2
  import type { Callback } from "../../util/function.js";
3
3
  import { type ButtonVariants } from "../button/Button.js";
4
4
  import type { ClassProps, OptionalChildProps } from "../util/props.js";
5
+ import "../transition/FadeTransition.css";
5
6
  /**
6
7
  * Props for `<Dialog>` — optional `children` content and an `onClose` callback.
7
8
  *
8
9
  * @see https://shelving.cc/ui/DialogProps
9
10
  */
10
11
  export interface DialogProps extends OptionalChildProps {
12
+ /** Called when the user closes the dialog. It must unmount the `<Dialog>`, and it runs inside `startTransition()` so the dialog animates out. */
11
13
  onClose?: Callback;
12
14
  }
13
15
  /**
14
16
  * Modal `<dialog>` element that opens on mount and includes a close button.
15
17
  *
16
- * - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, or the close button.
18
+ * - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, the close button, or the Escape key.
19
+ * - The whole dialog fades in and out in one view transition. A `<Modal>` pinned to an edge leaves that layer and slides in its own.
20
+ * - With `onClose`, a close request calls `onClose()` and the dialog stays open until it unmounts, so the view transition can capture it as it leaves.
21
+ * - Children sit in one wrapper in normal block layout, `--dialog-width` wide and centred on the screen. Content taller than the screen scrolls from its top.
17
22
  * - Wraps content in `<Suspense>` so lazy children can stream in.
18
23
  *
19
24
  * @kind component
@@ -1,13 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { XMarkIcon } from "@heroicons/react/24/solid";
3
- import { memo, Suspense, useEffect, useRef } from "react";
3
+ import { memo, Suspense, startTransition, useLayoutEffect, useRef, ViewTransition } from "react";
4
4
  import { getButtonClass } from "../button/Button.js";
5
5
  import { getClass, getModuleClass } from "../util/css.js";
6
+ import "../transition/FadeTransition.css";
6
7
  import styles from "./Dialog.module.css";
7
8
  /**
8
9
  * Modal `<dialog>` element that opens on mount and includes a close button.
9
10
  *
10
- * - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, or the close button.
11
+ * - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, the close button, or the Escape key.
12
+ * - The whole dialog fades in and out in one view transition. A `<Modal>` pinned to an edge leaves that layer and slides in its own.
13
+ * - With `onClose`, a close request calls `onClose()` and the dialog stays open until it unmounts, so the view transition can capture it as it leaves.
14
+ * - Children sit in one wrapper in normal block layout, `--dialog-width` wide and centred on the screen. Content taller than the screen scrolls from its top.
11
15
  * - Wraps content in `<Suspense>` so lazy children can stream in.
12
16
  *
13
17
  * @kind component
@@ -15,19 +19,33 @@ import styles from "./Dialog.module.css";
15
19
  */
16
20
  export const Dialog = memo(({ children, onClose, ...props }) => {
17
21
  const ref = useRef(null);
18
- useEffect(() => {
22
+ // Open in a layout effect, not a passive effect. React runs layout effects inside the view transition's update, so the new snapshot shows the open dialog.
23
+ useLayoutEffect(() => {
19
24
  ref.current?.showModal();
20
25
  }, []);
21
- return (_jsx(Suspense, { fallback: null, children: _jsxs("dialog", { ref: ref, className: getModuleClass(styles, "dialog"), onClick: _closeOnBackdropClick, onClose: onClose, ...props, children: [children, _jsx("div", { className: getModuleClass(styles, "close"), children: _jsx(DialogCloseButton, {}) })] }) }));
26
+ return (_jsx(Suspense, { fallback: null, children: _jsx(ViewTransition, { enter: "fade", exit: "fade", children: _jsxs("dialog", { ref: ref, className: getModuleClass(styles, "dialog"), onClick: _closeOnBackdropClick, onCancel: e => {
27
+ // Keep the dialog open and let the parent unmount it in a transition. An uncancelable request closes natively and fires `onClose` from the `close` event.
28
+ if (!onClose || !e.cancelable)
29
+ return;
30
+ e.preventDefault();
31
+ startTransition(() => onClose());
32
+ }, onClose: onClose, ...props, children: [_jsx("div", { className: getModuleClass(styles, "content"), children: children }), _jsx("div", { className: getModuleClass(styles, "close"), children: _jsx(DialogCloseButton, {}) })] }) }) }));
22
33
  });
23
34
  /** When the user clicks anywhere on a `<dialog>` element (and the click isn't on a link etc), then close the dialog. */
24
35
  function _closeOnBackdropClick({ currentTarget, target }) {
25
36
  // Close the dialog when clicking on the dialog itself (but not its children).
26
37
  if (currentTarget === target)
27
- currentTarget.close();
38
+ _requestClose(currentTarget);
28
39
  // Close the dialog when clicking on links or buttons in a `<nav>` element.
29
40
  if (target instanceof Element && target.closest("a:any-link, nav button:enabled"))
30
- currentTarget.close();
41
+ _requestClose(currentTarget);
42
+ }
43
+ /** Ask a `<dialog>` to close. `requestClose()` fires a cancelable `cancel` event, so `<Dialog>` can run `onClose` in a transition. Older browsers close at once. */
44
+ function _requestClose(dialog) {
45
+ if (typeof dialog.requestClose === "function")
46
+ dialog.requestClose();
47
+ else
48
+ dialog.close();
31
49
  }
32
50
  /**
33
51
  * Button that closes its wrapping `<dialog>`, showing an X icon by default.
@@ -41,5 +59,7 @@ export function DialogCloseButton({ children = _jsx(XMarkIcon, {}), plain = true
41
59
  return (_jsx("button", { type: "button", title: "Close", className: getClass(getButtonClass({ plain, ...variants }), className), onClick: _closeOnButtonClick, children: children }));
42
60
  }
43
61
  function _closeOnButtonClick({ currentTarget }) {
44
- currentTarget.closest("dialog")?.close();
62
+ const dialog = currentTarget.closest("dialog");
63
+ if (dialog)
64
+ _requestClose(dialog);
45
65
  }
@@ -4,10 +4,16 @@ A native `<dialog>` element opened in modal mode. It opens via `showModal()` whe
4
4
 
5
5
  **Things to know:**
6
6
 
7
- - Closes on a backdrop click, on any link or `<nav>` button clicked inside it, or via the built-in `<DialogCloseButton>` (an X icon, top-right).
7
+ - Closes on a backdrop click, the Escape key, any link or `<nav>` button clicked inside it, or the built-in `<DialogCloseButton>` (an X icon, top-right).
8
8
  - Children render inside a `<Suspense>` boundary, so lazy content can stream in.
9
- - `onClose` fires when the dialog closes — use it to clear the React state that mounts the dialog, or (when pushed via a store) to remove it from the list.
10
- - Pair with `DialogsStore`, `<DialogsContext>`, and `<Dialogs>` to open dialogs imperatively from anywhere in the app. For a non-blocking persistent overlay, reach for `<Modal>` instead.
9
+ - Children sit in one wrapper in normal block layout, so several children stack as they would on the page. The wrapper is `--dialog-width` wide (never wider than the screen) and centred on the screen. A centred `<Modal>` fills it. Content taller than the screen starts at the top, and the dialog scrolls.
10
+ - While a dialog is open, the page behind it does not scroll. A scroll inside the dialog never passes on to the page.
11
+ - `Dialog` only dims the page. Its text is white (`--tint-100`) so it reads on the dark overlay. Wrap the content in `<Modal>` to give it a panel with dark text on a light surface.
12
+ - `onClose` fires when the user closes the dialog. It must unmount the `Dialog`: clear the React state that mounts it, or (when pushed via a store) remove it from the list. `Dialog` calls `onClose` inside `startTransition()`, and the dialog stays open until it unmounts.
13
+ - The dialog animates with [view transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). The whole dialog fades in and out as one layer. A `<Modal>` pinned to an edge takes its own layer and slides.
14
+ - A view transition only runs for a React transition update. `DialogsStore` and `onClose` do this for you. To animate a declarative `Dialog` as it opens, set the state that mounts it inside `startTransition()`.
15
+ - A browser without view transitions shows and hides the dialog at once.
16
+ - Pair with `DialogsStore`, `<DialogsContext>`, and `<Dialogs>` to open dialogs imperatively from anywhere in the app.
11
17
 
12
18
  ## Usage
13
19
 
@@ -16,19 +22,21 @@ A native `<dialog>` element opened in modal mode. It opens via `showModal()` whe
16
22
  Mount `<Dialog>` directly when its lifetime matches a React state variable.
17
23
 
18
24
  ```tsx
19
- import { Dialog, DialogCloseButton } from "shelving/ui";
25
+ import { Dialog, Modal } from "shelving/ui";
20
26
 
21
27
  function ConfirmDelete({ onConfirm, onClose }: { onConfirm: () => void; onClose: () => void }) {
22
28
  return (
23
29
  <Dialog onClose={onClose}>
24
- <p>Delete this item?</p>
25
- <button type="button" onClick={onConfirm}>Delete</button>
26
- <DialogCloseButton />
30
+ <Modal>
31
+ <p>Delete this item?</p>
32
+ <button type="button" onClick={onConfirm}>Delete</button>
33
+ </Modal>
27
34
  </Dialog>
28
35
  );
29
36
  }
30
37
 
31
- // In the parent:
38
+ // In the parent. Open inside `startTransition()` so the dialog animates in.
39
+ <button type="button" onClick={() => startTransition(() => setShowConfirm(true))}>Delete</button>
32
40
  {showConfirm && <ConfirmDelete onConfirm={handleDelete} onClose={() => setShowConfirm(false)} />}
33
41
  ```
34
42
 
@@ -37,28 +45,35 @@ function ConfirmDelete({ onConfirm, onClose }: { onConfirm: () => void; onClose:
37
45
  Set up the context once near the app root (see `<DialogsContext>` and `<Dialogs>`), then push a `<Dialog>` from anywhere with `requireDialogs()`.
38
46
 
39
47
  ```tsx
40
- import { requireDialogs } from "shelving/ui";
48
+ import { Modal, requireDialogs } from "shelving/ui";
41
49
 
42
- function DeleteButton({ id }: { id: string }) {
50
+ function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
43
51
  const dialogs = requireDialogs();
44
- const open = () => dialogs.show(
45
- <ConfirmDelete id={id} onConfirm={() => handleDelete(id)} />,
46
- );
52
+ const open = () =>
53
+ dialogs.show(
54
+ <Modal>
55
+ <p>Delete this item?</p>
56
+ <button type="button" onClick={onConfirm}>Delete</button>
57
+ </Modal>,
58
+ );
47
59
  return <button type="button" onClick={open}>Delete</button>;
48
60
  }
49
61
  ```
50
62
 
51
- `dialogs.show()` wraps the content in a `<Dialog>` for you, so you pass plain children rather than a `<Dialog>` element.
63
+ `dialogs.show()` wraps the content in a `<Dialog>` for you, so you pass plain children rather than a `<Dialog>` element. It does not add a `<Modal>`; include one in the content if you want a panel.
52
64
 
53
65
  ## Styling
54
66
 
55
- `Dialog` paints the full-screen overlay; the inner panel is laid out by its children. Override these hooks at `:root` (or any ancestor scope) to retheme.
67
+ `Dialog` paints the full-screen overlay and resets the browser's default `<dialog>` border, size limits, and `::backdrop`. The inner panel comes from its children, usually `<Modal>`. Override these hooks at `:root` (or any ancestor scope) to retheme.
56
68
 
57
69
  | Variable | Styles | Default |
58
70
  |---|---|---|
59
71
  | `--dialog-padding` | Padding around the centred content | `var(--space-normal)` (16px) |
60
- | `--dialog-color-overlay` | Backdrop fill behind the content | `var(--color-shadow)` |
61
- | `--dialog-transition` | Open / close transition (a fixed discrete `display` transition runs alongside it so the fade animates across the show / hide toggle) | `all var(--duration-fast)` (150ms) |
62
- | `--dialog-close-offset` | Inset of the close button from the top-right corner | `var(--space-small)` (8px) |
72
+ | `--dialog-width` | Width of the centred content, and so of a centred `<Modal>` | `var(--width-narrow)` (36rem) |
73
+ | `--dialog-background` | Overlay fill behind the content | `var(--shadow-color)` |
74
+ | `--dialog-color` | Text colour directly on the overlay | `var(--tint-100)` (white) |
75
+ | `--dialog-close-offset` | Inset of the close button from the top-right corner | `var(--space-small)` (12px) |
76
+
77
+ The fade uses the `fade` class from `<FadeTransition>`, so `--fade-transition-duration` sets its length. It runs only as the dialog opens and closes; an open dialog stays still while other dialogs open and close.
63
78
 
64
- **Global tokens it reads** — move these to retheme broadly: `--space-normal`, `--space-small`, `--color-shadow`, and `--duration-fast`.
79
+ **Global tokens it reads** — move these to retheme broadly: `--tint-100`, `--width-narrow`, `--space-normal`, `--space-small`, `--shadow-color`, and `--duration-fast` (through `<FadeTransition>`).
@@ -1,8 +1,9 @@
1
1
  @import url("../style/layers.css");
2
2
  @import url("../style/Color.module.css");
3
- @import url("../style/Duration.module.css");
4
3
  @import url("../style/Space.module.css");
5
4
  @import url("../style/Shadow.module.css");
5
+ @import url("../style/Tint.module.css");
6
+ @import url("../style/Width.module.css");
6
7
 
7
8
  @layer components {
8
9
  .dialog {
@@ -12,29 +13,52 @@
12
13
  inset: 0;
13
14
  z-index: 1000;
14
15
  margin: 0;
15
- width: 100vw;
16
- height: 100vh;
17
- align-items: center;
18
- justify-content: center;
16
+ width: auto;
17
+ height: auto;
18
+ max-width: none;
19
+ max-height: none;
20
+ overflow: auto;
21
+
22
+ /* A scroll that reaches the top or bottom of the dialog stops there, and does not pass on to the page. */
23
+ overscroll-behavior: contain;
19
24
  padding: var(--dialog-padding, var(--space-normal));
25
+ border: none;
20
26
 
21
27
  /* Style */
22
- background: var(--dialog-color-overlay, var(--shadow-color));
23
- opacity: 0;
24
- transition:
25
- var(--dialog-transition, all var(--duration-fast)),
26
- display var(--duration-fast) allow-discrete;
28
+ color: var(--dialog-color, var(--tint-100));
29
+ background: var(--dialog-background, var(--shadow-color));
27
30
 
31
+ /* Flex only to centre `.content`, its one in-flow child. */
28
32
  &[open] {
29
33
  display: flex;
30
- opacity: 1;
31
34
  }
32
35
 
33
- @starting-style {
34
- opacity: 0;
36
+ /* `.dialog` paints the overlay itself, so the browser's own backdrop must not add a second shade. */
37
+ &::backdrop {
38
+ background: transparent;
35
39
  }
36
40
  }
37
41
 
42
+ /*
43
+ * Lock the page while a dialog is open, so the page behind never scrolls, even when the dialog itself has nothing to scroll.
44
+ * - No `scrollbar-gutter: stable` here: fixed elements stop at the root gutter, so the overlay would leave a bare strip at the side.
45
+ */
46
+ :root:has(.dialog[open]) {
47
+ overflow: hidden;
48
+ }
49
+
50
+ /*
51
+ * The children, in normal block layout, `--dialog-width` wide.
52
+ * - `margin: auto` centres it on both axes. When it is taller than the screen, the margins drop to 0, so it starts at the top and the dialog scrolls. (`align-items: center` would push the top out of reach.)
53
+ * - A pinned `<Modal>` and the close button are `position: fixed`, so this wrapper does not affect them.
54
+ */
55
+ .content {
56
+ margin: auto;
57
+ width: var(--dialog-width, var(--width-narrow));
58
+ min-width: 0;
59
+ max-width: 100%;
60
+ }
61
+
38
62
  .close {
39
63
  position: fixed;
40
64
  top: var(--dialog-close-offset, var(--space-small));