Picking up where Getting Started left off: things nearly every app wants - animation, content shown somewhere else, modal dialogs - each ready to use in a line or two.
Wrap anything in flipAnimationContainer(), and every change in it animates: elements moving, appearing, leaving and resizing. What's inside is entirely unaware of it - this list is just a list, and the one line around it is all the animation there is.
Note: CSS and transition animations cannot by themselves handle changes in the shape of the DOM tree, which often leads to complicated FLIP animation setups - all of which is automated here.
import { Component, callback } from "@liquefy/cascade.component";
import { div, text, flipAnimationContainer } from "@liquefy/cascade.dom";
import { button, row, column, controlPanel } from "@liquefy/cascade.ui";
const fruit = ["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"];
// A plain list - it knows nothing about animation. Its items move, so
// each has a key: that's what keeps a fruit the same fruit as it moves.
class FruitList extends Component {
setProperties({ items }) {
this.items = items;
}
build() {
return row(
{ style: { gap: "8px", flexWrap: "wrap", overflow: "visible" } },
this.items.map((name) => div(
{ key: name, style: { padding: "6px 12px", borderRadius: "16px", background: "#d6eaf8", color: "#1b4f72" } },
text(name),
)),
);
}
}
// Animated - by the one line around it.
export class ShuffleList extends Component {
initialState() {
return { items: fruit.slice(0, 5) };
}
build() {
const shuffled = () => [...this.items].sort(() => Math.random() - 0.5);
const missing = fruit.filter((name) => !this.items.includes(name));
return column(
{ style: { gap: "12px", overflow: "visible" } },
controlPanel(
button(text("Shuffle"), callback("shuffle", () => { this.items = shuffled(); })),
button({ disabled: missing.length === 0 }, text("Add"), callback("add", () => { this.items = [missing[0], ...this.items]; })),
button({ disabled: this.items.length === 0 }, text("Remove"), callback("remove", () => { this.items = this.items.slice(1); })),
),
flipAnimationContainer(new FruitList({ items: this.items })),
);
}
}
A component can show content somewhere else entirely: portal() is a place for it, portalSource() puts content there, from anywhere - found by name, nothing handed down. This demo's pages put their buttons in the top bar the same way.
What's put through a portal inherits from where it came from - the portal source - not from where it ends up. Give the source inheritFrom: "portal" to turn that around, and have the contents inherit from the portal instead, as if they had been built there.
import { Component, callback } from "@liquefy/cascade.component";
import { div, text, portal, portalSource } from "@liquefy/cascade.dom";
import { button, card, column, themeColor } from "@liquefy/cascade.ui";
// Owns a portal - a place for others to put things in - and provides it by
// name, for anything below to find. Owns it for real: created here, not in
// a build(), so it's this board that establishes it and disposes of it (see
// Component.establish()).
export class NoticeBoard extends Component {
initialUnobservables() {
return {
board: portal(
{ style: { padding: "12px", minHeight: "150px", border: "1px dashed " + themeColor.border, borderRadius: "8px" } },
div({ style: { opacity: 0.6 } }, text("No notices - the board is empty.")),
).establish(),
};
}
onDispose() {
this.unobservable.board.dispose();
super.onDispose();
}
// Found by inherit("noticeBoard") from anywhere below.
provide() {
return { noticeBoard: this.unobservable.board };
}
build() {
return card(
{ style: { display: "flex", flexDirection: "column", gap: "12px" } },
this.unobservable.board,
div({ style: { padding: "12px", border: "1px dashed " + themeColor.border, borderRadius: "8px" } },
div({ style: { padding: "12px", border: "1px dashed " + themeColor.border, borderRadius: "8px" } },
new Poster())),
);
}
}
// Deep inside - and puts its notice on the board, by the board's name.
class Poster extends Component {
initialState() {
return { posted: false };
}
build() {
return column(
{ style: { gap: "8px", alignItems: "flex-start" } },
text("A component, deep inside."),
button(
text(this.posted ? "Take the notice down" : "Put up a notice"),
callback("toggle", () => { this.posted = !this.posted; }),
),
// Keyed: built even while it isn't shown, and kept alive by its key.
portalSource(
{ key: "notice", portal: "noticeBoard" },
div(
{
style: {
width: "140px", minHeight: "140px", padding: "14px", boxSizing: "border-box",
background: "#fff59d", color: "#3e3a1f", borderRadius: "2px",
boxShadow: "2px 4px 8px rgba(0, 0, 0, 0.25)",
},
},
text("A notice - posted from deep inside, shown up here."),
),
).showIf(this.posted),
);
}
}
overlay() shows its content over the whole app - on the overlay frame at the app's root (overlayFrame()) - while it's built right where it belongs, next to the button that opens it. What it shows here is a modalAssembly(): a backdrop, and the dialog centered on it - or, on a phone-sized screen, the dialog full screen.
import { Component, callback } from "@liquefy/cascade.component";
import { p, text } from "@liquefy/cascade.dom";
import { button, dialog, overlay, modalAssembly, row, column } from "@liquefy/cascade.ui";
// A button that asks first - in a modal dialog, over everything else.
// overlay() shows its content on the app's overlay frame (an overlayFrame()
// at the app's root) while `showing` - built right here, next to the
// button, wherever that is in the app. modalAssembly() is the rest: a
// backdrop (a click on it closes), the dialog centered on it - and, on a
// phone-sized screen, the dialog full screen instead.
export class ConfirmDelete extends Component {
initialState() {
return { asking: false, deleted: 0 };
}
build() {
const close = callback("close", () => { this.asking = false; });
const confirm = callback("confirm", () => { this.deleted++; this.asking = false; });
return [
row(
{ style: { alignItems: "center", gap: "12px" } },
button(text("Delete everything"), callback("ask", () => { this.asking = true; })),
text("Deleted " + this.deleted + (this.deleted === 1 ? " time" : " times")),
),
overlay(
{ showing: this.asking },
modalAssembly(
{ close, width: 340, fullScreenBelow: 480 },
dialog(
{ title: "Are you sure?", close },
column(
{ style: { padding: "16px", gap: "16px" } },
p({ style: { margin: 0 } }, text("Everything will be gone - well, in this example.")),
row(
{ style: { gap: "8px", justifyContent: "flex-end" } },
button(text("Cancel"), close),
button({ variant: "filled" }, text("Delete"), confirm),
),
),
),
),
),
];
}
}