@liquefy/cascade.ui 1.0.0 → 2.1.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/README.md +55 -4
- package/package.json +5 -5
- package/src/BasicTheme.js +14 -10
- package/src/Drawer.js +463 -0
- package/src/Dropdown.js +84 -0
- package/src/Layout.js +153 -120
- package/src/Modal.js +136 -0
- package/src/Overlay.js +95 -85
- package/src/OverlayFrame.js +77 -39
- package/src/Popover.js +174 -158
- package/src/index.js +4 -2
- package/src/widgets.js +127 -109
- package/src/Portal.js +0 -132
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cascade.ui
|
|
2
2
|
|
|
3
|
-
Themed widgets, layout, overlays,
|
|
3
|
+
Themed widgets, layout, overlays, modals, popovers and color schemes for
|
|
4
4
|
[Cascade](https://github.com/erobwen/liquefy#readme) - and its basic theme.
|
|
5
5
|
|
|
6
6
|
```console
|
|
@@ -61,13 +61,64 @@ import { button, currentColorScheme } from "@liquefy/cascade.ui";
|
|
|
61
61
|
// button giving it another base color. Everything inside a
|
|
62
62
|
// colorSchemeScope() of that theme follows (put one around your app).
|
|
63
63
|
const scheme = currentColorScheme();
|
|
64
|
-
return button(
|
|
64
|
+
return button(text("Go green"), () => { scheme.base = "#2e7d32"; });
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
## Layout and the rest
|
|
68
68
|
|
|
69
69
|
- `row`, `column`, `filler`, `centerMiddle`, `zStack`, ... and their styles
|
|
70
70
|
(`fillerStyle`, `fitContainerStyle`, ...).
|
|
71
|
-
- `overlayFrame()` and `overlay()` -
|
|
71
|
+
- `overlayFrame()` and `overlay()` - content over the app, shown from
|
|
72
|
+
wherever it's built: any content, custom or animated. One overlay per
|
|
73
|
+
frame: an overlay shown on a frame already showing another evicts it (the
|
|
74
|
+
other isn't shown again until it's shown anew). Overlays stack across
|
|
75
|
+
frames - one opened from inside another's content - not side by side on
|
|
76
|
+
one. By design, for now: this may change should a use case turn up that
|
|
77
|
+
needs several on one frame.
|
|
78
|
+
- `modalAssembly()` - what an `overlay()` usually shows: a backdrop (a click
|
|
79
|
+
on it closes) and the content in a centered window - full screen, without
|
|
80
|
+
the backdrop, in a frame narrower than `fullScreenBelow`, and a themed
|
|
81
|
+
`dialog()` in it follows, back arrow and all. Its parts on their own:
|
|
82
|
+
`modalBackdrop()` and `modal()`.
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
overlay({ showing: this.open },
|
|
86
|
+
modalAssembly({ close, width: 360, fullScreenBelow: 600 },
|
|
87
|
+
dialog({ title: "Settings", close }, ...)))
|
|
88
|
+
```
|
|
89
|
+
- `drawer()` - a panel sliding in from an edge (`side`: left, right, top or
|
|
90
|
+
bottom) of its nearest positioned ancestor, over what's there. `modal`
|
|
91
|
+
adds a backdrop that fades in and closes it when clicked; `header` (the
|
|
92
|
+
default) a title and a chevron that closes it - or, `header: false`, the
|
|
93
|
+
content is the whole panel and calls `close` itself. `collapsed` is
|
|
94
|
+
what's left of it while it's closed: `"nothing"` (the default), a
|
|
95
|
+
`"handle"` on its edge that opens it (`onOpen`), or a `"bar"` - the panel
|
|
96
|
+
collapsed to a thin strip along the edge, with a chevron that opens it
|
|
97
|
+
and, after that, `bar`: shortcuts of the app's own. Opening it, the bar
|
|
98
|
+
grows into the panel: a component the app puts in `bar` while closed and
|
|
99
|
+
in the content while open (the same one, keyed) flies to its new place,
|
|
100
|
+
and the rest fades. Leave `drawerBarSize` of room for it. The panel is always
|
|
101
|
+
there - just past the edge, and inert, when closed - and a
|
|
102
|
+
FlipAnimationContainer animates it moving between the two. Moved to
|
|
103
|
+
another edge, it closes on one and opens on the other.
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
drawer({ open: this.open, close, side: "right", modal: true, title: "Filters" }, ...)
|
|
107
|
+
|
|
108
|
+
drawer({ open: this.open, close, onOpen, title: "Filters", collapsed: "bar",
|
|
109
|
+
bar: [iconButton({ icon: "movie", title: "Films", onClick: chooseFilms })] }, ...)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Several at once, one per edge, each opened and closed on its own - with
|
|
113
|
+
`drawers()` and a `drawerPanel()` each (one shared backdrop when modal,
|
|
114
|
+
closing every open one):
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
drawers({ modal: false },
|
|
118
|
+
drawerPanel({ side: "left", open: this.navigationOpen, close: closeNavigation, title: "Pages" }, ...),
|
|
119
|
+
drawerPanel({ side: "right", open: this.inspectorOpen, close: closeInspector, title: "Properties" }, ...))
|
|
120
|
+
```
|
|
72
121
|
- `popover()` - beside an element, following it when it moves.
|
|
73
|
-
- `
|
|
122
|
+
- `dropdown({ options, value, onSelect })` - one choice out of several: a button
|
|
123
|
+
showing it, opening a list of them all in a popover. Made of the theme's own
|
|
124
|
+
button, card and list items; an option's `style` previews it in the list.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liquefy/cascade.ui",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Themed widgets, layout, overlays,
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Themed widgets, layout, overlays, modals, popovers, a dropdown and color schemes for Cascade - and its basic theme.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
|
7
7
|
"ui",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"url": "https://github.com/erobwen/liquefy/issues"
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/erobwen/liquefy/tree/main/cascade.ui#readme",
|
|
35
|
-
"
|
|
36
|
-
"@liquefy/cascade.component": "^
|
|
37
|
-
"@liquefy/cascade.dom": "^
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@liquefy/cascade.component": "^2.0.0",
|
|
37
|
+
"@liquefy/cascade.dom": "^2.2.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"mocha": "^10.8.2",
|
package/src/BasicTheme.js
CHANGED
|
@@ -55,8 +55,9 @@ const styleSheet = `
|
|
|
55
55
|
width: 36px; height: 36px; padding: 0; margin: 0; border: none; border-radius: 6px;
|
|
56
56
|
color: inherit; background: transparent; cursor: pointer; transition: background-color 0.15s;
|
|
57
57
|
}
|
|
58
|
-
.cb-icon-button:hover { background: rgba(127, 150, 175, 0.18); }
|
|
59
|
-
.cb-icon-button:active { background: rgba(127, 150, 175, 0.32); }
|
|
58
|
+
.cb-icon-button:hover:not(:disabled) { background: rgba(127, 150, 175, 0.18); }
|
|
59
|
+
.cb-icon-button:active:not(:disabled) { background: rgba(127, 150, 175, 0.32); }
|
|
60
|
+
.cb-icon-button:disabled { opacity: 0.38; cursor: default; }
|
|
60
61
|
.cb-list-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; user-select: none; transition: background-color 0.15s; }
|
|
61
62
|
.cb-list-item:hover { background: ${c.accentFaint}; }
|
|
62
63
|
.cb-list-item.cb-active { background: ${c.accentSoft}; font-weight: bold; }
|
|
@@ -154,14 +155,17 @@ class BasicDialog extends Component {
|
|
|
154
155
|
setProperties({ title, close, fullScreen, style, children }) {
|
|
155
156
|
this.title = title || "";
|
|
156
157
|
this.close = close || null;
|
|
157
|
-
|
|
158
|
+
// Given, or - unset - as the modal() it's shown in presents it (see
|
|
159
|
+
// cascade.ui's Modal.js).
|
|
160
|
+
this.fullScreen = typeof(fullScreen) === "boolean" ? fullScreen : null;
|
|
158
161
|
this.style = frozen(style || null);
|
|
159
162
|
this.dialogChildren = frozen(children || []);
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
build() {
|
|
166
|
+
const fullScreen = this.fullScreen !== null ? this.fullScreen : this.inherit("modalPresentation") === "fullScreen";
|
|
163
167
|
const close = callback("close", () => this.close && this.close());
|
|
164
|
-
const frame =
|
|
168
|
+
const frame = fullScreen
|
|
165
169
|
? { width: "100%", height: "100%" }
|
|
166
170
|
: { borderRadius: "10px", boxShadow: dialogShadow };
|
|
167
171
|
return column(
|
|
@@ -173,16 +177,16 @@ class BasicDialog extends Component {
|
|
|
173
177
|
{
|
|
174
178
|
key: "titleBar",
|
|
175
179
|
style: {
|
|
176
|
-
padding:
|
|
177
|
-
...(
|
|
180
|
+
padding: fullScreen ? "6px 16px 6px 6px" : "6px 6px 6px 16px",
|
|
181
|
+
...(fullScreen ? { minHeight: "48px" } : {}),
|
|
178
182
|
background: c.chromeDark, color: c.onChrome, flex: "none", alignItems: "center", gap: "8px", fontWeight: "bold",
|
|
179
183
|
},
|
|
180
184
|
},
|
|
181
|
-
iconButton({ key: "back", icon: "arrow_back", title: "Back", onClick: close }).
|
|
185
|
+
iconButton({ key: "back", icon: "arrow_back", title: "Back", onClick: close }).showIf(fullScreen),
|
|
182
186
|
// text(): a lone string starting lowercase (a file name, say)
|
|
183
187
|
// would be taken for an implicit key.
|
|
184
188
|
filler({ key: "title" }, text({ key: "titleText", text: this.title })),
|
|
185
|
-
iconButton({ key: "close", icon: "close", title: "Close", onClick: close }).
|
|
189
|
+
iconButton({ key: "close", icon: "close", title: "Close", onClick: close }).showIf(!fullScreen),
|
|
186
190
|
),
|
|
187
191
|
// Sized by its content, scrolling once the dialog has a height of its own.
|
|
188
192
|
column({ key: "body", style: { flex: "1 1 auto", minHeight: 0, overflow: "auto" } }, this.dialogChildren),
|
|
@@ -208,7 +212,7 @@ class BasicTextField extends Component {
|
|
|
208
212
|
ensureStyleSheet();
|
|
209
213
|
return htmlLabel(
|
|
210
214
|
{ key: "field", style: { display: "flex", flexDirection: "column", gap: "4px", ...this.style } },
|
|
211
|
-
span({ key: "label", style: { fontSize: "13px", opacity: 0.8 } }, text({ key: "labelText", text: this.label })).
|
|
215
|
+
span({ key: "label", style: { fontSize: "13px", opacity: 0.8 } }, text({ key: "labelText", text: this.label })).showIf(!!this.label),
|
|
212
216
|
row(
|
|
213
217
|
{ key: "inputRow", style: { alignItems: "center", gap: "6px" } },
|
|
214
218
|
htmlInput({
|
|
@@ -222,7 +226,7 @@ class BasicTextField extends Component {
|
|
|
222
226
|
}),
|
|
223
227
|
this.unit ? span({ key: "unit" }, text({ key: "unitText", text: this.unit })) : null,
|
|
224
228
|
),
|
|
225
|
-
div({ key: "error", style: { fontSize: "12px", color: c.error } }, text({ key: "errorText", text: this.error || "" })).
|
|
229
|
+
div({ key: "error", style: { fontSize: "12px", color: c.error } }, text({ key: "errorText", text: this.error || "" })).showIf(!!this.error),
|
|
226
230
|
);
|
|
227
231
|
}
|
|
228
232
|
}
|
package/src/Drawer.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { Component, callback, frozen } from "@liquefy/cascade.component";
|
|
2
|
+
import { div, text, flipAnimationContainer } from "@liquefy/cascade.dom";
|
|
3
|
+
import { iconButton } from "./widgets.js";
|
|
4
|
+
import { modalBackdropColor } from "./Modal.js";
|
|
5
|
+
import { themeColor } from "./colorScheme.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drawers - panels that slide in from the edges of whatever they're placed
|
|
9
|
+
* in, over what's there, and out again. One:
|
|
10
|
+
*
|
|
11
|
+
* drawer({ open: this.filtersOpen, close, side: "right", title: "Filters" }, ...content)
|
|
12
|
+
*
|
|
13
|
+
* or several at once, one per edge - a navigation on the left and an
|
|
14
|
+
* inspector on the right, say, each opened and closed on its own:
|
|
15
|
+
*
|
|
16
|
+
* drawers({ modal: false },
|
|
17
|
+
* drawerPanel({ side: "left", open: this.navigationOpen, close: closeNavigation, title: "Pages" }, ...),
|
|
18
|
+
* drawerPanel({ side: "right", open: this.inspectorOpen, close: closeInspector, title: "Properties" }, ...))
|
|
19
|
+
*
|
|
20
|
+
* They cover their nearest positioned ancestor (`position: absolute`, the
|
|
21
|
+
* whole of it), so that's what they slide over - a page's work area, a
|
|
22
|
+
* card, the whole app. Wherever they're placed, they're best placed last,
|
|
23
|
+
* so they're drawn above what comes before them.
|
|
24
|
+
*
|
|
25
|
+
* drawers({ modal, animate, speed }, ...panels):
|
|
26
|
+
*
|
|
27
|
+
* - modal: a dimmed backdrop behind them while any is open, which closes
|
|
28
|
+
* every open one when clicked - for panels the user deals with before
|
|
29
|
+
* going on. Off by default: what's beside them stays usable, and each is
|
|
30
|
+
* closed by its button.
|
|
31
|
+
* - animate: whether they slide (default true). Off, they're just there,
|
|
32
|
+
* or not. `speed`: how fast, as FlipAnimationContainer's (see
|
|
33
|
+
* cascade.dom).
|
|
34
|
+
*
|
|
35
|
+
* drawerPanel({ open, close, onOpen, side, size, header, title, collapsed, bar, style }, ...content)
|
|
36
|
+
* - one of them. Whether it's open is its owner's (`open`), changed by
|
|
37
|
+
* whoever opens it - and by `close`, which it calls when the user closes
|
|
38
|
+
* it: its chevron button, the backdrop, Escape.
|
|
39
|
+
*
|
|
40
|
+
* - open: whether it's open.
|
|
41
|
+
* - close(): called when the user closes it.
|
|
42
|
+
* - onOpen(): called when the user opens it - from its handle or its bar
|
|
43
|
+
* (below).
|
|
44
|
+
* - side: "left" (the default), "right", "top" or "bottom" - the edge it
|
|
45
|
+
* slides in from. One panel per edge: a second on the same one is left
|
|
46
|
+
* out.
|
|
47
|
+
* - size: its width (from the left or right) or height (from the top or
|
|
48
|
+
* bottom) - pixels, or any CSS length ("80%"). 300 by default. Never
|
|
49
|
+
* more than the room it has.
|
|
50
|
+
* - header: a header with `title` and a chevron button that closes it (the
|
|
51
|
+
* default). With `header: false`, the content is the whole panel - a
|
|
52
|
+
* panel of its own, which closes it itself (calling `close`).
|
|
53
|
+
* - title: the header's title.
|
|
54
|
+
* - collapsed: what's left of it while it's closed.
|
|
55
|
+
* - "nothing" (the default): nothing at all.
|
|
56
|
+
* - "handle": a tab on its inner edge - a chevron that opens it
|
|
57
|
+
* (`onOpen`), and closes it again.
|
|
58
|
+
* - "bar": a thin bar along the edge (drawerBarSize wide) - the panel
|
|
59
|
+
* collapsed. At its start, a chevron that opens it; after that,
|
|
60
|
+
* `bar`. Opening it, the bar grows into the panel: what's in the bar
|
|
61
|
+
* and not in the panel fades out, what's in the panel and not in the
|
|
62
|
+
* bar fades in - and what's in both moves (see below).
|
|
63
|
+
* The bar covers a strip along the edge of what the drawers are over:
|
|
64
|
+
* leave room for it there (drawerBarSize).
|
|
65
|
+
* - bar: what's in the bar, after its chevron - shortcuts to what's in
|
|
66
|
+
* the panel, say: icon buttons, one under the other (one after the
|
|
67
|
+
* other, at the top or bottom). Shown only while it's closed, as the
|
|
68
|
+
* content is only while it's open.
|
|
69
|
+
*
|
|
70
|
+
* Shared: a component the app puts in the bar while it's closed, and in
|
|
71
|
+
* the content while it's open - the same one, keyed - moves from one
|
|
72
|
+
* place to the other: a category's icon, in the bar, flies to its place
|
|
73
|
+
* beside the category's heading in the panel, and back. (It's in one or
|
|
74
|
+
* the other, never both: built where `open` says.) So does the chevron,
|
|
75
|
+
* from the bar to the header. To do that, what's in a panel collapsing
|
|
76
|
+
* to a bar isn't placed as one piece (see below): the container sees -
|
|
77
|
+
* and animates - every element in it, and a change in its structure
|
|
78
|
+
* places the whole of what it covers again.
|
|
79
|
+
* - style: the panel's own (its background, shadow, ...).
|
|
80
|
+
*
|
|
81
|
+
* drawer({ modal, animate, speed, ...panel }, ...content) is drawers() with
|
|
82
|
+
* that one panel.
|
|
83
|
+
*
|
|
84
|
+
* How they move: a FlipAnimationContainer (see cascade.dom), around a
|
|
85
|
+
* sheet per edge - all four always there, shown at their edge when open,
|
|
86
|
+
* just past it when closed. Opening and closing is a sheet moving from one
|
|
87
|
+
* place to the other, which the container animates - a spring, so closing
|
|
88
|
+
* one halfway through opening turns it round smoothly. The backdrop comes
|
|
89
|
+
* and goes, and fades as it does.
|
|
90
|
+
*
|
|
91
|
+
* A panel moving to another edge isn't carried across: the sheet it leaves
|
|
92
|
+
* closes - looking as it did, but for its content, which fades out as it
|
|
93
|
+
* goes - and the one it comes to opens with it, if it's open. The content
|
|
94
|
+
* is the same - the components the app gave it, their state kept - shown
|
|
95
|
+
* in another sheet.
|
|
96
|
+
*
|
|
97
|
+
* Closed, a panel is still there - built, its state kept (a scroll
|
|
98
|
+
* position, a half-filled field), but inert: nothing in it can be clicked
|
|
99
|
+
* or focused, and it's hidden from assistive technology.
|
|
100
|
+
*
|
|
101
|
+
* What's in a panel is animated by nothing: it's placed as one piece (an
|
|
102
|
+
* island - see cascade.dom's DOMPlacingContainer), rendered as it would be
|
|
103
|
+
* anywhere else - so changes in there are its own business, as efficient
|
|
104
|
+
* as anywhere else. It fills the panel's body, and scrolls when it's
|
|
105
|
+
* larger. Except in a panel collapsing to a bar: that doesn't slide, it
|
|
106
|
+
* grows out of its bar, and what's in it is placed element by element, so
|
|
107
|
+
* what it shares with the bar can move (see `collapsed`). A panel like
|
|
108
|
+
* that moved to another edge while open has what's in it fly across.
|
|
109
|
+
*/
|
|
110
|
+
export function drawers(...parameters) {
|
|
111
|
+
return new Drawers(...parameters);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function drawerPanel(...parameters) {
|
|
115
|
+
return new DrawerPanel(...parameters);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function drawer(...parameters) {
|
|
119
|
+
return new Drawer(...parameters);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Per side: which of top/right/bottom/left it's placed by, whether its
|
|
123
|
+
// size is a width, and its chevrons - "close" pointing at its edge, "open"
|
|
124
|
+
// away from it. In the order their sheets are drawn: a later one over an
|
|
125
|
+
// earlier one, where they overlap.
|
|
126
|
+
const sides = {
|
|
127
|
+
left: { edge: "left", horizontal: true, close: "chevron_left", open: "chevron_right" },
|
|
128
|
+
right: { edge: "right", horizontal: true, close: "chevron_right", open: "chevron_left" },
|
|
129
|
+
top: { edge: "top", horizontal: false, close: "expand_less", open: "expand_more" },
|
|
130
|
+
bottom: { edge: "bottom", horizontal: false, close: "expand_more", open: "expand_less" },
|
|
131
|
+
};
|
|
132
|
+
const sideNames = Object.keys(sides);
|
|
133
|
+
|
|
134
|
+
const coverAll = { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", boxSizing: "border-box" };
|
|
135
|
+
|
|
136
|
+
// The panel's shadow - only while it's open: closed, it lies just past the
|
|
137
|
+
// edge, and its shadow would show along it.
|
|
138
|
+
const drawerShadow = "0 0 16px rgba(0, 0, 0, 0.25)";
|
|
139
|
+
|
|
140
|
+
// The handle's size: how far it sticks out, and how long it is.
|
|
141
|
+
const HANDLE_DEPTH = 28;
|
|
142
|
+
const HANDLE_LENGTH = 48;
|
|
143
|
+
|
|
144
|
+
// How thick a bar is (see `collapsed`) - the room to leave for it.
|
|
145
|
+
export const drawerBarSize = 48;
|
|
146
|
+
|
|
147
|
+
const collapsedModes = ["nothing", "handle", "bar"];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One panel of a drawers() - what it shows, and how. Built by the app,
|
|
151
|
+
* shown by the drawers() it's given to: it's never rendered itself.
|
|
152
|
+
*/
|
|
153
|
+
export class DrawerPanel extends Component {
|
|
154
|
+
setProperties({ open, close, onOpen, side, size, header, title, collapsed, bar, style, children }) {
|
|
155
|
+
this.open = !!open;
|
|
156
|
+
this.close = close || null;
|
|
157
|
+
this.onOpen = onOpen || null;
|
|
158
|
+
this.side = sides[side] ? side : "left";
|
|
159
|
+
this.size = typeof(size) === "number" ? size + "px" : (size || "300px");
|
|
160
|
+
this.header = header !== false;
|
|
161
|
+
this.title = title || "";
|
|
162
|
+
this.collapsed = collapsedModes.includes(collapsed) ? collapsed : "nothing";
|
|
163
|
+
this.bar = bar || null;
|
|
164
|
+
this.style = frozen(style || null);
|
|
165
|
+
this.panelChildren = frozen(children || []);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
build() {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class Drawers extends Component {
|
|
174
|
+
setProperties({ modal, animate, speed, children }) {
|
|
175
|
+
this.modal = !!modal;
|
|
176
|
+
this.animate = animate !== false;
|
|
177
|
+
this.speed = typeof(speed) === "number" ? speed : null;
|
|
178
|
+
this.panels = frozen(children || []);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// How each edge's panel looked last - its sheet still does, once the
|
|
182
|
+
// panel has gone elsewhere, while it closes (see sheetLook()).
|
|
183
|
+
initialUnobservables() {
|
|
184
|
+
return { looks: {} };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The panel at each edge, now - the first one there.
|
|
188
|
+
panelsBySide() {
|
|
189
|
+
const result = {};
|
|
190
|
+
for (const panel of this.panels) {
|
|
191
|
+
if (!(panel instanceof DrawerPanel)) {
|
|
192
|
+
throw new Error("drawers() takes drawerPanel()s only.");
|
|
193
|
+
}
|
|
194
|
+
if (result[panel.side]) {
|
|
195
|
+
console.warn("drawers(): two panels on the " + panel.side + " edge - the second is left out.");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
result[panel.side] = panel;
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
build() {
|
|
204
|
+
const panels = this.panelsBySide();
|
|
205
|
+
const anyOpen = sideNames.some((side) => panels[side] && panels[side].open);
|
|
206
|
+
// Each edge's: its sheet's handle and its bar both use it - named once.
|
|
207
|
+
const toggles = {};
|
|
208
|
+
for (const side of sideNames) toggles[side] = this.toggleCallback(side);
|
|
209
|
+
// Every open one - as they are when it's clicked.
|
|
210
|
+
const closeAll = callback("closeAll", () => {
|
|
211
|
+
const now = this.panelsBySide();
|
|
212
|
+
for (const side of sideNames) {
|
|
213
|
+
const panel = now[side];
|
|
214
|
+
if (panel && panel.open && panel.close) panel.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return flipAnimationContainer(
|
|
219
|
+
{
|
|
220
|
+
enabled: this.animate,
|
|
221
|
+
speed: this.speed,
|
|
222
|
+
// The backdrop fades in where it lies, below the panels - never
|
|
223
|
+
// lifted above them.
|
|
224
|
+
confine: true,
|
|
225
|
+
// What's in a panel is the app's, placed as one piece - an island
|
|
226
|
+
// - but for one collapsing to a bar (see buildSheet()).
|
|
227
|
+
isUnit: isDrawerContent,
|
|
228
|
+
style: { ...coverAll, overflow: "hidden", pointerEvents: "none" },
|
|
229
|
+
},
|
|
230
|
+
// Below the sheets: a bar is usable over it.
|
|
231
|
+
div({
|
|
232
|
+
key: "backdrop",
|
|
233
|
+
onclick: closeAll,
|
|
234
|
+
style: { ...coverAll, pointerEvents: "auto", background: modalBackdropColor },
|
|
235
|
+
}).showIf(this.modal && anyOpen),
|
|
236
|
+
sideNames.map((side) => this.buildSheet(side, panels[side] || null, toggles[side])),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Opens the panel at `side` if it's closed, closes it if it's open.
|
|
241
|
+
toggleCallback(side) {
|
|
242
|
+
return callback(side + "Toggle", () => {
|
|
243
|
+
const now = this.panelsBySide()[side];
|
|
244
|
+
if (now) this.call(side, now.open ? "close" : "onOpen");
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// How an edge's sheet looks: as its panel says - or, with none there
|
|
249
|
+
// now, as the last one did (it's closing, or closed), so a panel moving
|
|
250
|
+
// elsewhere leaves a sheet that closes as it was.
|
|
251
|
+
sheetLook(side, panel) {
|
|
252
|
+
const u = this.unobservable;
|
|
253
|
+
if (panel) {
|
|
254
|
+
u.looks[side] = { size: panel.size, header: panel.header, title: panel.title, style: panel.style };
|
|
255
|
+
}
|
|
256
|
+
return u.looks[side] || { size: "300px", header: false, title: "", style: null };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Calls the panel at `side` - as it is when called - has, if it has it.
|
|
260
|
+
call(side, name) {
|
|
261
|
+
const panel = this.panelsBySide()[side];
|
|
262
|
+
if (panel && panel[name]) panel[name]();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// An edge's sheet: what moves - and in it, the panel and its handle.
|
|
266
|
+
// Keyed by its edge, as everything in it: each is the same element,
|
|
267
|
+
// whatever else comes and goes.
|
|
268
|
+
//
|
|
269
|
+
// Most panels slide: at the edge when open, just past it when closed.
|
|
270
|
+
// One collapsing to a bar doesn't: it's always at the edge, and grows
|
|
271
|
+
// from the bar into the panel - showing, closed, the bar (a chevron, and
|
|
272
|
+
// the app's `bar`), and open, the panel (its header - with that same
|
|
273
|
+
// chevron - and its content). Nothing in it is an island, so the
|
|
274
|
+
// container sees every element in it: what the app moves from the bar
|
|
275
|
+
// into the content (the same component - keyed) flies to its new place,
|
|
276
|
+
// and what's only in one fades in or out.
|
|
277
|
+
buildSheet(side, panel, toggle) {
|
|
278
|
+
const geometry = sides[side];
|
|
279
|
+
const look = this.sheetLook(side, panel);
|
|
280
|
+
const open = !!(panel && panel.open);
|
|
281
|
+
const collapsed = panel ? panel.collapsed : "nothing";
|
|
282
|
+
const handle = collapsed === "handle";
|
|
283
|
+
const morphing = collapsed === "bar";
|
|
284
|
+
const showingBar = morphing && !open;
|
|
285
|
+
// Usable: open, or showing its bar.
|
|
286
|
+
const usable = open || showingBar;
|
|
287
|
+
|
|
288
|
+
// Along its whole edge, as deep as its size - or its bar - and, sliding,
|
|
289
|
+
// just past the edge when closed: by its handle's depth more, when it
|
|
290
|
+
// has none - then nothing at all shows of it.
|
|
291
|
+
const depth = showingBar ? drawerBarSize + "px" : look.size;
|
|
292
|
+
const across = geometry.horizontal
|
|
293
|
+
? { top: 0, height: "100%", width: depth, maxWidth: "100%" }
|
|
294
|
+
: { left: 0, width: "100%", height: depth, maxHeight: "100%" };
|
|
295
|
+
const past = handle ? "0px" : HANDLE_DEPTH + "px";
|
|
296
|
+
const placed = {
|
|
297
|
+
position: "absolute", boxSizing: "border-box", pointerEvents: "none", ...across,
|
|
298
|
+
[geometry.edge]: open || morphing ? "0px" : `calc(0px - ${look.size} - ${past})`,
|
|
299
|
+
// A stacking context of its own: its handle goes under its panel.
|
|
300
|
+
zIndex: 0,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Opens and closes it: in the header when open, at the start of the
|
|
304
|
+
// bar when collapsed to it - the same button, moving between them. (A
|
|
305
|
+
// sliding panel's is only ever seen open.)
|
|
306
|
+
const chevronOpen = open || !morphing;
|
|
307
|
+
const chevron = iconButton({
|
|
308
|
+
key: side + "Chevron",
|
|
309
|
+
icon: chevronOpen ? geometry.close : geometry.open,
|
|
310
|
+
title: chevronOpen ? "Close" : "Open",
|
|
311
|
+
onClick: toggle,
|
|
312
|
+
style: { flex: "none" },
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const inner = { left: "Right", right: "Left", top: "Bottom", bottom: "Top" }[side];
|
|
316
|
+
const sheetPanel = div(
|
|
317
|
+
{
|
|
318
|
+
inert: !usable,
|
|
319
|
+
"aria-hidden": usable ? undefined : "true",
|
|
320
|
+
onkeydown: callback(side + "Keydown", (event) => {
|
|
321
|
+
if (event.key !== "Escape" || !open) return;
|
|
322
|
+
event.stopPropagation();
|
|
323
|
+
this.call(side, "close");
|
|
324
|
+
}),
|
|
325
|
+
style: {
|
|
326
|
+
...coverAll, display: "flex", overflow: "hidden",
|
|
327
|
+
background: themeColor.surface, color: themeColor.text,
|
|
328
|
+
boxShadow: open ? drawerShadow : "none", transition: "box-shadow 0.3s",
|
|
329
|
+
pointerEvents: usable ? "auto" : "none",
|
|
330
|
+
...(showingBar
|
|
331
|
+
// The bar: the chevron and the app's shortcuts, one under the
|
|
332
|
+
// other - or, along the top or the bottom, one after the other.
|
|
333
|
+
? {
|
|
334
|
+
flexDirection: geometry.horizontal ? "column" : "row", alignItems: "center", gap: "4px",
|
|
335
|
+
padding: geometry.horizontal ? "6px 0" : "0 6px", ["border" + inner]: "1px solid " + themeColor.border,
|
|
336
|
+
}
|
|
337
|
+
: { flexDirection: "column" }),
|
|
338
|
+
...look.style,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
showingBar ? chevron : null,
|
|
342
|
+
showingBar && panel.bar ? this.buildBarItems(side, panel) : null,
|
|
343
|
+
showingBar ? null : this.buildHeader(side, look, chevron).showIf(look.header),
|
|
344
|
+
showingBar ? null : this.buildBody(side, panel, morphing),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return div(
|
|
348
|
+
{ key: side + "Sheet", style: placed },
|
|
349
|
+
sheetPanel,
|
|
350
|
+
this.buildHandle(side, open, toggle).showIf(handle),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// A title, and the chevron at the end nearest the edge (the start, for
|
|
355
|
+
// one on the right), pointing there.
|
|
356
|
+
buildHeader(side, look, chevron) {
|
|
357
|
+
const title = div({ style: { flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, text(look.title));
|
|
358
|
+
return div(
|
|
359
|
+
{
|
|
360
|
+
key: side + "Header",
|
|
361
|
+
style: {
|
|
362
|
+
display: "flex", alignItems: "center", gap: "8px", flex: "none", minHeight: "48px", boxSizing: "border-box",
|
|
363
|
+
padding: side === "right" ? "6px 16px 6px 6px" : "6px 6px 6px 16px",
|
|
364
|
+
background: themeColor.chromeDark, color: themeColor.onChrome, fontWeight: "bold",
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
...(side === "right" ? [chevron, title] : [title, chevron]),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The panel's content. A grid of one cell: what's in it fills the body -
|
|
372
|
+
// its own height, 100%, is the body's - and the body scrolls when it's
|
|
373
|
+
// larger.
|
|
374
|
+
//
|
|
375
|
+
// Placed as one piece - an island, this edge's own: a panel coming here
|
|
376
|
+
// from another edge is shown in a new one, appearing in this sheet, not
|
|
377
|
+
// carried across. But morphing from and into a bar, it's placed element
|
|
378
|
+
// by element, so what it shares with the bar moves between them.
|
|
379
|
+
buildBody(side, panel, morphing) {
|
|
380
|
+
const children = panel ? panel.panelChildren : [];
|
|
381
|
+
return div(
|
|
382
|
+
{ key: side + "Body", style: { flex: "1 1 auto", minHeight: 0, overflow: "auto", display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gridTemplateRows: "1fr" } },
|
|
383
|
+
morphing
|
|
384
|
+
? div({ key: side + "Morphing" }, children)
|
|
385
|
+
: new DrawerContent({ key: side + "Content", children }).showIf(!!panel),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The app's shortcuts in the bar, after a line under the chevron.
|
|
390
|
+
buildBarItems(side, panel) {
|
|
391
|
+
const horizontal = sides[side].horizontal;
|
|
392
|
+
return [
|
|
393
|
+
div({
|
|
394
|
+
key: side + "BarLine",
|
|
395
|
+
style: { flex: "none", background: themeColor.border, ...(horizontal ? { width: "60%", height: "1px", margin: "2px 0" } : { height: "60%", width: "1px", margin: "0 2px" }) },
|
|
396
|
+
}),
|
|
397
|
+
div(
|
|
398
|
+
{ key: side + "BarItems", style: { display: "flex", flexDirection: horizontal ? "column" : "row", alignItems: "center", gap: "4px" } },
|
|
399
|
+
[].concat(panel.bar),
|
|
400
|
+
),
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// A tab sticking out of the panel's inner edge, at its middle - not
|
|
405
|
+
// transformed into place: the container animating it owns its transform.
|
|
406
|
+
buildHandle(side, open, toggle) {
|
|
407
|
+
const geometry = sides[side];
|
|
408
|
+
const middle = `calc(50% - ${HANDLE_LENGTH / 2}px)`;
|
|
409
|
+
const corners = { left: "0 8px 8px 0", right: "8px 0 0 8px", top: "0 0 8px 8px", bottom: "8px 8px 0 0" }[side];
|
|
410
|
+
const placement = geometry.horizontal
|
|
411
|
+
? { top: middle, width: HANDLE_DEPTH + "px", height: HANDLE_LENGTH + "px", [side]: "100%" }
|
|
412
|
+
: { left: middle, height: HANDLE_DEPTH + "px", width: HANDLE_LENGTH + "px", [side]: "100%" };
|
|
413
|
+
return div(
|
|
414
|
+
{
|
|
415
|
+
key: side + "Handle",
|
|
416
|
+
style: {
|
|
417
|
+
position: "absolute", ...placement, boxSizing: "border-box", display: "flex", alignItems: "center", justifyContent: "center",
|
|
418
|
+
borderRadius: corners, background: themeColor.surface, color: themeColor.text, boxShadow: drawerShadow, pointerEvents: "auto",
|
|
419
|
+
// Its shadow under the panel's: it's part of it.
|
|
420
|
+
zIndex: -1,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
iconButton({
|
|
424
|
+
icon: open ? geometry.close : geometry.open,
|
|
425
|
+
title: open ? "Close" : "Open",
|
|
426
|
+
onClick: toggle,
|
|
427
|
+
style: { flex: "none", width: "100%", height: "100%", padding: 0, minWidth: 0 },
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// drawers() with one panel - see the module doc.
|
|
434
|
+
export class Drawer extends Component {
|
|
435
|
+
setProperties({ modal, animate, speed, children, ...panel }) {
|
|
436
|
+
this.modal = !!modal;
|
|
437
|
+
this.animate = animate !== false;
|
|
438
|
+
this.speed = typeof(speed) === "number" ? speed : null;
|
|
439
|
+
this.panel = frozen(panel);
|
|
440
|
+
this.drawerChildren = frozen(children || []);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
build() {
|
|
444
|
+
return drawers(
|
|
445
|
+
{ modal: this.modal, animate: this.animate, speed: this.speed },
|
|
446
|
+
drawerPanel({ ...this.panel, children: this.drawerChildren }),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The same function every build - a new one would count as a change.
|
|
452
|
+
const isDrawerContent = (component) => component instanceof DrawerContent;
|
|
453
|
+
|
|
454
|
+
// What's in a drawer's panel - placed as one piece (see Drawers.build()).
|
|
455
|
+
class DrawerContent extends Component {
|
|
456
|
+
setProperties({ children }) {
|
|
457
|
+
this.contentChildren = frozen(children || []);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
build() {
|
|
461
|
+
return this.contentChildren;
|
|
462
|
+
}
|
|
463
|
+
}
|