@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/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
+ }