shelving 1.285.7 → 1.286.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/ui/block/Details.d.ts +12 -7
- package/ui/block/Details.js +9 -7
- package/ui/block/Details.md +63 -0
- package/ui/block/Details.module.css +7 -24
- package/ui/block/Details.tsx +17 -11
- package/ui/button/FullscreenButton.js +1 -1
- package/ui/button/FullscreenButton.tsx +1 -1
- package/ui/button/RetryButton.js +1 -1
- package/ui/button/RetryButton.tsx +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/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
|
}
|