shelving 1.285.6 → 1.286.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.
- package/package.json +1 -1
- package/ui/block/Card.d.ts +1 -1
- package/ui/block/Card.js +1 -1
- package/ui/block/Card.md +12 -12
- package/ui/block/Card.module.css +4 -5
- package/ui/block/Card.tsx +1 -1
- package/ui/block/Panel.md +5 -5
- package/ui/block/Panel.module.css +2 -3
- package/ui/dialog/Dialog.d.ts +6 -1
- package/ui/dialog/Dialog.js +27 -7
- package/ui/dialog/Dialog.md +34 -19
- package/ui/dialog/Dialog.module.css +37 -13
- package/ui/dialog/Dialog.tsx +41 -13
- package/ui/dialog/Dialogs.d.ts +3 -2
- package/ui/dialog/Dialogs.js +6 -10
- package/ui/dialog/Dialogs.tsx +7 -12
- package/ui/dialog/Modal.d.ts +30 -4
- package/ui/dialog/Modal.js +24 -3
- package/ui/dialog/Modal.md +96 -17
- package/ui/dialog/Modal.module.css +168 -7
- package/ui/dialog/Modal.tsx +62 -5
- package/ui/layout/SidebarLayout.md +2 -2
- package/ui/layout/SidebarLayout.module.css +2 -3
- package/ui/menu/Menu.md +2 -2
- package/ui/menu/Menu.module.css +10 -9
- package/ui/menu/MenuItem.md +4 -4
- package/ui/notice/Notices.d.ts +2 -0
- package/ui/notice/Notices.js +14 -4
- package/ui/notice/Notices.md +15 -0
- package/ui/notice/Notices.module.css +14 -1
- package/ui/notice/Notices.tsx +22 -5
- package/ui/notice/NoticesTransition.css +67 -0
- package/ui/style/TINT_CLASS.md +1 -1
- package/ui/transition/useTransitionValue.d.ts +12 -0
- package/ui/transition/useTransitionValue.js +16 -0
- package/ui/transition/useTransitionValue.ts +18 -0
package/package.json
CHANGED
package/ui/block/Card.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface CardProps extends ClickableProps, StatusVariants, BlockVariants
|
|
|
23
23
|
* - When `href` or `onClick` is set the card becomes navigable: a stretched overlay `<a>` / `<button>` covers the entire card while the children render normally inside.
|
|
24
24
|
* - Real interactive elements inside the card (e.g. inline `<a>` links) stay clickable thanks to `position: relative; z-index: 2` rules in the stylesheet.
|
|
25
25
|
* - Accepts a `status` colour and raw `ColorProps` — the card styles the box; lay out its contents however the use case needs.
|
|
26
|
-
* -
|
|
26
|
+
* - Has no drop shadow by default — set `shadow="small"`, `shadow="normal"` or `shadow="large"` to raise a card.
|
|
27
27
|
*
|
|
28
28
|
* @kind component
|
|
29
29
|
* @see https://shelving.cc/ui/Card
|
package/ui/block/Card.js
CHANGED
|
@@ -11,7 +11,7 @@ import CARD_CSS from "./Card.module.css";
|
|
|
11
11
|
* - When `href` or `onClick` is set the card becomes navigable: a stretched overlay `<a>` / `<button>` covers the entire card while the children render normally inside.
|
|
12
12
|
* - Real interactive elements inside the card (e.g. inline `<a>` links) stay clickable thanks to `position: relative; z-index: 2` rules in the stylesheet.
|
|
13
13
|
* - Accepts a `status` colour and raw `ColorProps` — the card styles the box; lay out its contents however the use case needs.
|
|
14
|
-
* -
|
|
14
|
+
* - Has no drop shadow by default — set `shadow="small"`, `shadow="normal"` or `shadow="large"` to raise a card.
|
|
15
15
|
*
|
|
16
16
|
* @kind component
|
|
17
17
|
* @see https://shelving.cc/ui/Card
|
package/ui/block/Card.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Card
|
|
2
2
|
|
|
3
|
-
A boxed surface that groups a self-contained piece of content. Rendered as an `<article>`, painted from the tint ladder (surface,
|
|
3
|
+
A boxed surface that groups a self-contained piece of content. Rendered as an `<article>`, painted from the tint ladder (surface, text) and styled with rounded corners and padding by default.
|
|
4
4
|
|
|
5
5
|
**Things to know:**
|
|
6
6
|
|
|
7
7
|
- Set `href` or `onClick` to make the whole card navigable — a stretched, visually-hidden overlay `<a>` / `<button>` covers the card while the children render normally inside. Real interactive elements inside the card (inline links, buttons) stay clickable and keyboard-focusable.
|
|
8
|
-
- `color=` and `status=` move the tint anchor for the card's scope, so the surface,
|
|
8
|
+
- `color=` and `status=` move the tint anchor for the card's scope, so the surface, text, and hover shade all re-derive together — and nested components (`<Tag>`, `<Preformatted>`, `<Button>`) inherit the same tint.
|
|
9
9
|
- A card styles only the box. Lay out its contents with the usual block components (`<Subheading>`, `<Paragraph>`, `<Row>`, …).
|
|
10
|
-
- Cards
|
|
10
|
+
- Cards have no drop shadow by default — set `shadow="small"`, `shadow="normal"` or `shadow="large"` to raise a given card.
|
|
11
11
|
- Composes the standard styling variants: `color`, `status`, `padding`, `space`, `width`, `shadow`, plus typography.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -49,14 +49,14 @@ import { Card, Subheading } from "shelving/ui";
|
|
|
49
49
|
```tsx
|
|
50
50
|
import { Card, Subheading } from "shelving/ui";
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
<Card shadow="
|
|
54
|
-
<Card shadow="large"><Subheading>Raised</Subheading></Card>
|
|
52
|
+
// Raise a card a little or a lot.
|
|
53
|
+
<Card shadow="small"><Subheading>Raised</Subheading></Card>
|
|
54
|
+
<Card shadow="large"><Subheading>Raised more</Subheading></Card>
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
## Styling
|
|
58
58
|
|
|
59
|
-
`Card` paints from the [tint ladder](/ui/TINT_CLASS); override these hooks at `:root` (or any ancestor scope) to retheme. Apply `color=` / `status=` (on the card or an ancestor scope) to recolour everything at once — surface,
|
|
59
|
+
`Card` paints from the [tint ladder](/ui/TINT_CLASS); override these hooks at `:root` (or any ancestor scope) to retheme. Apply `color=` / `status=` (on the card or an ancestor scope) to recolour everything at once — surface, text, and hover shade re-derive together; reach for a per-property hook for a single surgical change.
|
|
60
60
|
|
|
61
61
|
| Variable | Styles | Default |
|
|
62
62
|
|---|---|---|
|
|
@@ -64,20 +64,20 @@ import { Card, Subheading } from "shelving/ui";
|
|
|
64
64
|
| `--card-hover-background` | Surface fill when a navigable card is hovered | `var(--tint-95)` |
|
|
65
65
|
| `--card-color` | Text colour | `var(--tint-00)` |
|
|
66
66
|
| `--card-border` | Border shorthand | `var(--card-stroke) solid var(--tint-80)` |
|
|
67
|
-
| `--card-stroke` | Border
|
|
67
|
+
| `--card-stroke` | Border thickness — set it (e.g. `var(--stroke-normal)`) to show the border | `0` |
|
|
68
68
|
| `--card-radius` | Corner radius | `var(--radius-normal)` (16px) |
|
|
69
69
|
| `--card-padding` | Inner padding | `var(--space-normal)` (16px) |
|
|
70
70
|
| `--card-space` | Outer block margin (top + bottom) | `var(--space-paragraph)` (16px) |
|
|
71
|
-
| `--card-shadow` | Drop shadow | `
|
|
71
|
+
| `--card-shadow` | Drop shadow | `none` |
|
|
72
72
|
| `--card-transition` | Transition | `all var(--duration-fast)` (150ms) |
|
|
73
73
|
| `--card-focus-border` | Focus outline | `var(--stroke-focus) solid var(--color-focus)` |
|
|
74
74
|
|
|
75
|
-
**Global tokens it reads** — move these to retheme broadly rather than overriding ladder steps directly: the tint ladder `--tint-00` / `--tint-80` / `--tint-90` / `--tint-95`, plus `--space-normal`, `--space-paragraph`, `--radius-normal`, `--
|
|
75
|
+
**Global tokens it reads** — move these to retheme broadly rather than overriding ladder steps directly: the tint ladder `--tint-00` / `--tint-80` / `--tint-90` / `--tint-95`, plus `--space-normal`, `--space-paragraph`, `--radius-normal`, `--stroke-focus`, `--color-focus`, and `--duration-fast`.
|
|
76
76
|
|
|
77
77
|
```css
|
|
78
|
-
/* Theme:
|
|
78
|
+
/* Theme: raised cards with tighter corners. */
|
|
79
79
|
:root {
|
|
80
|
-
--card-shadow:
|
|
80
|
+
--card-shadow: var(--shadow-normal);
|
|
81
81
|
--card-radius: var(--radius-small);
|
|
82
82
|
}
|
|
83
83
|
```
|
package/ui/block/Card.module.css
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
@import url("../style/Color.module.css");
|
|
3
3
|
@import url("../style/Duration.module.css");
|
|
4
4
|
@import url("../style/Radius.module.css");
|
|
5
|
-
@import url("../style/Shadow.module.css");
|
|
6
5
|
@import url("../style/Space.module.css");
|
|
7
6
|
@import url("../style/Stroke.module.css");
|
|
8
7
|
@import url("../style/Tint.module.css");
|
|
@@ -24,22 +23,22 @@
|
|
|
24
23
|
position: relative;
|
|
25
24
|
margin-inline: 0;
|
|
26
25
|
margin-block: var(--card-space, var(--space-paragraph));
|
|
27
|
-
border: var(--card-border, var(--card-stroke,
|
|
26
|
+
border: var(--card-border, var(--card-stroke, 0) solid var(--tint-80));
|
|
28
27
|
padding: var(--card-padding, var(--space-normal));
|
|
29
28
|
border-radius: var(--card-radius, var(--radius-normal));
|
|
30
29
|
|
|
31
30
|
/* Style */
|
|
32
31
|
background: var(--card-background, var(--tint-90));
|
|
33
32
|
color: var(--card-color, var(--tint-00));
|
|
34
|
-
box-shadow: var(--card-shadow,
|
|
33
|
+
box-shadow: var(--card-shadow, none);
|
|
35
34
|
transition: var(--card-transition, all var(--duration-fast));
|
|
36
35
|
outline: var(--card-focus-border, var(--stroke-focus) solid var(--color-focus));
|
|
37
|
-
outline-offset: calc(0px - var(--
|
|
36
|
+
outline-offset: calc(0px - var(--stroke-focus)); /* Fully inset, so it sits inside the card edge. */
|
|
38
37
|
|
|
39
38
|
/* Hover/focus affordance driven by the overlay link/button. */
|
|
40
39
|
&:has(.overlay:hover) {
|
|
41
40
|
background: var(--card-hover-background, var(--tint-95));
|
|
42
|
-
border: var(--card-hover-border, var(--card-stroke,
|
|
41
|
+
border: var(--card-hover-border, var(--card-stroke, 0) solid var(--tint-90));
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
|
package/ui/block/Card.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export interface CardProps extends ClickableProps, StatusVariants, BlockVariants
|
|
|
27
27
|
* - When `href` or `onClick` is set the card becomes navigable: a stretched overlay `<a>` / `<button>` covers the entire card while the children render normally inside.
|
|
28
28
|
* - Real interactive elements inside the card (e.g. inline `<a>` links) stay clickable thanks to `position: relative; z-index: 2` rules in the stylesheet.
|
|
29
29
|
* - Accepts a `status` colour and raw `ColorProps` — the card styles the box; lay out its contents however the use case needs.
|
|
30
|
-
* -
|
|
30
|
+
* - Has no drop shadow by default — set `shadow="small"`, `shadow="normal"` or `shadow="large"` to raise a card.
|
|
31
31
|
*
|
|
32
32
|
* @kind component
|
|
33
33
|
* @see https://shelving.cc/ui/Card
|
package/ui/block/Panel.md
CHANGED
|
@@ -7,8 +7,8 @@ A full-width vertical region that paints the current surface colour. Use panels
|
|
|
7
7
|
- A panel always spans the full width of its container. To constrain the content inside, compose a `<Block>` `width="narrow"` (or `width="wide"`) within it.
|
|
8
8
|
- Block margin is always zero so panels stack flush; control the vertical breathing room with the `padding` variant (`<Panel padding="large">`, `<Panel padding="none">`).
|
|
9
9
|
- Inline padding ("indent") keeps content off the edges by default. Override it per-property with `--panel-indent`, or change it with the shared `indent` variant (`<Panel indent="large">`, `<Panel indent="none">`).
|
|
10
|
-
- `color=` / `status=` move the tint anchor for the whole panel scope, so the surface
|
|
11
|
-
-
|
|
10
|
+
- `color=` / `status=` move the tint anchor for the whole panel scope, so the surface and text re-derive together and cascade into nested content.
|
|
11
|
+
- Panels have no border by default. Set `--panel-stroke` (or `--panel-border`) to add top and bottom borders; the first and last panel drop them so the page doesn't gain stray edge lines.
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
@@ -31,15 +31,15 @@ import { Panel, Block, Title, Paragraph } from "shelving/ui";
|
|
|
31
31
|
|
|
32
32
|
## Styling
|
|
33
33
|
|
|
34
|
-
`Panel` paints from the [tint ladder](/ui/TINT_CLASS); apply `color=` / `status=` (on the panel or an ancestor scope) to recolour the whole scope at once — surface
|
|
34
|
+
`Panel` paints from the [tint ladder](/ui/TINT_CLASS); apply `color=` / `status=` (on the panel or an ancestor scope) to recolour the whole scope at once — surface and text re-derive together — or reach for a per-property hook for a single change.
|
|
35
35
|
|
|
36
36
|
| Variable | Styles | Default |
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| `--panel-background` | Surface fill | `var(--tint-90)` |
|
|
39
39
|
| `--panel-color` | Text colour | `var(--tint-00)` |
|
|
40
40
|
| `--panel-border` | Top/bottom border shorthand | `var(--panel-stroke) solid var(--tint-80)` |
|
|
41
|
-
| `--panel-stroke` | Border thickness
|
|
41
|
+
| `--panel-stroke` | Border thickness — set it (e.g. `var(--stroke-normal)`) to show the border | `0` |
|
|
42
42
|
| `--panel-padding` | Block padding (top + bottom) | `var(--space-section)` (2rem) |
|
|
43
43
|
| `--panel-indent` | Inline padding (left + right) keeping content off the edges | `var(--space-normal)` (16px) |
|
|
44
44
|
|
|
45
|
-
**Global tokens it reads:** the tint-ladder steps `--tint-00` / `--tint-80` / `--tint-90`, plus `--
|
|
45
|
+
**Global tokens it reads:** the tint-ladder steps `--tint-00` / `--tint-80` / `--tint-90`, plus `--space-section`, and `--space-normal`. The shared `padding` variant overrides `--panel-padding`; the shared `indent` variant overrides `--panel-indent`.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
@import url("../style/layers.css");
|
|
2
|
-
@import url("../style/Stroke.module.css");
|
|
3
2
|
@import url("../style/Tint.module.css");
|
|
4
3
|
@import url("../style/Space.module.css");
|
|
5
4
|
|
|
@@ -18,8 +17,8 @@
|
|
|
18
17
|
position: relative;
|
|
19
18
|
padding-block: var(--panel-padding, var(--space-section));
|
|
20
19
|
padding-inline: var(--panel-indent, var(--space-normal));
|
|
21
|
-
border-top: var(--panel-border, var(--panel-stroke,
|
|
22
|
-
border-bottom: var(--panel-border, var(--panel-stroke,
|
|
20
|
+
border-top: var(--panel-border, var(--panel-stroke, 0) solid var(--tint-80));
|
|
21
|
+
border-bottom: var(--panel-border, var(--panel-stroke, 0) solid var(--tint-80));
|
|
23
22
|
|
|
24
23
|
/* Style */
|
|
25
24
|
background: var(--panel-background, var(--tint-90));
|
package/ui/dialog/Dialog.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/ui/dialog/Dialog.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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")
|
|
62
|
+
const dialog = currentTarget.closest("dialog");
|
|
63
|
+
if (dialog)
|
|
64
|
+
_requestClose(dialog);
|
|
45
65
|
}
|
package/ui/dialog/Dialog.md
CHANGED
|
@@ -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,
|
|
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
|
-
-
|
|
10
|
-
-
|
|
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,
|
|
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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
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({
|
|
50
|
+
function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
|
|
43
51
|
const dialogs = requireDialogs();
|
|
44
|
-
const open = () =>
|
|
45
|
-
|
|
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
|
|
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-
|
|
61
|
-
| `--dialog-
|
|
62
|
-
| `--dialog-
|
|
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
|
|
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:
|
|
16
|
-
height:
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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));
|
package/ui/dialog/Dialog.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
|
2
|
-
import { type MouseEvent, memo, type ReactElement, Suspense,
|
|
2
|
+
import { type MouseEvent, memo, type ReactElement, Suspense, startTransition, useLayoutEffect, useRef, ViewTransition } from "react";
|
|
3
3
|
import type { Callback } from "../../util/function.js";
|
|
4
4
|
import { type ButtonVariants, getButtonClass } from "../button/Button.js";
|
|
5
5
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
6
6
|
import type { ClassProps, OptionalChildProps } from "../util/props.js";
|
|
7
|
+
import "../transition/FadeTransition.css";
|
|
7
8
|
import styles from "./Dialog.module.css";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -12,13 +13,17 @@ import styles from "./Dialog.module.css";
|
|
|
12
13
|
* @see https://shelving.cc/ui/DialogProps
|
|
13
14
|
*/
|
|
14
15
|
export interface DialogProps extends OptionalChildProps {
|
|
16
|
+
/** Called when the user closes the dialog. It must unmount the `<Dialog>`, and it runs inside `startTransition()` so the dialog animates out. */
|
|
15
17
|
onClose?: Callback;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Modal `<dialog>` element that opens on mount and includes a close button.
|
|
20
22
|
*
|
|
21
|
-
* - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, or the
|
|
23
|
+
* - Opens via `showModal()` when mounted and closes on backdrop clicks, link/nav-button clicks, the close button, or the Escape key.
|
|
24
|
+
* - 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.
|
|
25
|
+
* - 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.
|
|
26
|
+
* - 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.
|
|
22
27
|
* - Wraps content in `<Suspense>` so lazy children can stream in.
|
|
23
28
|
*
|
|
24
29
|
* @kind component
|
|
@@ -27,19 +32,35 @@ export interface DialogProps extends OptionalChildProps {
|
|
|
27
32
|
export const Dialog = memo(({ children, onClose, ...props }: DialogProps) => {
|
|
28
33
|
const ref = useRef<HTMLDialogElement>(null);
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
// 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.
|
|
36
|
+
useLayoutEffect(() => {
|
|
31
37
|
ref.current?.showModal();
|
|
32
38
|
}, []);
|
|
33
39
|
|
|
34
40
|
return (
|
|
35
41
|
<Suspense fallback={null}>
|
|
36
|
-
{
|
|
37
|
-
<
|
|
38
|
-
{
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
{/* The transition must wrap the `<dialog>`: React only animates a `<ViewTransition>` that comes before any DOM element in the inserted or deleted tree. Fade only on enter and exit, so an open dialog stays still while another dialog opens or closes. */}
|
|
43
|
+
<ViewTransition enter="fade" exit="fade">
|
|
44
|
+
{/** biome-ignore lint/a11y/useKeyWithClickEvents: Dialogs also show a close button. */}
|
|
45
|
+
<dialog
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={getModuleClass(styles, "dialog")}
|
|
48
|
+
onClick={_closeOnBackdropClick}
|
|
49
|
+
onCancel={e => {
|
|
50
|
+
// 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.
|
|
51
|
+
if (!onClose || !e.cancelable) return;
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
startTransition(() => onClose());
|
|
54
|
+
}}
|
|
55
|
+
onClose={onClose}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<div className={getModuleClass(styles, "content")}>{children}</div>
|
|
59
|
+
<div className={getModuleClass(styles, "close")}>
|
|
60
|
+
<DialogCloseButton />
|
|
61
|
+
</div>
|
|
62
|
+
</dialog>
|
|
63
|
+
</ViewTransition>
|
|
43
64
|
</Suspense>
|
|
44
65
|
);
|
|
45
66
|
});
|
|
@@ -47,10 +68,16 @@ export const Dialog = memo(({ children, onClose, ...props }: DialogProps) => {
|
|
|
47
68
|
/** When the user clicks anywhere on a `<dialog>` element (and the click isn't on a link etc), then close the dialog. */
|
|
48
69
|
function _closeOnBackdropClick({ currentTarget, target }: MouseEvent<HTMLDialogElement>): void {
|
|
49
70
|
// Close the dialog when clicking on the dialog itself (but not its children).
|
|
50
|
-
if (currentTarget === target) currentTarget
|
|
71
|
+
if (currentTarget === target) _requestClose(currentTarget);
|
|
51
72
|
|
|
52
73
|
// Close the dialog when clicking on links or buttons in a `<nav>` element.
|
|
53
|
-
if (target instanceof Element && target.closest("a:any-link, nav button:enabled")) currentTarget
|
|
74
|
+
if (target instanceof Element && target.closest("a:any-link, nav button:enabled")) _requestClose(currentTarget);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ask a `<dialog>` to close. `requestClose()` fires a cancelable `cancel` event, so `<Dialog>` can run `onClose` in a transition. Older browsers close at once. */
|
|
78
|
+
function _requestClose(dialog: HTMLDialogElement): void {
|
|
79
|
+
if (typeof dialog.requestClose === "function") dialog.requestClose();
|
|
80
|
+
else dialog.close();
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
/**
|
|
@@ -87,5 +114,6 @@ export function DialogCloseButton({
|
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
function _closeOnButtonClick({ currentTarget }: MouseEvent<HTMLButtonElement>): void {
|
|
90
|
-
currentTarget.closest("dialog")
|
|
117
|
+
const dialog = currentTarget.closest("dialog");
|
|
118
|
+
if (dialog) _requestClose(dialog);
|
|
91
119
|
}
|
package/ui/dialog/Dialogs.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { ChildProps } from "../util/props.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Store holding the live list of open `<Dialog>` elements.
|
|
6
6
|
*
|
|
7
|
-
* - `show()` opens a new dialog
|
|
7
|
+
* - `show()` opens a new dialog. A dialog is removed from the list as soon as it closes.
|
|
8
|
+
* - `<Dialogs>` renders the list inside a transition, so dialogs animate in and out with view transitions.
|
|
8
9
|
*
|
|
9
10
|
* @see https://shelving.cc/ui/DialogsStore
|
|
10
11
|
*/
|
|
@@ -59,5 +60,5 @@ export declare function DialogsContext({ children }: DialogsContextProps): React
|
|
|
59
60
|
* @kind component
|
|
60
61
|
* @see https://shelving.cc/ui/Dialogs
|
|
61
62
|
*/
|
|
62
|
-
export declare function Dialogs(): ReactNode
|
|
63
|
+
export declare function Dialogs(): ReactNode;
|
|
63
64
|
export {};
|
package/ui/dialog/Dialogs.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, use } from "react";
|
|
3
3
|
import { useInstance } from "../../react/useInstance.js";
|
|
4
|
-
import { useStore } from "../../react/useStore.js";
|
|
5
4
|
import { ArrayStore } from "../../store/ArrayStore.js";
|
|
6
5
|
import { getRandomKey } from "../../util/random.js";
|
|
6
|
+
import { useTransitionValue } from "../transition/useTransitionValue.js";
|
|
7
7
|
import { Dialog } from "./Dialog.js";
|
|
8
|
-
/** How long before a hidden dialogs are removed from the DOM (allow time for animates to complete). */
|
|
9
|
-
const REMOVE_DELAY = 500;
|
|
10
8
|
/**
|
|
11
9
|
* Store holding the live list of open `<Dialog>` elements.
|
|
12
10
|
*
|
|
13
|
-
* - `show()` opens a new dialog
|
|
11
|
+
* - `show()` opens a new dialog. A dialog is removed from the list as soon as it closes.
|
|
12
|
+
* - `<Dialogs>` renders the list inside a transition, so dialogs animate in and out with view transitions.
|
|
14
13
|
*
|
|
15
14
|
* @see https://shelving.cc/ui/DialogsStore
|
|
16
15
|
*/
|
|
@@ -25,10 +24,8 @@ export class DialogsStore extends ArrayStore {
|
|
|
25
24
|
const dialog = (_jsx(Dialog
|
|
26
25
|
// Add a `key=""` so dialogs can be rendered directly and added/removed in any order.
|
|
27
26
|
, {
|
|
28
|
-
// When the `<dialog>`
|
|
29
|
-
onClose: () =>
|
|
30
|
-
setTimeout(() => this.delete(dialog), REMOVE_DELAY);
|
|
31
|
-
}, children: children }, getRandomKey()));
|
|
27
|
+
// When the `<dialog>` closes, remove it from the list. The view transition animates it out.
|
|
28
|
+
onClose: () => this.delete(dialog), children: children }, getRandomKey()));
|
|
32
29
|
this.add(dialog);
|
|
33
30
|
}
|
|
34
31
|
/**
|
|
@@ -68,6 +65,5 @@ export function DialogsContext({ children }) {
|
|
|
68
65
|
* @see https://shelving.cc/ui/Dialogs
|
|
69
66
|
*/
|
|
70
67
|
export function Dialogs() {
|
|
71
|
-
|
|
72
|
-
return dialogs ? dialogs.value : null;
|
|
68
|
+
return useTransitionValue(requireDialogs());
|
|
73
69
|
}
|