CascadeIntroductioninfoGetting StartedConvenient UsageAdvanced Usage
Examples
Reactive FormFocus StoreToolbar EllipsisHybrid Modal DialogWord ProcessorHydrationJSXProgrammatic Reactive LayoutRecursive ExampleAnimationDrawerFile SystemThemespalette
Convenient Usage

Convenient Usage

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.

Animation, as a separate concern

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.

Apple
Banana
Cherry
Date
Elderberry

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 })),
    );
  }
}

Portals

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.

No notices - the board is empty.
A component, deep inside.
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),
    );
  }
}

Modals

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.

Deleted 0 times
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),
              ),
            ),
          ),
        ),
      ),
    ];
  }
}