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.
@@ -1,9 +1,10 @@
1
1
  import { XMarkIcon } from "@heroicons/react/24/solid";
2
- import { type MouseEvent, memo, type ReactElement, Suspense, useEffect, useRef } from "react";
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 close button.
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
- useEffect(() => {
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
- {/** biome-ignore lint/a11y/useKeyWithClickEvents: Dialogs also show a close button. */}
37
- <dialog ref={ref} className={getModuleClass(styles, "dialog")} onClick={_closeOnBackdropClick} onClose={onClose} {...props}>
38
- {children}
39
- <div className={getModuleClass(styles, "close")}>
40
- <DialogCloseButton />
41
- </div>
42
- </dialog>
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.close();
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.close();
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")?.close();
117
+ const dialog = currentTarget.closest("dialog");
118
+ if (dialog) _requestClose(dialog);
91
119
  }
@@ -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; closed dialogs are removed after an animation delay.
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 | null;
63
+ export declare function Dialogs(): ReactNode;
63
64
  export {};
@@ -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; closed dialogs are removed after an animation delay.
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>` is closed, wait for the animation to finish then remove the dialog from the list.
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
- const dialogs = useStore(requireDialogs());
72
- return dialogs ? dialogs.value : null;
68
+ return useTransitionValue(requireDialogs());
73
69
  }
@@ -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; closed dialogs are removed after an animation delay.
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>` is closed, wait for the animation to finish then remove the dialog from the list.
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 | null {
101
- const dialogs = useStore(requireDialogs());
102
- return dialogs ? dialogs.value : null;
96
+ export function Dialogs(): ReactNode {
97
+ return useTransitionValue(requireDialogs());
103
98
  }
@@ -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
- * Props for `<Modal>` — optional `children` content.
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>` overlay container for modal content.
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;
@@ -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>` overlay container for modal content.
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
- return _jsx("aside", { className: getClass(getModuleClass(styles, "modal"), className), children: children });
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
  }
@@ -1,35 +1,114 @@
1
1
  # Modal
2
2
 
3
- A non-blocking `<aside>` overlay for persistent panels — drawers, toasts, and side-sheets that coexist with the page rather than blocking interaction with it. Unlike `<Dialog>`, it is not a native `<dialog>` and does not trap focus or dim the page.
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
- - Reach for `Modal` when the overlay should sit alongside the page (a notification panel, a side drawer); reach for `<Dialog>` when it should block interaction until dismissed.
8
- - It only styles the box — lay out its contents with the usual block components.
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
- import { Modal } from "shelving/ui";
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
- <Modal>
16
- <NotificationPanel />
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 bordered, shadowed surface. Override these hooks at `:root` (or any ancestor scope) to retheme.
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` | Box width | `var(--width-narrow)` |
27
- | `--modal-border` | Border shorthand | `var(--stroke-normal)` solid, 50% of `--tint-50` |
28
- | `--modal-radius` | Corner radius | `var(--radius-normal)` (16px) |
29
- | `--modal-color-bg` | Surface fill | `var(--tint-100)` |
30
- | `--modal-padding` | Inner padding | `var(--space-normal)` (16px) |
31
- | `--modal-color-text` | Text colour | `var(--tint-00)` |
32
- | `--modal-transition` | Transition | `all var(--duration-fast)` (150ms) |
33
- | `--modal-shadow` | Drop shadow | `var(--shadow-normal)` |
34
-
35
- **Global tokens it reads** — move these to retheme broadly: the tint ladder `--tint-00` / `--tint-50` / `--tint-100`, plus `--width-narrow`, `--space-normal`, `--radius-normal`, `--stroke-normal`, `--shadow-normal`, and `--duration-fast`.
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: var(--modal-width, var(--width-narrow));
14
+ min-width: 0;
15
15
  max-width: 100%;
16
- border: var(--modal-border, var(--stroke-normal) solid color-mix(in oklch, var(--tint-50) 50%, transparent));
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-color-bg, var(--tint-100));
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-text, var(--tint-00));
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
  }