@liquefy/cascade.ui 1.0.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/LICENSE +15 -0
- package/README.md +73 -0
- package/package.json +46 -0
- package/src/BasicTheme.js +390 -0
- package/src/Layout.js +120 -0
- package/src/Overlay.js +85 -0
- package/src/OverlayFrame.js +137 -0
- package/src/Popover.js +158 -0
- package/src/Portal.js +132 -0
- package/src/colorScheme.js +201 -0
- package/src/index.js +17 -0
- package/src/widgets.js +109 -0
package/src/Overlay.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Component, withoutRecording } from "@liquefy/cascade.component";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overlay - the other half of overlay.js's port (see OverlayFrame.js).
|
|
5
|
+
* Builds nothing at its own position; its whole job is to hand its
|
|
6
|
+
* single child to the nearest enclosing OverlayFrame (found via
|
|
7
|
+
* `this.inherit("overlayFrame")`) whenever `this.showing` is true, and
|
|
8
|
+
* take it back whenever it's false (or this component stops being shown
|
|
9
|
+
* altogether - see onHide()).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately named `showing`, not flow's own `isVisible` - flow's
|
|
12
|
+
* `isVisible` is a much more general, base-Component concept (computed
|
|
13
|
+
* from the render tree itself - whether a component is actually being
|
|
14
|
+
* rendered at all at its own position right now, propagated down from
|
|
15
|
+
* the root via renderParent - see flow.core's own reactiveRender()) that
|
|
16
|
+
* cascade's direct-render model has no equivalent for yet. `showing` here
|
|
17
|
+
* is Overlay's own, narrower concept: set this to true/false directly
|
|
18
|
+
* (or via the properties bag - `overlay(content, {showing: true})`) to
|
|
19
|
+
* control it, not flow's generic `.show(value)` helper (which just
|
|
20
|
+
* conditionally includes a component in a build() result at all -
|
|
21
|
+
* ported separately, unchanged, onto Component itself).
|
|
22
|
+
*/
|
|
23
|
+
export function overlay(...parameters) {
|
|
24
|
+
return new Overlay(...parameters);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Overlay extends Component {
|
|
28
|
+
setProperties({ children, showing }) {
|
|
29
|
+
if (children && children.length > 1) {
|
|
30
|
+
throw new Error("Overlay accepts at most a single child.");
|
|
31
|
+
}
|
|
32
|
+
this.overlayChild = children && children.length > 0 ? children[0] : null;
|
|
33
|
+
this.showing = !!showing;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
initialUnobservables() {
|
|
37
|
+
return { visibleOnFrame: null, shownChild: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build-only (like PortalContents - see Portal.js): a build only runs
|
|
41
|
+
// while something is showing this component, so building is where the
|
|
42
|
+
// content is handed to the frame - again whenever showing changes.
|
|
43
|
+
// Builds nothing where it stands.
|
|
44
|
+
build() {
|
|
45
|
+
this.update();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Shown again (see Component.onShow()): an up-to-date build doesn't
|
|
50
|
+
// rerun, so hand the content over here. Read without recording - this
|
|
51
|
+
// runs inside whoever is rendering.
|
|
52
|
+
onShow() {
|
|
53
|
+
withoutRecording(() => this.update());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// No longer shown - its page switched away from, say: take the content
|
|
57
|
+
// back, whatever `showing` says.
|
|
58
|
+
onHide() {
|
|
59
|
+
const u = this.unobservable;
|
|
60
|
+
if (u.visibleOnFrame) {
|
|
61
|
+
u.visibleOnFrame.hideOverlay(this);
|
|
62
|
+
u.visibleOnFrame = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
update() {
|
|
67
|
+
const u = this.unobservable;
|
|
68
|
+
if (this.showing) {
|
|
69
|
+
const overlayFrame = this.inherit("overlayFrame");
|
|
70
|
+
if (overlayFrame && u.visibleOnFrame !== overlayFrame) {
|
|
71
|
+
if (u.visibleOnFrame) u.visibleOnFrame.hideOverlay(this);
|
|
72
|
+
u.visibleOnFrame = overlayFrame;
|
|
73
|
+
u.shownChild = this.overlayChild;
|
|
74
|
+
overlayFrame.showOverlay(this, this.overlayChild);
|
|
75
|
+
} else if (overlayFrame && u.shownChild !== this.overlayChild) {
|
|
76
|
+
// Shown already, with other content: a modal window becoming full
|
|
77
|
+
// screen, say - hand the frame the new content.
|
|
78
|
+
u.shownChild = this.overlayChild;
|
|
79
|
+
overlayFrame.showOverlay(this, this.overlayChild);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
this.onHide();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Component, flush, frozen } from "@liquefy/cascade.component";
|
|
2
|
+
import { div } from "@liquefy/cascade.dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OverlayFrame - ported from flow.ui/basic/src/overlay.js's own
|
|
6
|
+
* OverlayFrame/Overlay pair, adapted to cascade's direct-render model
|
|
7
|
+
* (no separate build-then-mount pass, so no `ensure()`/creators-stack
|
|
8
|
+
* dance is needed to keep a modal's content correctly parented - see
|
|
9
|
+
* the comment on `overlayContent.overlayFrame` below for the one place
|
|
10
|
+
* that actually mattered and turned out to already be free here).
|
|
11
|
+
*
|
|
12
|
+
* Any component nested arbitrarily deep underneath an OverlayFrame -
|
|
13
|
+
* through any mix of build() and hardcoded child references - can find
|
|
14
|
+
* *this* one specifically via `this.inherit("overlayFrame")` (see
|
|
15
|
+
* Component.inherit()): every OverlayFrame provides itself
|
|
16
|
+
* (`this.overlayFrame = this`), so the walk always resolves to the
|
|
17
|
+
* *nearest* one, never skipping past it to an outer frame. That's what
|
|
18
|
+
* makes recursive modality work: a dialog opened from inside another
|
|
19
|
+
* modal's own content finds *that* modal's own frame, not the page's
|
|
20
|
+
* top-level one, so a third modal opened from inside it stacks correctly
|
|
21
|
+
* on top of the second, not the first.
|
|
22
|
+
*
|
|
23
|
+
* A frame's own real children are its static content plus, when
|
|
24
|
+
* something is currently showing a modal on it, exactly one nested
|
|
25
|
+
* `modalSubFrame` - itself just another OverlayFrame, absolutely
|
|
26
|
+
* positioned to cover this frame's own area (`pointerEvents: none` so it
|
|
27
|
+
* doesn't block clicks through the parts of itself nothing is actually
|
|
28
|
+
* using) - which is what lets an Overlay shown *inside* that sub-frame's
|
|
29
|
+
* own content recurse the exact same way, arbitrarily deep.
|
|
30
|
+
*/
|
|
31
|
+
export function overlayFrame(...parameters) {
|
|
32
|
+
return new OverlayFrame(...parameters);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class OverlayFrame extends Component {
|
|
36
|
+
setProperties({ style, children, overlayContent }) {
|
|
37
|
+
this.style = frozen(style || null);
|
|
38
|
+
this.staticContent = frozen(children || []);
|
|
39
|
+
// The alternative to showOverlay()/hideOverlay() below: an overlay
|
|
40
|
+
// frame can be handed its modal content directly as a property
|
|
41
|
+
// instead, for a frame that's dedicated to one specific modal rather
|
|
42
|
+
// than a general, shared one any Overlay can find via inherit().
|
|
43
|
+
this.receivedOverlayContent = overlayContent || null;
|
|
44
|
+
// Provides itself for inherit("overlayFrame") - see Component.provide().
|
|
45
|
+
this.overlayFrame = this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// What some Overlay has currently assigned to this frame is *state* (see
|
|
49
|
+
// cascade.component/README.md): established as nothing, then changed
|
|
50
|
+
// only through showOverlay()/hideOverlay() below - and, crucially, never
|
|
51
|
+
// reset by a rebuild, even though this frame is reconstructed (and
|
|
52
|
+
// reconciled by key) every time whoever builds it reruns. Declaring it
|
|
53
|
+
// here is also what positions it at the baseline (time 0, writer null),
|
|
54
|
+
// which is exactly where setState() in show/hideOverlay writes it back
|
|
55
|
+
// to - the same writing, reused, not a fresh one spliced in ahead of a
|
|
56
|
+
// reader that could never actually reach it (see their comment).
|
|
57
|
+
initializeState() {
|
|
58
|
+
return { assignedOverlayContent: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
initialUnobservables() {
|
|
62
|
+
return { assigningContentProvider: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Called by an Overlay component (via inherit("overlayFrame")) when it
|
|
66
|
+
// becomes visible - see Overlay.render(). `contentProvider` is the
|
|
67
|
+
// Overlay itself, kept so a later hideOverlay() call from a *different*
|
|
68
|
+
// Overlay (e.g. one that's since taken over) can't clear content it
|
|
69
|
+
// never actually assigned.
|
|
70
|
+
//
|
|
71
|
+
// Called from inside an Overlay's own render() - which is to say, from
|
|
72
|
+
// *later* in this same pipeline than OverlayFrame's own build() (a
|
|
73
|
+
// descendant calling back up to an ancestor). A plain write here could
|
|
74
|
+
// never actually reach OverlayFrame's own already-registered dependency
|
|
75
|
+
// on `assignedOverlayContent`: "time as tree position" means a
|
|
76
|
+
// later-positioned write can never overtake an earlier-positioned
|
|
77
|
+
// reader, by construction (see cascade.reactive's own
|
|
78
|
+
// migrateOvertakenObserversFor) - this is exactly the motivating case
|
|
79
|
+
// flush()/accessInitialValues() were built for (a modal whose frame
|
|
80
|
+
// already rendered needing new content added back into it, in the same
|
|
81
|
+
// frame, without flicker - see docs/plan-flagged-scheduling.md).
|
|
82
|
+
// setState() (Component.js) makes the write land at the baseline
|
|
83
|
+
// position (before everything), reusing the exact writing
|
|
84
|
+
// initializeState() above set up rather than splicing a new one in
|
|
85
|
+
// ahead of it that could never reach OverlayFrame's own reader either
|
|
86
|
+
// way - it's also the only way a state property *may* be written from
|
|
87
|
+
// inside a repeater at all - and flush() makes sure the resulting
|
|
88
|
+
// rebuild happens within this same wave rather than waiting for the
|
|
89
|
+
// next one.
|
|
90
|
+
showOverlay(contentProvider, overlayContent) {
|
|
91
|
+
this.unobservable.assigningContentProvider = contentProvider;
|
|
92
|
+
flush(() => this.setState({ assignedOverlayContent: overlayContent }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hideOverlay(contentProvider) {
|
|
96
|
+
if (this.unobservable.assigningContentProvider === contentProvider) {
|
|
97
|
+
flush(() => this.setState({ assignedOverlayContent: null }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Replace this frame's own static content in place - used when the
|
|
102
|
+
// *same* modalSubFrame is reused for new overlay content (see build()
|
|
103
|
+
// below) rather than tearing the sub-frame down and recreating it,
|
|
104
|
+
// which would also retract and rebuild everything recursively nested
|
|
105
|
+
// inside it for no reason.
|
|
106
|
+
setStaticContent(staticContent) {
|
|
107
|
+
this.staticContent = frozen(staticContent instanceof Array ? staticContent : [staticContent]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
build() {
|
|
111
|
+
if (this.assignedOverlayContent && this.receivedOverlayContent) {
|
|
112
|
+
throw new Error("Cannot both assign overlay content via showOverlay() and set it as a property on the same overlay frame.");
|
|
113
|
+
}
|
|
114
|
+
const overlayContent = this.assignedOverlayContent || this.receivedOverlayContent;
|
|
115
|
+
|
|
116
|
+
// const u = this.unobservable;
|
|
117
|
+
|
|
118
|
+
const children = [...this.staticContent];
|
|
119
|
+
|
|
120
|
+
if (overlayContent) {
|
|
121
|
+
children.push(new OverlayFrame(overlayContent, {
|
|
122
|
+
key: "modalSubFrame",
|
|
123
|
+
style: {
|
|
124
|
+
position: "absolute", top: 0, left: 0, width: "100%", height: "100%",
|
|
125
|
+
pointerEvents: "none",
|
|
126
|
+
},
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A stable key - this frame's own real element (and everything
|
|
131
|
+
// reconciled underneath it) must survive across rebuilds triggered
|
|
132
|
+
// by a modal opening/closing, not be torn down and recreated every
|
|
133
|
+
// single time (see the constructor's own docs on why a key is what
|
|
134
|
+
// makes build()-composed reconciliation possible at all).
|
|
135
|
+
return div({ class: "overlay-frame", key: "frame", style: { position: "relative", ...this.style }, children });
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/Popover.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Component, frozen, callback } from "@liquefy/cascade.component";
|
|
2
|
+
import { div } from "@liquefy/cascade.dom";
|
|
3
|
+
import { overlay } from "./Overlay.js";
|
|
4
|
+
import { zStack, wrapper, fitContainerStyle, zStackElementStyle } from "./Layout.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Popover - ported from flow.ui/basic/src/popover.js: content shown next to
|
|
8
|
+
* something on the page (an information button, say), over everything
|
|
9
|
+
* else, closed by clicking anywhere outside it.
|
|
10
|
+
*
|
|
11
|
+
* - anchor: what it points to - an element (typically what was clicked:
|
|
12
|
+
* `event.currentTarget`), or a rectangle in viewport coordinates. An
|
|
13
|
+
* element it follows: while it's shown, a resize of the window, or a
|
|
14
|
+
* scroll, places it again, where the element now is - watched in the
|
|
15
|
+
* frames after, for as long as it keeps moving, so whatever lays out
|
|
16
|
+
* again on the same resize has done so, however late (a toolbar moving
|
|
17
|
+
* the button it was opened from, say). A rectangle stays put.
|
|
18
|
+
* - showing, close: whether it's shown, and what a click outside it calls.
|
|
19
|
+
* - style, children: the popover's own content (usually a card or an
|
|
20
|
+
* alert), and extra style for the box it's placed in.
|
|
21
|
+
*
|
|
22
|
+
* Flow measured the popover's own size to place it; here it's placed by
|
|
23
|
+
* which quarter of the window its anchor is in instead - below the anchor
|
|
24
|
+
* when that's in the upper half, above it otherwise, and aligned to the
|
|
25
|
+
* anchor's left or right edge, whichever is further from the window's
|
|
26
|
+
* edge - so it never needs measuring, and so never needs a second pass.
|
|
27
|
+
* Shown through overlay() - the nearest OverlayFrame shows it.
|
|
28
|
+
*/
|
|
29
|
+
export function popover(...parameters) {
|
|
30
|
+
return new Popover(...parameters);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GAP = 6;
|
|
34
|
+
|
|
35
|
+
export class Popover extends Component {
|
|
36
|
+
setProperties({ anchor, showing, close, style, children }) {
|
|
37
|
+
this.anchor = frozen(anchor || null);
|
|
38
|
+
this.showing = !!showing;
|
|
39
|
+
this.close = close || null;
|
|
40
|
+
this.style = frozen(style || null);
|
|
41
|
+
this.popoverChildren = frozen(children || []);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `moved`: counts the frames after a resize or a scroll, while it's
|
|
45
|
+
// shown and anchored to an element - so it's built, and placed, again
|
|
46
|
+
// (see follow()).
|
|
47
|
+
initializeState() {
|
|
48
|
+
return { moved: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
initialUnobservables() {
|
|
52
|
+
return { following: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Where the anchor is now - or, for an element no longer in the page (a
|
|
56
|
+
// theme switch replacing the button, say), where it last was.
|
|
57
|
+
anchorRect() {
|
|
58
|
+
const anchor = this.anchor;
|
|
59
|
+
if (!anchor) return { left: 0, top: 0, right: 0, bottom: 0 };
|
|
60
|
+
if (typeof(anchor.getBoundingClientRect) !== "function") return anchor;
|
|
61
|
+
const u = this.unobservable;
|
|
62
|
+
if (anchor.isConnected || !u.lastAnchorRect) u.lastAnchorRect = anchor.getBoundingClientRect();
|
|
63
|
+
return u.lastAnchorRect;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Following an element: while shown, on a resize or a scroll, place again
|
|
67
|
+
// - in the frames after, whenever the element has moved.
|
|
68
|
+
follow() {
|
|
69
|
+
const u = this.unobservable;
|
|
70
|
+
if (u.following) return;
|
|
71
|
+
const view = document.defaultView;
|
|
72
|
+
const later = view.requestAnimationFrame ? (action) => view.requestAnimationFrame(action) : (action) => setTimeout(action, 0);
|
|
73
|
+
// Watched frame by frame, until it has held still for a few: what moves
|
|
74
|
+
// it may lay out again later than the next frame - a bounds provider's
|
|
75
|
+
// ResizeObserver, say, which runs after animation frames.
|
|
76
|
+
const sameRect = (a, b) => a && b && a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
|
|
77
|
+
let watching = false;
|
|
78
|
+
let stillFrames = 0;
|
|
79
|
+
const watch = () => {
|
|
80
|
+
if (!u.following) { watching = false; return; }
|
|
81
|
+
const anchor = this.anchor;
|
|
82
|
+
const rect = anchor && anchor.isConnected && typeof(anchor.getBoundingClientRect) === "function" ? anchor.getBoundingClientRect() : null;
|
|
83
|
+
// Placed again if the element has moved - or the window has changed
|
|
84
|
+
// size: a placement from its right or bottom edge moves along with it.
|
|
85
|
+
const viewportChanged = u.placedViewport !== view.innerWidth + "x" + view.innerHeight;
|
|
86
|
+
if ((rect && !sameRect(rect, u.lastAnchorRect)) || viewportChanged) {
|
|
87
|
+
stillFrames = 0;
|
|
88
|
+
this.moved = this.moved + 1;
|
|
89
|
+
} else stillFrames++;
|
|
90
|
+
if (stillFrames < 3) later(watch);
|
|
91
|
+
else watching = false;
|
|
92
|
+
};
|
|
93
|
+
const moved = () => {
|
|
94
|
+
stillFrames = 0;
|
|
95
|
+
if (watching) return;
|
|
96
|
+
watching = true;
|
|
97
|
+
later(watch);
|
|
98
|
+
};
|
|
99
|
+
view.addEventListener("resize", moved);
|
|
100
|
+
document.addEventListener("scroll", moved, true);
|
|
101
|
+
u.following = () => {
|
|
102
|
+
view.removeEventListener("resize", moved);
|
|
103
|
+
document.removeEventListener("scroll", moved, true);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
unfollow() {
|
|
108
|
+
const u = this.unobservable;
|
|
109
|
+
if (!u.following) return;
|
|
110
|
+
u.following();
|
|
111
|
+
u.following = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onHide() {
|
|
115
|
+
this.unfollow();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onDispose() {
|
|
119
|
+
super.onDispose();
|
|
120
|
+
this.unfollow();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
placement() {
|
|
124
|
+
const anchor = this.anchorRect();
|
|
125
|
+
const view = document.defaultView;
|
|
126
|
+
const width = view ? view.innerWidth : 1024;
|
|
127
|
+
const height = view ? view.innerHeight : 768;
|
|
128
|
+
this.unobservable.placedViewport = width + "x" + height;
|
|
129
|
+
const placement = {};
|
|
130
|
+
if ((anchor.top + anchor.bottom) / 2 < height / 2) placement.top = anchor.bottom + GAP + "px";
|
|
131
|
+
else placement.bottom = height - anchor.top + GAP + "px";
|
|
132
|
+
if ((anchor.left + anchor.right) / 2 < width / 2) placement.left = anchor.left + "px";
|
|
133
|
+
else placement.right = width - anchor.right + "px";
|
|
134
|
+
return placement;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
build() {
|
|
138
|
+
this.moved; // Placed again when its anchor element has moved - see follow().
|
|
139
|
+
const followsElement = this.anchor !== null && typeof(this.anchor.getBoundingClientRect) === "function";
|
|
140
|
+
if (this.showing && followsElement) this.follow();
|
|
141
|
+
else this.unfollow();
|
|
142
|
+
return overlay(
|
|
143
|
+
{ key: "overlay", showing: this.showing },
|
|
144
|
+
zStack(
|
|
145
|
+
{ key: "layer", style: { ...fitContainerStyle, pointerEvents: "none" } },
|
|
146
|
+
div({
|
|
147
|
+
key: "outside",
|
|
148
|
+
onmousedown: callback("outside", () => this.close && this.close()),
|
|
149
|
+
style: { ...zStackElementStyle, pointerEvents: "auto" },
|
|
150
|
+
}),
|
|
151
|
+
wrapper(
|
|
152
|
+
{ key: "content", style: { position: "fixed", pointerEvents: "auto", maxWidth: "min(800px, 90vw)", ...this.placement(), ...this.style } },
|
|
153
|
+
this.popoverChildren,
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/Portal.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Component, flush, withoutRecording, frozen } from "@liquefy/cascade.component";
|
|
2
|
+
import { wrapper } from "./Layout.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Portals - ported from flow.ui/basic/src/Portals.js: a place in the tree
|
|
6
|
+
* (portal()) whose content is supplied from somewhere else entirely
|
|
7
|
+
* (portalContents()) - a page putting its own buttons in the app's top
|
|
8
|
+
* bar, say, which the page knows nothing else about.
|
|
9
|
+
*
|
|
10
|
+
* - portal({ style, children }): a div, showing whatever is currently
|
|
11
|
+
* assigned to it - or its own children (a default) when nothing is, or
|
|
12
|
+
* nothing but an empty list (an empty cart).
|
|
13
|
+
* - portalContents({ portal, children }): builds nothing where it
|
|
14
|
+
* stands; while it's shown, its children are shown in `portal` - a
|
|
15
|
+
* portal, or the name of one to inherit() ("topBarPortal"), found when
|
|
16
|
+
* it builds, so it can be created before it has a place in the tree
|
|
17
|
+
* instead, and once it isn't (it's hidden, or its page is switched
|
|
18
|
+
* away from), they're taken back out - see Component.onShow()/onHide().
|
|
19
|
+
* The last one shown wins.
|
|
20
|
+
*
|
|
21
|
+
* The same mechanism as OverlayFrame/Overlay (see OverlayFrame.js - an
|
|
22
|
+
* overlay frame is a portal with a modal layer): the contents are
|
|
23
|
+
* typically rendered *after* the portal (a page's work area comes after
|
|
24
|
+
* the top bar), so assigning them is a write backwards in the pipeline -
|
|
25
|
+
* setState() puts it at the baseline, where the portal's build reads it,
|
|
26
|
+
* and flush() gets it rebuilt in the same wave, so the portal fills in the
|
|
27
|
+
* same frame, not the next.
|
|
28
|
+
*
|
|
29
|
+
* Who holds the portal: something that creates it itself and owns it (not
|
|
30
|
+
* one built in some build() and also referenced - see
|
|
31
|
+
* cascade.component/README.md), typically providing it for inherit() too,
|
|
32
|
+
* so that whatever is rendered below can find it without being handed it.
|
|
33
|
+
*/
|
|
34
|
+
export function portal(...parameters) {
|
|
35
|
+
return new Portal(...parameters);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function portalContents(...parameters) {
|
|
39
|
+
return new PortalContents(...parameters);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class Portal extends Component {
|
|
43
|
+
setProperties({ style, children }) {
|
|
44
|
+
this.style = frozen(style || null);
|
|
45
|
+
this.defaultContents = frozen(children || []);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// What some PortalContents has assigned is state: changed only by
|
|
49
|
+
// show()/hide(), never reset by a rebuild.
|
|
50
|
+
initializeState() {
|
|
51
|
+
return { contents: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
initialUnobservables() {
|
|
55
|
+
return { assignedBy: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Called from a PortalContents render: what is shown now is read
|
|
59
|
+
// without recording, so that render never depends on it - two contents
|
|
60
|
+
// taking turns would otherwise keep rerunning each other.
|
|
61
|
+
show(contentsProvider, contents) {
|
|
62
|
+
this.unobservable.assignedBy = contentsProvider;
|
|
63
|
+
const current = withoutRecording(() => this.contents);
|
|
64
|
+
if (current !== contents) flush(() => this.setState({ contents }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Only the PortalContents that assigned what's shown can take it back -
|
|
68
|
+
// one that has since been taken over by another doesn't clear its
|
|
69
|
+
// successor's.
|
|
70
|
+
hide(contentsProvider) {
|
|
71
|
+
const u = this.unobservable;
|
|
72
|
+
if (u.assignedBy !== contentsProvider) return;
|
|
73
|
+
u.assignedBy = null;
|
|
74
|
+
flush(() => this.setState({ contents: null }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
build() {
|
|
78
|
+
const assigned = this.contents && this.contents.length > 0;
|
|
79
|
+
return wrapper({ key: "portal", style: this.style || {} }, assigned ? this.contents : this.defaultContents);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class PortalContents extends Component {
|
|
84
|
+
setProperties({ portal, children }) {
|
|
85
|
+
this.portal = portal || null;
|
|
86
|
+
this.portalChildren = frozen(children || []);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
initialUnobservables() {
|
|
90
|
+
return { shownIn: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build-only - so it's also placed like any other component by one that
|
|
94
|
+
// places its subtree itself (cascade.DOM's FlipAnimationContainer), where
|
|
95
|
+
// it expands to nothing. A build only runs while something is showing
|
|
96
|
+
// this component (rendering or expanding it pulls it), so building is
|
|
97
|
+
// where the contents are assigned - again whenever they change.
|
|
98
|
+
build() {
|
|
99
|
+
this.assign();
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Shown again (see Component.onShow()): an up-to-date build doesn't
|
|
104
|
+
// rerun, so assign the contents here. Read without recording - this runs
|
|
105
|
+
// inside whoever is rendering.
|
|
106
|
+
onShow() {
|
|
107
|
+
withoutRecording(() => this.assign());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No longer shown (hidden, its page switched away from, dropped): take
|
|
111
|
+
// the contents back.
|
|
112
|
+
onHide() {
|
|
113
|
+
const u = this.unobservable;
|
|
114
|
+
if (u.shownIn) {
|
|
115
|
+
u.shownIn.hide(this);
|
|
116
|
+
u.shownIn = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
assign() {
|
|
121
|
+
const u = this.unobservable;
|
|
122
|
+
const portal = typeof(this.portal) === "string" ? this.inherit(this.portal) : this.portal;
|
|
123
|
+
if (u.shownIn && u.shownIn !== portal) {
|
|
124
|
+
u.shownIn.hide(this);
|
|
125
|
+
u.shownIn = null;
|
|
126
|
+
}
|
|
127
|
+
if (portal) {
|
|
128
|
+
portal.show(this, this.portalChildren);
|
|
129
|
+
u.shownIn = portal;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|