shelving 1.285.7 → 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/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/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/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/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
|
}
|
package/ui/dialog/Dialogs.tsx
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
2
|
import { useInstance } from "../../react/useInstance.js";
|
|
3
|
-
import { useStore } from "../../react/useStore.js";
|
|
4
3
|
import { ArrayStore } from "../../store/ArrayStore.js";
|
|
5
4
|
import { getRandomKey } from "../../util/random.js";
|
|
5
|
+
import { useTransitionValue } from "../transition/useTransitionValue.js";
|
|
6
6
|
import type { ChildProps } from "../util/props.js";
|
|
7
7
|
import { Dialog } from "./Dialog.js";
|
|
8
8
|
|
|
9
|
-
/** How long before a hidden dialogs are removed from the DOM (allow time for animates to complete). */
|
|
10
|
-
const REMOVE_DELAY = 500;
|
|
11
|
-
|
|
12
9
|
/**
|
|
13
10
|
* Store holding the live list of open `<Dialog>` elements.
|
|
14
11
|
*
|
|
15
|
-
* - `show()` opens a new dialog
|
|
12
|
+
* - `show()` opens a new dialog. A dialog is removed from the list as soon as it closes.
|
|
13
|
+
* - `<Dialogs>` renders the list inside a transition, so dialogs animate in and out with view transitions.
|
|
16
14
|
*
|
|
17
15
|
* @see https://shelving.cc/ui/DialogsStore
|
|
18
16
|
*/
|
|
@@ -28,10 +26,8 @@ export class DialogsStore extends ArrayStore<ReactElement> {
|
|
|
28
26
|
<Dialog
|
|
29
27
|
// Add a `key=""` so dialogs can be rendered directly and added/removed in any order.
|
|
30
28
|
key={getRandomKey()}
|
|
31
|
-
// When the `<dialog>`
|
|
32
|
-
onClose={() =>
|
|
33
|
-
setTimeout(() => this.delete(dialog), REMOVE_DELAY);
|
|
34
|
-
}}
|
|
29
|
+
// When the `<dialog>` closes, remove it from the list. The view transition animates it out.
|
|
30
|
+
onClose={() => this.delete(dialog)}
|
|
35
31
|
>
|
|
36
32
|
{children}
|
|
37
33
|
</Dialog>
|
|
@@ -97,7 +93,6 @@ export function DialogsContext({ children }: DialogsContextProps): ReactElement
|
|
|
97
93
|
* @kind component
|
|
98
94
|
* @see https://shelving.cc/ui/Dialogs
|
|
99
95
|
*/
|
|
100
|
-
export function Dialogs(): ReactNode
|
|
101
|
-
|
|
102
|
-
return dialogs ? dialogs.value : null;
|
|
96
|
+
export function Dialogs(): ReactNode {
|
|
97
|
+
return useTransitionValue(requireDialogs());
|
|
103
98
|
}
|
package/ui/dialog/Modal.d.ts
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
+
import { type IndentVariants } from "../style/Indent.js";
|
|
3
|
+
import { type PaddingVariants } from "../style/Padding.js";
|
|
4
|
+
import { type RadiusVariants } from "../style/Radius.js";
|
|
5
|
+
import { type ShadowVariants } from "../style/Shadow.js";
|
|
2
6
|
import type { ClassProps, OptionalChildProps } from "../util/props.js";
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
8
|
+
* Variant props for `<Modal>` — pin the panel to one edge of the screen.
|
|
9
|
+
*
|
|
10
|
+
* - Set one of `top`, `right`, `bottom`, or `left`. Without one, the panel is centred.
|
|
11
|
+
* - If more than one is set, the first in the order `top`, `right`, `bottom`, `left` wins.
|
|
12
|
+
*
|
|
13
|
+
* @see https://shelving.cc/ui/ModalVariants
|
|
14
|
+
*/
|
|
15
|
+
export interface ModalVariants {
|
|
16
|
+
/** Pin the panel to the top edge, full width, and slide it in from the top. */
|
|
17
|
+
top?: boolean | undefined;
|
|
18
|
+
/** Pin the panel to the right edge, full height, and slide it in from the right. */
|
|
19
|
+
right?: boolean | undefined;
|
|
20
|
+
/** Pin the panel to the bottom edge, full width, and slide it in from the bottom. */
|
|
21
|
+
bottom?: boolean | undefined;
|
|
22
|
+
/** Pin the panel to the left edge, full height, and slide it in from the left. */
|
|
23
|
+
left?: boolean | undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Props for `<Modal>` — edge, padding, indent, radius, and shadow variants, optional `children` content, and an optional `className`.
|
|
5
27
|
*
|
|
6
28
|
* @see https://shelving.cc/ui/ModalProps
|
|
7
29
|
*/
|
|
8
|
-
export interface ModalProps extends OptionalChildProps, ClassProps {
|
|
30
|
+
export interface ModalProps extends ModalVariants, PaddingVariants, IndentVariants, RadiusVariants, ShadowVariants, OptionalChildProps, ClassProps {
|
|
9
31
|
}
|
|
10
32
|
/**
|
|
11
|
-
* Styled `<aside>`
|
|
33
|
+
* Styled `<aside>` panel for content inside a `<Dialog>`, with dark text on a light surface.
|
|
34
|
+
*
|
|
35
|
+
* - Centred by default. It fades in and out with its `<Dialog>`.
|
|
36
|
+
* - Has a `--shadow-normal` drop shadow by default. Set `shadow="none"`, `shadow="small"` or `shadow="large"` to change it.
|
|
37
|
+
* - `top`, `right`, `bottom`, or `left` pins it to that edge. It then takes its own layer in the `<Dialog>` view transition, and slides in from that edge and out to it.
|
|
12
38
|
*
|
|
13
39
|
* @kind component
|
|
14
40
|
* @see https://shelving.cc/ui/Modal
|
|
15
41
|
*/
|
|
16
|
-
export declare function Modal({ children, className }: ModalProps): ReactElement;
|
|
42
|
+
export declare function Modal({ children, className, ...props }: ModalProps): ReactElement;
|
package/ui/dialog/Modal.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { getIndentClass } from "../style/Indent.js";
|
|
3
|
+
import { getPaddingClass } from "../style/Padding.js";
|
|
4
|
+
import { getRadiusClass } from "../style/Radius.js";
|
|
5
|
+
import { getShadowClass } from "../style/Shadow.js";
|
|
2
6
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
3
7
|
import styles from "./Modal.module.css";
|
|
4
8
|
/**
|
|
5
|
-
* Styled `<aside>`
|
|
9
|
+
* Styled `<aside>` panel for content inside a `<Dialog>`, with dark text on a light surface.
|
|
10
|
+
*
|
|
11
|
+
* - Centred by default. It fades in and out with its `<Dialog>`.
|
|
12
|
+
* - Has a `--shadow-normal` drop shadow by default. Set `shadow="none"`, `shadow="small"` or `shadow="large"` to change it.
|
|
13
|
+
* - `top`, `right`, `bottom`, or `left` pins it to that edge. It then takes its own layer in the `<Dialog>` view transition, and slides in from that edge and out to it.
|
|
6
14
|
*
|
|
7
15
|
* @kind component
|
|
8
16
|
* @see https://shelving.cc/ui/Modal
|
|
9
17
|
*/
|
|
10
|
-
export function Modal({ children, className }) {
|
|
11
|
-
|
|
18
|
+
export function Modal({ children, className, ...props }) {
|
|
19
|
+
const side = _getSide(props);
|
|
20
|
+
return (_jsx("aside", { className: getClass(getModuleClass(styles, "modal"), //
|
|
21
|
+
side && getModuleClass(styles, side), getPaddingClass(props), getIndentClass(props), getRadiusClass(props), getShadowClass(props), className), children: children }));
|
|
22
|
+
}
|
|
23
|
+
/** Get the edge a modal is pinned to, if any. */
|
|
24
|
+
function _getSide({ top, right, bottom, left }) {
|
|
25
|
+
if (top)
|
|
26
|
+
return "top";
|
|
27
|
+
if (right)
|
|
28
|
+
return "right";
|
|
29
|
+
if (bottom)
|
|
30
|
+
return "bottom";
|
|
31
|
+
if (left)
|
|
32
|
+
return "left";
|
|
12
33
|
}
|
package/ui/dialog/Modal.md
CHANGED
|
@@ -1,35 +1,114 @@
|
|
|
1
1
|
# Modal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The panel inside a `<Dialog>`. `<Dialog>` dims the page; `Modal` gives the content a shadowed surface with dark text on a light fill.
|
|
4
4
|
|
|
5
5
|
**Things to know:**
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- A native `<dialog>` does both jobs. Shelving splits them: `<Dialog>` is the overlay and `Modal` is the panel. Content placed directly in a `<Dialog>` shows as white text on the dark overlay.
|
|
8
|
+
- `Modal` sets its text back to `--tint-00`, so it reads on its own `--tint-100` surface.
|
|
9
|
+
- `DialogsStore.show()` wraps its content in a `<Dialog>` only. It does not add a `Modal`, so put the `Modal` in the content yourself.
|
|
10
|
+
- `Modal` only styles the box. Lay out its contents with the usual block components.
|
|
11
|
+
- A centred `Modal` fills the width of its `<Dialog>`, so set `--dialog-width` to change it. `--modal-width` sets the width of a `left` or `right` panel.
|
|
12
|
+
- The `padding` variant (for example `padding="large"`) sets the top and bottom padding, and the `indent` variant sets the left and right padding, the same as on `<Panel>`.
|
|
13
|
+
- The `radius` variant sets the corner radius. On a pinned panel, corners that touch a screen edge stay square.
|
|
14
|
+
- It has a `--shadow-normal` drop shadow by default. Set `shadow="none"`, `shadow="small"` or `shadow="large"` to change it, the same as on `<Card>`.
|
|
15
|
+
- Set `top`, `right`, `bottom`, or `left` to pin the panel to that edge of the screen. A top or bottom panel is full width; a left or right panel is full height. Use these for mobile menus, bottom sheets, and side menus.
|
|
16
|
+
- A centred panel fades in and out with its `<Dialog>`. A pinned panel slides in from its edge and out to it, in its own view-transition layer. With reduced motion, a pinned panel fades in place.
|
|
9
17
|
|
|
10
18
|
## Usage
|
|
11
19
|
|
|
20
|
+
### Declarative
|
|
21
|
+
|
|
22
|
+
Put the `Modal` inside a `<Dialog>` that you mount from React state.
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { Dialog, Modal } from "shelving/ui";
|
|
26
|
+
|
|
27
|
+
function ConfirmDelete({ onConfirm, onClose }: { onConfirm: () => void; onClose: () => void }) {
|
|
28
|
+
return (
|
|
29
|
+
<Dialog onClose={onClose}>
|
|
30
|
+
<Modal>
|
|
31
|
+
<p>Delete this item?</p>
|
|
32
|
+
<button type="button" onClick={onConfirm}>Delete</button>
|
|
33
|
+
</Modal>
|
|
34
|
+
</Dialog>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Imperative
|
|
40
|
+
|
|
41
|
+
Pass the `Modal` to `DialogsStore.show()`. The store adds the `<Dialog>` for you. See `<DialogsContext>` and `<Dialogs>` for the setup.
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { Modal, requireDialogs } from "shelving/ui";
|
|
45
|
+
|
|
46
|
+
function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
|
|
47
|
+
const dialogs = requireDialogs();
|
|
48
|
+
const open = () =>
|
|
49
|
+
dialogs.show(
|
|
50
|
+
<Modal>
|
|
51
|
+
<p>Delete this item?</p>
|
|
52
|
+
<button type="button" onClick={onConfirm}>Delete</button>
|
|
53
|
+
</Modal>,
|
|
54
|
+
);
|
|
55
|
+
return <button type="button" onClick={open}>Delete</button>;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Padding, indent, and radius
|
|
60
|
+
|
|
12
61
|
```tsx
|
|
13
|
-
|
|
62
|
+
<Modal padding="large" indent="large" radius="large">
|
|
63
|
+
<p>More space on all sides, and rounder corners.</p>
|
|
64
|
+
</Modal>
|
|
65
|
+
```
|
|
14
66
|
|
|
15
|
-
|
|
16
|
-
|
|
67
|
+
### Shadow
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<Modal shadow="large">
|
|
71
|
+
<p>Raised above the overlay.</p>
|
|
17
72
|
</Modal>
|
|
18
73
|
```
|
|
19
74
|
|
|
75
|
+
### Pinned to an edge
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { Menu, MenuItem, Modal, requireDialogs } from "shelving/ui";
|
|
79
|
+
|
|
80
|
+
function MenuButton() {
|
|
81
|
+
const dialogs = requireDialogs();
|
|
82
|
+
const open = () =>
|
|
83
|
+
dialogs.show(
|
|
84
|
+
<Modal left>
|
|
85
|
+
<Menu>
|
|
86
|
+
<MenuItem href="/">Home</MenuItem>
|
|
87
|
+
<MenuItem href="/settings">Settings</MenuItem>
|
|
88
|
+
</Menu>
|
|
89
|
+
</Modal>,
|
|
90
|
+
);
|
|
91
|
+
return <button type="button" onClick={open}>Menu</button>;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A link click inside a `<Dialog>` closes it, so the menu slides out as the page changes. Use `<Modal bottom>` for a bottom sheet on mobile.
|
|
96
|
+
|
|
20
97
|
## Styling
|
|
21
98
|
|
|
22
|
-
`Modal` paints a
|
|
99
|
+
`Modal` paints a shadowed surface with no border. Set `--modal-stroke` (for example `var(--stroke-normal)`) to show the themed border, or `--modal-border` to replace it. A pinned panel then keeps only the border on its inner side. On a pinned panel, only the corners that do not touch a screen edge are round. Override these hooks at `:root` (or any ancestor scope) to retheme.
|
|
23
100
|
|
|
24
101
|
| Variable | Styles | Default |
|
|
25
102
|
|---|---|---|
|
|
26
|
-
| `--modal-width` |
|
|
27
|
-
| `--modal-
|
|
28
|
-
| `--modal-
|
|
29
|
-
| `--modal-
|
|
30
|
-
| `--modal-
|
|
31
|
-
| `--modal-
|
|
32
|
-
| `--modal-
|
|
33
|
-
| `--modal-
|
|
34
|
-
|
|
35
|
-
|
|
103
|
+
| `--modal-width` | Width of a `left` or `right` panel (a centred panel takes `--dialog-width`) | `var(--width-narrow)` |
|
|
104
|
+
| `--modal-stroke` | Border width | `0` (no border) |
|
|
105
|
+
| `--modal-border` | Border shorthand | `var(--modal-stroke)` solid `--tint-80` |
|
|
106
|
+
| `--modal-radius` | Corner radius (the `radius` variant wins over it) | `var(--radius-normal)` (16px) |
|
|
107
|
+
| `--modal-background` | Surface fill | `var(--tint-100)` |
|
|
108
|
+
| `--modal-padding` | Inner padding (the `padding` variant overrides the top and bottom, and `indent` the left and right) | `var(--space-normal)` (16px) |
|
|
109
|
+
| `--modal-color` | Text colour | `var(--tint-00)` |
|
|
110
|
+
| `--modal-max-height` | Maximum height of a `top` or `bottom` panel (it scrolls past this) | `100%` |
|
|
111
|
+
| `--modal-transition-duration` | Length of the slide for a pinned panel. Keep it the same as `--fade-transition-duration`, so the panel and the `<Dialog>` overlay finish together | `var(--duration-fast)` (150ms) |
|
|
112
|
+
| `--modal-shadow` | Drop shadow (the `shadow` variant wins over it) | `var(--shadow-normal)` |
|
|
113
|
+
|
|
114
|
+
**Global tokens it reads** — move these to retheme broadly: the tint ladder `--tint-00` / `--tint-80` / `--tint-100`, plus `--width-narrow`, `--space-normal`, `--radius-normal`, `--shadow-normal`, and `--duration-fast`.
|
|
@@ -3,24 +3,185 @@
|
|
|
3
3
|
@import url("../style/Radius.module.css");
|
|
4
4
|
@import url("../style/Shadow.module.css");
|
|
5
5
|
@import url("../style/Space.module.css");
|
|
6
|
-
@import url("../style/Stroke.module.css");
|
|
7
6
|
@import url("../style/Tint.module.css");
|
|
8
7
|
@import url("../style/Width.module.css");
|
|
9
8
|
|
|
10
9
|
@layer components {
|
|
11
10
|
.modal {
|
|
12
|
-
/* Box */
|
|
11
|
+
/* Box: `border-box` keeps the padding inside `max-width`, and `min-width: 0` lets the panel shrink below its content in the flex `<Dialog>`, so it never goes wider than the screen. */
|
|
12
|
+
box-sizing: border-box;
|
|
13
13
|
margin: auto;
|
|
14
|
-
width:
|
|
14
|
+
min-width: 0;
|
|
15
15
|
max-width: 100%;
|
|
16
|
-
border: var(--modal-border, var(--stroke
|
|
16
|
+
border: var(--modal-border, var(--modal-stroke, 0) solid var(--tint-80));
|
|
17
17
|
border-radius: var(--modal-radius, var(--radius-normal));
|
|
18
|
-
background: var(--modal-
|
|
18
|
+
background: var(--modal-background, var(--tint-100));
|
|
19
19
|
padding: var(--modal-padding, var(--space-normal));
|
|
20
20
|
|
|
21
21
|
/* Style */
|
|
22
|
-
color: var(--modal-color
|
|
23
|
-
transition: var(--modal-transition, all var(--duration-fast));
|
|
22
|
+
color: var(--modal-color, var(--tint-00));
|
|
24
23
|
box-shadow: var(--modal-shadow, var(--shadow-normal));
|
|
24
|
+
|
|
25
|
+
/* Pinned to an edge: fixed to the viewport, flush with the edge, and scrolls when its content is too tall. */
|
|
26
|
+
&.top,
|
|
27
|
+
&.right,
|
|
28
|
+
&.bottom,
|
|
29
|
+
&.left {
|
|
30
|
+
position: fixed;
|
|
31
|
+
margin: 0;
|
|
32
|
+
overflow: auto;
|
|
33
|
+
overscroll-behavior: contain;
|
|
34
|
+
|
|
35
|
+
/* Take a separate layer in any view transition, so the panel can slide while the `<Dialog>` around it fades. `match-element` gives each panel a unique name. */
|
|
36
|
+
view-transition-name: match-element;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&.top,
|
|
40
|
+
&.bottom {
|
|
41
|
+
inset-inline: 0;
|
|
42
|
+
width: auto;
|
|
43
|
+
max-height: var(--modal-max-height, 100%);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Side panels are not inside the `<Dialog>` wrapper's width, so they set their own. */
|
|
47
|
+
&.left,
|
|
48
|
+
&.right {
|
|
49
|
+
inset-block: 0;
|
|
50
|
+
width: var(--modal-width, var(--width-narrow));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* If a theme sets `--modal-stroke` or `--modal-border`, keep only the border on the inner side, because the other sides touch the edges of the screen. */
|
|
54
|
+
&.top {
|
|
55
|
+
top: 0;
|
|
56
|
+
border-top: none;
|
|
57
|
+
border-inline: none;
|
|
58
|
+
view-transition-class: modal-top;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&.bottom {
|
|
62
|
+
bottom: 0;
|
|
63
|
+
border-bottom: none;
|
|
64
|
+
border-inline: none;
|
|
65
|
+
view-transition-class: modal-bottom;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&.left {
|
|
69
|
+
left: 0;
|
|
70
|
+
border-left: none;
|
|
71
|
+
border-block: none;
|
|
72
|
+
view-transition-class: modal-left;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
&.right {
|
|
76
|
+
right: 0;
|
|
77
|
+
border-right: none;
|
|
78
|
+
border-block: none;
|
|
79
|
+
view-transition-class: modal-right;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Corners that touch a screen edge are square. This is in `overrides` so it beats the `radius` variant, which then sets only the inner corners. */
|
|
85
|
+
@layer overrides {
|
|
86
|
+
.modal {
|
|
87
|
+
&.top {
|
|
88
|
+
border-top-left-radius: 0;
|
|
89
|
+
border-top-right-radius: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&.bottom {
|
|
93
|
+
border-bottom-left-radius: 0;
|
|
94
|
+
border-bottom-right-radius: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Side panels are full height, so all their corners touch an edge. */
|
|
98
|
+
&.left,
|
|
99
|
+
&.right {
|
|
100
|
+
border-radius: 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* View transitions for panels pinned to an edge.
|
|
107
|
+
* - These rules must stay in this file. Bun hashes the `view-transition-class` values above and the class names in the selectors below the same way only when both are in the same file.
|
|
108
|
+
* - Each keyframe only sets `from`, so it ends at the panel's own position. The exit plays the same keyframe in reverse.
|
|
109
|
+
* - `:only-child` limits the slide to enter (a new image with no old one) and exit (an old image with no new one). A panel that stays open is in every view transition on the page as an old and a new image, and must not move.
|
|
110
|
+
*/
|
|
111
|
+
@keyframes modal-from-top {
|
|
112
|
+
from {
|
|
113
|
+
transform: translateY(-100%);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@keyframes modal-from-right {
|
|
118
|
+
from {
|
|
119
|
+
transform: translateX(100%);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@keyframes modal-from-bottom {
|
|
124
|
+
from {
|
|
125
|
+
transform: translateY(100%);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@keyframes modal-from-left {
|
|
130
|
+
from {
|
|
131
|
+
transform: translateX(-100%);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@keyframes modal-fade {
|
|
136
|
+
from {
|
|
137
|
+
opacity: 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
::view-transition-new(.modal-top):only-child,
|
|
142
|
+
::view-transition-new(.modal-right):only-child,
|
|
143
|
+
::view-transition-new(.modal-bottom):only-child,
|
|
144
|
+
::view-transition-new(.modal-left):only-child {
|
|
145
|
+
animation: var(--modal-transition-duration, var(--duration-fast)) ease-out both;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
::view-transition-old(.modal-top):only-child,
|
|
149
|
+
::view-transition-old(.modal-right):only-child,
|
|
150
|
+
::view-transition-old(.modal-bottom):only-child,
|
|
151
|
+
::view-transition-old(.modal-left):only-child {
|
|
152
|
+
animation: var(--modal-transition-duration, var(--duration-fast)) ease-in reverse both;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
::view-transition-new(.modal-top):only-child,
|
|
156
|
+
::view-transition-old(.modal-top):only-child {
|
|
157
|
+
animation-name: modal-from-top;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
::view-transition-new(.modal-right):only-child,
|
|
161
|
+
::view-transition-old(.modal-right):only-child {
|
|
162
|
+
animation-name: modal-from-right;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
::view-transition-new(.modal-bottom):only-child,
|
|
166
|
+
::view-transition-old(.modal-bottom):only-child {
|
|
167
|
+
animation-name: modal-from-bottom;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
::view-transition-new(.modal-left):only-child,
|
|
171
|
+
::view-transition-old(.modal-left):only-child {
|
|
172
|
+
animation-name: modal-from-left;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Reduced motion: fade the panel in and out in place of the slide. */
|
|
176
|
+
@media (prefers-reduced-motion: reduce) {
|
|
177
|
+
::view-transition-new(.modal-top):only-child,
|
|
178
|
+
::view-transition-old(.modal-top):only-child,
|
|
179
|
+
::view-transition-new(.modal-right):only-child,
|
|
180
|
+
::view-transition-old(.modal-right):only-child,
|
|
181
|
+
::view-transition-new(.modal-bottom):only-child,
|
|
182
|
+
::view-transition-old(.modal-bottom):only-child,
|
|
183
|
+
::view-transition-new(.modal-left):only-child,
|
|
184
|
+
::view-transition-old(.modal-left):only-child {
|
|
185
|
+
animation-name: modal-fade;
|
|
25
186
|
}
|
|
26
187
|
}
|
package/ui/dialog/Modal.tsx
CHANGED
|
@@ -1,21 +1,78 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
+
import { getIndentClass, type IndentVariants } from "../style/Indent.js";
|
|
3
|
+
import { getPaddingClass, type PaddingVariants } from "../style/Padding.js";
|
|
4
|
+
import { getRadiusClass, type RadiusVariants } from "../style/Radius.js";
|
|
5
|
+
import { getShadowClass, type ShadowVariants } from "../style/Shadow.js";
|
|
2
6
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
3
7
|
import type { ClassProps, OptionalChildProps } from "../util/props.js";
|
|
4
8
|
import styles from "./Modal.module.css";
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
11
|
+
* Variant props for `<Modal>` — pin the panel to one edge of the screen.
|
|
12
|
+
*
|
|
13
|
+
* - Set one of `top`, `right`, `bottom`, or `left`. Without one, the panel is centred.
|
|
14
|
+
* - If more than one is set, the first in the order `top`, `right`, `bottom`, `left` wins.
|
|
15
|
+
*
|
|
16
|
+
* @see https://shelving.cc/ui/ModalVariants
|
|
17
|
+
*/
|
|
18
|
+
export interface ModalVariants {
|
|
19
|
+
/** Pin the panel to the top edge, full width, and slide it in from the top. */
|
|
20
|
+
top?: boolean | undefined;
|
|
21
|
+
/** Pin the panel to the right edge, full height, and slide it in from the right. */
|
|
22
|
+
right?: boolean | undefined;
|
|
23
|
+
/** Pin the panel to the bottom edge, full width, and slide it in from the bottom. */
|
|
24
|
+
bottom?: boolean | undefined;
|
|
25
|
+
/** Pin the panel to the left edge, full height, and slide it in from the left. */
|
|
26
|
+
left?: boolean | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Props for `<Modal>` — edge, padding, indent, radius, and shadow variants, optional `children` content, and an optional `className`.
|
|
8
31
|
*
|
|
9
32
|
* @see https://shelving.cc/ui/ModalProps
|
|
10
33
|
*/
|
|
11
|
-
export interface ModalProps
|
|
34
|
+
export interface ModalProps
|
|
35
|
+
extends ModalVariants,
|
|
36
|
+
PaddingVariants,
|
|
37
|
+
IndentVariants,
|
|
38
|
+
RadiusVariants,
|
|
39
|
+
ShadowVariants,
|
|
40
|
+
OptionalChildProps,
|
|
41
|
+
ClassProps {}
|
|
12
42
|
|
|
13
43
|
/**
|
|
14
|
-
* Styled `<aside>`
|
|
44
|
+
* Styled `<aside>` panel for content inside a `<Dialog>`, with dark text on a light surface.
|
|
45
|
+
*
|
|
46
|
+
* - Centred by default. It fades in and out with its `<Dialog>`.
|
|
47
|
+
* - Has a `--shadow-normal` drop shadow by default. Set `shadow="none"`, `shadow="small"` or `shadow="large"` to change it.
|
|
48
|
+
* - `top`, `right`, `bottom`, or `left` pins it to that edge. It then takes its own layer in the `<Dialog>` view transition, and slides in from that edge and out to it.
|
|
15
49
|
*
|
|
16
50
|
* @kind component
|
|
17
51
|
* @see https://shelving.cc/ui/Modal
|
|
18
52
|
*/
|
|
19
|
-
export function Modal({ children, className }: ModalProps): ReactElement {
|
|
20
|
-
|
|
53
|
+
export function Modal({ children, className, ...props }: ModalProps): ReactElement {
|
|
54
|
+
const side = _getSide(props);
|
|
55
|
+
return (
|
|
56
|
+
<aside
|
|
57
|
+
className={getClass(
|
|
58
|
+
getModuleClass(styles, "modal"), //
|
|
59
|
+
side && getModuleClass(styles, side),
|
|
60
|
+
getPaddingClass(props),
|
|
61
|
+
getIndentClass(props),
|
|
62
|
+
getRadiusClass(props),
|
|
63
|
+
getShadowClass(props),
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</aside>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Get the edge a modal is pinned to, if any. */
|
|
73
|
+
function _getSide({ top, right, bottom, left }: ModalVariants): keyof ModalVariants | undefined {
|
|
74
|
+
if (top) return "top";
|
|
75
|
+
if (right) return "right";
|
|
76
|
+
if (bottom) return "bottom";
|
|
77
|
+
if (left) return "left";
|
|
21
78
|
}
|
package/ui/notice/Notices.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import { type FlexVariants } from "../style/Flex.js";
|
|
3
3
|
import type { ClassProps } from "../util/props.js";
|
|
4
|
+
import "./NoticesTransition.css";
|
|
4
5
|
/**
|
|
5
6
|
* Props for `<Notices>` — flex styling variants for the notices container.
|
|
6
7
|
*
|
|
@@ -12,6 +13,7 @@ export interface NoticesProps extends FlexVariants, ClassProps {
|
|
|
12
13
|
* Render the global list of notices and subscribe to incoming `"notice"` events.
|
|
13
14
|
* - Listens for `"notice"` events on `window` (or that bubble up to `window`) and shows them in the global notice list.
|
|
14
15
|
* - This is how e.g. `<Button>` and `<FormNotify>` components send notices into the global list.
|
|
16
|
+
* - Each notice slides in and out in its own view transition. The other notices move to their new places in the same transition.
|
|
15
17
|
*
|
|
16
18
|
* @kind component
|
|
17
19
|
* @see https://shelving.cc/ui/Notices
|
package/ui/notice/Notices.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect } from "react";
|
|
3
|
-
import { useStore } from "../../react/useStore.js";
|
|
2
|
+
import { useEffect, ViewTransition } from "react";
|
|
4
3
|
import { getFlexClass } from "../style/Flex.js";
|
|
4
|
+
import { useTransitionValue } from "../transition/useTransitionValue.js";
|
|
5
5
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
6
6
|
import { subscribeNotices } from "../util/notice.js";
|
|
7
7
|
import { Notice } from "./Notice.js";
|
|
8
|
+
import "./NoticesTransition.css";
|
|
8
9
|
import NOTICES_CSS from "./Notices.module.css";
|
|
9
10
|
import { NOTICES } from "./NoticesStore.js";
|
|
10
11
|
const NOTICES_CLASS = getModuleClass(NOTICES_CSS, "notices");
|
|
@@ -12,15 +13,24 @@ const NOTICES_CLASS = getModuleClass(NOTICES_CSS, "notices");
|
|
|
12
13
|
* Render the global list of notices and subscribe to incoming `"notice"` events.
|
|
13
14
|
* - Listens for `"notice"` events on `window` (or that bubble up to `window`) and shows them in the global notice list.
|
|
14
15
|
* - This is how e.g. `<Button>` and `<FormNotify>` components send notices into the global list.
|
|
16
|
+
* - Each notice slides in and out in its own view transition. The other notices move to their new places in the same transition.
|
|
15
17
|
*
|
|
16
18
|
* @kind component
|
|
17
19
|
* @see https://shelving.cc/ui/Notices
|
|
18
20
|
*/
|
|
19
21
|
export function Notices({ className, ...props }) {
|
|
20
|
-
const notices =
|
|
22
|
+
const notices = useTransitionValue(NOTICES);
|
|
21
23
|
useEffect(() => {
|
|
22
24
|
// Subscribe to global notices.
|
|
23
25
|
return subscribeNotices((message, status) => NOTICES.show(message, status));
|
|
24
26
|
});
|
|
25
|
-
return (_jsx("aside", { className: getClass(NOTICES_CLASS, getFlexClass(props), className), children: notices.map(
|
|
27
|
+
return (_jsx("aside", { className: getClass(NOTICES_CLASS, getFlexClass(props), className), children: notices.map(notice => (_jsx(NoticeItem, { notice: notice }, notice.key))) }));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Render one notice in its own view transition, and re-render when the notice changes in place.
|
|
31
|
+
* - Module-private, but named without the `_` prefix: React and the hooks lint rule only treat a capitalised name as a component.
|
|
32
|
+
*/
|
|
33
|
+
function NoticeItem({ notice }) {
|
|
34
|
+
const { children, status } = useTransitionValue(notice);
|
|
35
|
+
return (_jsx(ViewTransition, { enter: "notice", exit: "notice", update: "notice-update", children: _jsx(Notice, { status: status, children: children }) }));
|
|
26
36
|
}
|
package/ui/notice/Notices.md
CHANGED
|
@@ -7,6 +7,8 @@ Renders the global list of active notices and subscribes to incoming `"notice"`
|
|
|
7
7
|
- Mount `<Notices>` once near the root of your app. It renders at that point in the DOM and listens automatically — no context required.
|
|
8
8
|
- Notices auto-dismiss after a short delay unless they carry a `"loading"` status.
|
|
9
9
|
- Backed by the `NOTICES` store singleton; for advanced use you can keep a reference to a notice to update or close it manually.
|
|
10
|
+
- Notices animate with [view transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API). A new notice slides in from the right, and a closed notice slides out to the right. The other notices move to their new places. A notice that changes in place (for example from loading to success) changes at once, with no animation.
|
|
11
|
+
- With reduced motion, notices fade in and out in place, and the others move at once. A browser without view transitions shows the changes at once.
|
|
10
12
|
|
|
11
13
|
## Usage
|
|
12
14
|
|
|
@@ -51,3 +53,16 @@ await uploadFile(file);
|
|
|
51
53
|
notice.show("Upload complete.", "success"); // Update in place.
|
|
52
54
|
notice.close(); // Or close it immediately.
|
|
53
55
|
```
|
|
56
|
+
|
|
57
|
+
## Styling
|
|
58
|
+
|
|
59
|
+
`Notices` positions the list in the bottom-right corner. Each notice is only as wide as its content and is right-aligned. The list is never wider than `--notices-width`, and keeps `--notices-offset` clear at both sides on a narrow screen. Clicks pass through the gaps between notices to the page. Each item is a `<Notice>`, which has its own hooks. Override these hooks at `:root` (or any ancestor scope) to retheme.
|
|
60
|
+
|
|
61
|
+
| Variable | Styles | Default |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `--notices-offset` | Distance of the list from the bottom, right, and left edges | `var(--space-normal)` (16px) |
|
|
64
|
+
| `--notices-width` | Maximum width of the list and of each notice | `var(--width-narrow)` |
|
|
65
|
+
| `--notices-gap` | Gap between notices | `var(--space-small)` (12px) |
|
|
66
|
+
| `--notices-transition-duration` | Length of the slide in, slide out, and move | `var(--duration-fast)` (150ms) |
|
|
67
|
+
|
|
68
|
+
**Global tokens it reads** — move these to retheme broadly: `--space-normal`, `--space-small`, `--width-narrow`, and `--duration-fast`.
|
|
@@ -5,12 +5,25 @@
|
|
|
5
5
|
@layer components {
|
|
6
6
|
.notices {
|
|
7
7
|
position: fixed;
|
|
8
|
-
bottom: var(--notices-offset, var(--space-normal));
|
|
9
8
|
right: var(--notices-offset, var(--space-normal));
|
|
9
|
+
bottom: var(--notices-offset, var(--space-normal));
|
|
10
|
+
left: var(--notices-offset, var(--space-normal));
|
|
10
11
|
z-index: 1000;
|
|
11
12
|
display: flex;
|
|
13
|
+
margin-left: auto;
|
|
12
14
|
max-width: var(--notices-width, var(--width-narrow));
|
|
13
15
|
flex-direction: column;
|
|
16
|
+
align-items: flex-end;
|
|
14
17
|
gap: var(--notices-gap, var(--space-small));
|
|
18
|
+
|
|
19
|
+
/* The list is as wide as `--notices-width`, so let clicks pass through the gaps to the page. */
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
|
|
22
|
+
/* Each notice is only as wide as its content, and stays on the right. */
|
|
23
|
+
& > * {
|
|
24
|
+
inline-size: auto;
|
|
25
|
+
max-width: 100%;
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
}
|
|
15
28
|
}
|
|
16
29
|
}
|
package/ui/notice/Notices.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { type ReactElement, useEffect } from "react";
|
|
2
|
-
import { useStore } from "../../react/useStore.js";
|
|
1
|
+
import { type ReactElement, useEffect, ViewTransition } from "react";
|
|
3
2
|
import { type FlexVariants, getFlexClass } from "../style/Flex.js";
|
|
3
|
+
import type { Status } from "../style/Status.js";
|
|
4
|
+
import { useTransitionValue } from "../transition/useTransitionValue.js";
|
|
4
5
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
5
6
|
import { subscribeNotices } from "../util/notice.js";
|
|
6
7
|
import type { ClassProps } from "../util/props.js";
|
|
7
8
|
import { Notice } from "./Notice.js";
|
|
9
|
+
import type { NoticeStore } from "./NoticeStore.js";
|
|
10
|
+
import "./NoticesTransition.css";
|
|
8
11
|
import NOTICES_CSS from "./Notices.module.css";
|
|
9
12
|
import { NOTICES } from "./NoticesStore.js";
|
|
10
13
|
|
|
@@ -21,21 +24,35 @@ export interface NoticesProps extends FlexVariants, ClassProps {}
|
|
|
21
24
|
* Render the global list of notices and subscribe to incoming `"notice"` events.
|
|
22
25
|
* - Listens for `"notice"` events on `window` (or that bubble up to `window`) and shows them in the global notice list.
|
|
23
26
|
* - This is how e.g. `<Button>` and `<FormNotify>` components send notices into the global list.
|
|
27
|
+
* - Each notice slides in and out in its own view transition. The other notices move to their new places in the same transition.
|
|
24
28
|
*
|
|
25
29
|
* @kind component
|
|
26
30
|
* @see https://shelving.cc/ui/Notices
|
|
27
31
|
*/
|
|
28
32
|
export function Notices({ className, ...props }: NoticesProps): ReactElement {
|
|
29
|
-
const notices =
|
|
33
|
+
const notices = useTransitionValue(NOTICES);
|
|
30
34
|
useEffect(() => {
|
|
31
35
|
// Subscribe to global notices.
|
|
32
36
|
return subscribeNotices((message, status) => NOTICES.show(message, status));
|
|
33
37
|
});
|
|
34
38
|
return (
|
|
35
39
|
<aside className={getClass(NOTICES_CLASS, getFlexClass(props), className)}>
|
|
36
|
-
{notices.map(
|
|
37
|
-
<
|
|
40
|
+
{notices.map(notice => (
|
|
41
|
+
<NoticeItem key={notice.key} notice={notice} />
|
|
38
42
|
))}
|
|
39
43
|
</aside>
|
|
40
44
|
);
|
|
41
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render one notice in its own view transition, and re-render when the notice changes in place.
|
|
49
|
+
* - Module-private, but named without the `_` prefix: React and the hooks lint rule only treat a capitalised name as a component.
|
|
50
|
+
*/
|
|
51
|
+
function NoticeItem({ notice }: { notice: NoticeStore<Status> }): ReactElement {
|
|
52
|
+
const { children, status } = useTransitionValue(notice);
|
|
53
|
+
return (
|
|
54
|
+
<ViewTransition enter="notice" exit="notice" update="notice-update">
|
|
55
|
+
<Notice status={status}>{children}</Notice>
|
|
56
|
+
</ViewTransition>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* Global CSS, not a CSS module. Bun hashes a class inside `::view-transition-*()` from the file basename, but hashes the exported names from the file path, so a module-scoped view-transition class can never match the name that JS passes to `view-transition-class`. Keep these names global until Bun fixes this — see dhoulb/shelving#325. */
|
|
2
|
+
@import url("../style/layers.css");
|
|
3
|
+
@import url("../style/Duration.module.css");
|
|
4
|
+
|
|
5
|
+
@layer defaults {
|
|
6
|
+
:root {
|
|
7
|
+
--notices-transition-duration: var(--duration-fast);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* Only `from` is set, so the keyframe ends at the snapshot's own position. The exit plays it in reverse. */
|
|
12
|
+
@keyframes notice-from-right {
|
|
13
|
+
from {
|
|
14
|
+
opacity: 0;
|
|
15
|
+
transform: translateX(100%);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@keyframes notice-fade {
|
|
20
|
+
from {
|
|
21
|
+
opacity: 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* A new notice slides in from the right. */
|
|
26
|
+
::view-transition-new(.notice) {
|
|
27
|
+
animation: notice-from-right var(--notices-transition-duration) ease-out both;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* A closed notice slides out to the right. */
|
|
31
|
+
::view-transition-old(.notice) {
|
|
32
|
+
animation: notice-from-right var(--notices-transition-duration) ease-in reverse both;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* The other notices move to their new places (the default group morph). */
|
|
36
|
+
::view-transition-group(.notice-update) {
|
|
37
|
+
animation-duration: var(--notices-transition-duration);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* A notice that changes in place does not animate: hide the old image, and show the new image at once at its own size.
|
|
42
|
+
* - The group still morphs between the old and new size, so the new image is drawn unscaled from the bottom-right corner, which does not move.
|
|
43
|
+
* - For a notice that only moves, the old and new images are the same, so this changes nothing.
|
|
44
|
+
*/
|
|
45
|
+
::view-transition-old(.notice-update) {
|
|
46
|
+
animation: none;
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
::view-transition-new(.notice-update) {
|
|
51
|
+
animation: none;
|
|
52
|
+
inset: auto 0 0 auto;
|
|
53
|
+
width: auto;
|
|
54
|
+
height: auto;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Reduced motion: fade new and closed notices in place, and move the others at once. */
|
|
58
|
+
@media (prefers-reduced-motion: reduce) {
|
|
59
|
+
::view-transition-new(.notice),
|
|
60
|
+
::view-transition-old(.notice) {
|
|
61
|
+
animation-name: notice-fade;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
::view-transition-group(.notice-update) {
|
|
65
|
+
animation: none;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AnyStore } from "../../store/Store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read a store's value, and re-render inside `startTransition()` when it changes, so `<ViewTransition>` boundaries animate.
|
|
4
|
+
*
|
|
5
|
+
* - `useStore()` uses `useSyncExternalStore()`, and React always applies those updates synchronously. A synchronous update never starts a view transition.
|
|
6
|
+
* - This hook copies the value into React state inside `startTransition()` instead.
|
|
7
|
+
* - Internal for now: not exported from the `shelving/ui` barrel.
|
|
8
|
+
*
|
|
9
|
+
* @param store The store to read. Its value must not be loading or failed.
|
|
10
|
+
* @returns The store's current value.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useTransitionValue<S extends AnyStore>(store: S): S["value"];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { startTransition, useEffect, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Read a store's value, and re-render inside `startTransition()` when it changes, so `<ViewTransition>` boundaries animate.
|
|
4
|
+
*
|
|
5
|
+
* - `useStore()` uses `useSyncExternalStore()`, and React always applies those updates synchronously. A synchronous update never starts a view transition.
|
|
6
|
+
* - This hook copies the value into React state inside `startTransition()` instead.
|
|
7
|
+
* - Internal for now: not exported from the `shelving/ui` barrel.
|
|
8
|
+
*
|
|
9
|
+
* @param store The store to read. Its value must not be loading or failed.
|
|
10
|
+
* @returns The store's current value.
|
|
11
|
+
*/
|
|
12
|
+
export function useTransitionValue(store) {
|
|
13
|
+
const [value, setValue] = useState(() => store.value);
|
|
14
|
+
useEffect(() => store.subscribe((v) => startTransition(() => setValue(() => v))), [store]);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { startTransition, useEffect, useState } from "react";
|
|
2
|
+
import type { AnyStore } from "../../store/Store.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a store's value, and re-render inside `startTransition()` when it changes, so `<ViewTransition>` boundaries animate.
|
|
6
|
+
*
|
|
7
|
+
* - `useStore()` uses `useSyncExternalStore()`, and React always applies those updates synchronously. A synchronous update never starts a view transition.
|
|
8
|
+
* - This hook copies the value into React state inside `startTransition()` instead.
|
|
9
|
+
* - Internal for now: not exported from the `shelving/ui` barrel.
|
|
10
|
+
*
|
|
11
|
+
* @param store The store to read. Its value must not be loading or failed.
|
|
12
|
+
* @returns The store's current value.
|
|
13
|
+
*/
|
|
14
|
+
export function useTransitionValue<S extends AnyStore>(store: S): S["value"] {
|
|
15
|
+
const [value, setValue] = useState<S["value"]>(() => store.value);
|
|
16
|
+
useEffect(() => store.subscribe((v: S["value"]) => startTransition(() => setValue(() => v))), [store]);
|
|
17
|
+
return value;
|
|
18
|
+
}
|