shelving 1.285.6 → 1.286.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -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
- * Props for `<Modal>` — optional `children` content.
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 extends OptionalChildProps, ClassProps {}
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>` overlay container for modal content.
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
- return <aside className={getClass(getModuleClass(styles, "modal"), className)}>{children}</aside>;
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
  }
@@ -43,8 +43,8 @@ Layouts compose naturally as `<Router>` route values — wrap a group of routes
43
43
  | `--sidebar-layout-color` | Text colour for the layout (set on `body`, inherited by the content column) | `var(--tint-00)` (black) |
44
44
  | `--sidebar-layout-sidebar-background` | Sidebar column fill | `var(--tint-90)` (one shade darker than the page) |
45
45
  | `--sidebar-layout-sidebar-color` | Sidebar column text colour | `var(--tint-00)` (black) |
46
- | `--sidebar-layout-border` | Divider between sidebar and content | `var(--stroke-normal) solid var(--tint-80)` |
46
+ | `--sidebar-layout-border` | Divider between sidebar and content, e.g. `1px solid var(--tint-80)` | `none` |
47
47
 
48
48
  The sidebar and content columns own their own scroll behaviour directly (this layout no longer composes a shared `.layout` class).
49
49
 
50
- **Global tokens it reads** — `--tint-00` / `--tint-80` / `--tint-90` / `--tint-100`, plus `--space-normal`, `--stroke-normal`, `--duration-normal`, and `--color-shadow`.
50
+ **Global tokens it reads** — `--tint-00` / `--tint-90` / `--tint-100`, plus `--space-normal`, `--duration-normal`, and `--color-shadow`.
@@ -2,7 +2,6 @@
2
2
  @import url("../style/Color.module.css");
3
3
  @import url("../style/Duration.module.css");
4
4
  @import url("../style/Space.module.css");
5
- @import url("../style/Stroke.module.css");
6
5
  @import url("../style/Tint.module.css");
7
6
  @import url("../style/Shadow.module.css");
8
7
 
@@ -74,7 +73,7 @@
74
73
  .sidebar {
75
74
  /* Box */
76
75
  display: flow-root;
77
- border-right: var(--sidebar-layout-border, var(--stroke-normal) solid var(--tint-80));
76
+ border-right: var(--sidebar-layout-border, none);
78
77
 
79
78
  /* Scrolling */
80
79
  overflow-y: auto;
@@ -118,7 +117,7 @@
118
117
  .main.right .sidebar {
119
118
  grid-column: 2;
120
119
  border-right: none;
121
- border-left: var(--sidebar-layout-border, var(--stroke-normal) solid var(--tint-80));
120
+ border-left: var(--sidebar-layout-border, none);
122
121
  }
123
122
 
124
123
  .main.right .content {
package/ui/menu/Menu.md CHANGED
@@ -29,13 +29,13 @@ import { Menu, MenuItem } from "shelving/ui";
29
29
 
30
30
  | Variable | Styles | Default |
31
31
  |---|---|---|
32
- | `--menu-gap` | Vertical gap between items | `var(--space-xxsmall)` |
32
+ | `--menu-gap` | Vertical gap between items | `0` |
33
33
  | `--menu-font` | Font family | `var(--font-body)` |
34
34
  | `--menu-size` | Font size | `var(--size-normal)` |
35
35
  | `--menu-leading` | Line height | `var(--leading)` |
36
36
  | `--menu-color` | Text colour | `var(--tint-00)` |
37
37
  | `--menu-nested-space` | Block margin around a nested submenu | `var(--space-xxsmall)` |
38
- | `--menu-padding` | Item link padding (also insets the nested border) | `var(--space-xxsmall)` |
38
+ | `--menu-padding` | Item link padding (also insets the nested border) | `var(--space-xsmall)` |
39
39
  | `--menu-nested-border` | Nested submenu left-border width | `var(--stroke-focus)` |
40
40
  | `--menu-nested-color-border` | Nested submenu left-border colour | `var(--tint-50)` |
41
41
  | `--menu-nested-indent` | Nested submenu left padding | `var(--space-xsmall)` |
@@ -13,7 +13,7 @@
13
13
  padding: 0;
14
14
  display: flex;
15
15
  flex-direction: column;
16
- gap: var(--menu-gap, var(--space-xxsmall));
16
+ gap: var(--menu-gap, 0);
17
17
 
18
18
  /* Text */
19
19
  font-family: var(--menu-font, var(--font-body));
@@ -26,7 +26,7 @@
26
26
  margin-block: var(--menu-nested-space, var(--space-xxsmall));
27
27
 
28
28
  /* Inset the border by the link's inline padding so it lines up with the parent item's label. */
29
- margin-inline-start: var(--menu-padding, var(--space-xxsmall));
29
+ margin-inline-start: var(--menu-padding, var(--space-xsmall));
30
30
  border-inline-start: var(--menu-nested-border, var(--stroke-focus)) solid var(--menu-nested-color-border, var(--tint-50));
31
31
  padding-inline-start: var(--menu-nested-indent, var(--space-xsmall));
32
32
  }
@@ -37,12 +37,13 @@
37
37
  }
38
38
 
39
39
  .link {
40
- /* Box — `inline-size` and `border` also reset the `<button>` an `onClick` item renders. */
40
+ /* Box — `inline-size` and `border` also reset the `<button>` an `onClick` item renders. `border-box` keeps the padding inside the full width. */
41
41
  display: block;
42
+ box-sizing: border-box;
42
43
  inline-size: 100%;
43
- padding: var(--menu-padding, var(--space-xxsmall));
44
+ padding: var(--menu-padding, var(--space-xsmall));
44
45
  border: none;
45
- border-radius: var(--menu-radius, var(--radius-xxsmall));
46
+ border-radius: var(--menu-radius, var(--radius-xsmall));
46
47
 
47
48
  /* Style — `background`, `font`, `text-align` and `cursor` reset the native button look. */
48
49
  background: none;
@@ -53,11 +54,11 @@
53
54
  cursor: pointer;
54
55
  transition: all 120ms ease-in-out;
55
56
  outline: var(--menu-focus-border, var(--stroke-focus) solid var(--color-focus));
56
- outline-offset: calc(0px - var(--stroke-normal));
57
+ outline-offset: calc(0px - var(--stroke-focus)); /* Fully inset, so a neighbouring item's background can't paint over it. */
57
58
 
58
- /* Pseudo-classes */
59
- &:hover,
60
- &:focus:not(:focus-visible) {
59
+ /* Pseudo-classes — skip the active item, so it keeps its own background while hovered or focused. */
60
+ &:not(.active):hover,
61
+ &:not(.active):focus:not(:focus-visible) {
61
62
  background: var(--menu-hover-background, var(--tint-90));
62
63
  color: var(--menu-hover-color, var(--tint-00));
63
64
  }