Skip to content

echox-js/echox

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

59 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EchoX: UI = f(reactive, template)

The fast, 3KB JavaScript framework for "echoing" reactive UI in functional style.

  • Fast - No Compiling, but Fine-tune Reactivity and No Virtual DOM Diff
  • Small - Zero Dependencies, 3KB (gzip)
  • Simple - 16 APIs, 1 Hour Learning
  • Productive - Structural Code, but Nicely Reusable Logic and Flexible Organization of Concerns
  • Pragmatic - No Transpiling, but Readable Template and Fully TS Support

Note

The current next branch is implementing the new proposal API for production use. Please refer to the main branch for the current release.

Getting Started

EchoX is typically installed via a package manager such as Yarn or NPM.

$ npm install echox

EchoX can then imported as a namespace:

import * as EchoX from "echox";

const {html} = EchoX;

const Counter = EchoX.component(
  EchoX.reactive()
    .let("value", 0)
    .let("increment", (d) => () => d.value++)
    .let("decrement", (d) => () => d.value--),
  html.div()(
    html.button({onclick: (d) => d.increment})("đź‘Ť"),
    html.button({onclick: (d) => d.decrement})("đź‘Ž"),
    html.span()((d) => d.value),
  ),
);

EchoX.mount(document.body, Counter());

EchoX is also available as a UMD bundle for legacy browsers.

<script src="https://cdn.jsdelivr.net/npm/echox"></script>
<script>
  const {html} = EchoX;
</script>

Please reading the following core concepts to learn more:

UI Describing

EchoX uses a dynamic object html implemented with Proxy to describing UI:

import {html} from "echox";

html.div({class: "title"})(
  html.span({style: "color:blue"})("Hello"),
  html.span()("World"),
  html.input({value: "EchoX"}),
);

# EchoX.html.<tag>([props])([...children])

html.<tag> returns the tag function to create the specified tag template (not the real DOM). Calling the tag function to create the tag template with the specified props (if any) and returns a function to specify the children (if any) of the tag template. A child noe can be a tag template or a string.

Component Mounting

A component is a piece of UI (user interface) that has its own logic and appearance which can be defined by EchoX.component:

const HelloWorld = EchoX.component(html.h1()("hello World"));

A component can be rendered and mount to the specified container by EchoX.mount:

EchoX.mount(document.body, HelloWorld());

Then it can be removed and disposes allocated resources by EchoX.unmount:

EchoX.unmount(document.body);

# EchoX.component([reactive,] template)

Returns a component with the specified reactive scope and template. If only one argument is specified, returns a component only with template.

# EchoX.mount(container, template)

Mounts the specified template into the specified container.

# EchoX.unmount(container)

Unmounts the the specified container with the mounted template.

Reactive Defining

// Define primitive states.
const Counter = EchoX.component(
  EchoX.reactive().let("value", 0),
  html.button({onclick: (d) => () => d.value++})((d) => d.value),
);
// Define non-primitive states.
const Person = EchoX.component(
  EchoX.reactive().let("person", () => ({name: "Jack", age: 24})),
  html.div(
    html.span()((d) => d.person.name),
    html.span()((d) => d.person.age),
  ),
);
// Define computed states.
const Message = EchoX.component(
  EchoX.reactive()
    .let("message", "hello world")
    .let("reversed", (d) => d.message.split("").reverse().join("")),
  html.div()(
    html.input({
      oninput: (d) => (e) => (d.message = e.target.value),
      value: (d) => d.message,
    }),
    html.span()((d) => d.reversed),
  ),
);
// Define methods.
const Counter = EchoX.component(
  EchoX.reactive()
    .let("value", 0)
    .let("increment", (d) => () => d.value++),
  html.button({onclick: (d) => d.increment})((d) => d.value),
);
// Define props.
const Red = EchoX.component(
  EchoX.reactive().get("text"),
  html.span({style: "color:red"})((d) => d.text),
);
// Define effects.
const Timer = EchoX.component(
  EchoX.reactive()
    .let("date", new Date())
    .call((d) => {
      const timer = setInterval(() => (d.date = new Date()), 1000);
      return () => clearInterval(timer);
    }),
  EchoX.span((d) => d.date.toLocaleString()),
);

Style Bindings

Class and style are just like other properties:

html.span({
  class: (d) => (d.random > 0.5 ? "red" : null),
  style: (d) => (d.random > 0.5 ? `background: ${d.color}` : null),
})("hello");

But EchoX.cx and EchoX.css make it easier to style conditionally. With them, now say:

html.span({
  class: (d) => EchoX.cx({red: d.random > 0.5}),
  style: (d) => EchoX.css(d.random > 0.5 && {background: d.color})
})("hello");

Multiple class objects and style objects can be specified and only truthy strings will be applied:

// class: 'a b d'
html.span({
  class: EchoX.cx(null, "a", undefined, new Date(), {b: true}, {c: false, d: true, e: new Date()}),
});

// style: background: blue
html.span({
  style: EchoX.css({background: "red"}, {background: "blue"}, false && {background: "yellow"}),
});

# EchoX.cx(...classObjects)

Returns a string joined by all the attribute names defined in the specified classObjects with truthy string values.

# EchoX.css(...styleObjects)

Returns a string joined by all the attributes names defined in the merged specified styleObjects with truthy string values.

Event Handling

In tag function, you provide a function value for property starting with on. This is a convenient way to specify event handlers. The specified function takes the reactive scope as the parameter and returns the event handler:

const Input = EchoX.component(
  EchoX.reactive().let("text", "hello"),
  html.input({oninput: (d) => (e) => (d.text = e.target.value)}),
);

You can also define a method variable and bind it to an event handler.

const Counter = EchoX.component(
  EchoX.reactive()
    .let("value", 0)
    .let("onclick", (d) => () => d.value++),
  html.button({onclick: (d) => d.onclick})((d) => d.value),
);

Control Flow

Control Flow is the component to control the logic flow in the template, such as conditional or list rendering.

Conditional Rendering

The most basic control flow is EchoX.<Match>, which is for conditional rendering. To handle boolean expression (a && b), wraps the component by <Match> component with the test attribute specified. If the specified test function returns a truthy value, the wrapped component will be rendered, otherwise nothing.

// Boolean expression.
EchoX.Match({test: (d) => d.value > 0.5})(
  html.span()("Hello World"),
);

To handle ternaries (a ? b : c) expression, wraps two components by <Match> component with the test attribute specified. If the specified test function returns a truthy value, the first component will be rendered, otherwise the second.

// Match with two arms.
EchoX.Match({test: (d) => d.value > 0.5})(
  html.span()("Yes"),
  html.span()("No")
);

To deal with conditionals with more than 2 mutual exclusive outcomes, wraps EchoX.<Arm> by EchoX.<Match>. Each <Arm> component should be specified the test attribute. If the test attribute is not specified, it defaults to () => true. The children of the first <Arm> component whose test function returns a truthy value will be rendered.

// Match with multiple arms.
EchoX.Match()(
  EchoX.Arm({test: (d) => d.type === "A"})(html.span("apple")),
  EchoX.Arm({test: (d) => d.type === "B"})(html.span("banana")),
  EchoX.Arm()(html.span("unknown")),
);

It is also possible to define switch-like match:

// Switch-like match.
EchoX.Match({value: (d) => d.type})(
  EchoX.Arm({test: "A"})(html.span("apple")),
  EchoX.Arm({test: "B"})(html.span("banana")),
  EchoX.Arm()(html.span("unknown")),
);

# EchoX.<Match>

The control flow for list rendering. With the test attribute being specified, renders the first child if the test function returns a truthy value, otherwise the second child. With the test attribute not being specified, renders the children of the first <Arm> component with the truthy test attribute.

# EchoX.<Arm>

The control flow for defining one outcome for Match control flow. If the test attribute is not specified, it defaults to () => true. The children of it will be rendered if it is the first <Arm> component with the truthy test attribute for its parent <Match> component.

List Rendering

// Render a list.
const List = EchoX.component(
  EchoX.reactive().let("list", () => [1, 2, 3]),
  html.ul()(
    EchoX.For({of: (d) => d.list})(
      html.li()((d, item) => item.index + ": " + item.val)
    )
  ),
);
// Reactive updating.
const List = EchoX.component(
  EchoX.reactive().let("list", () => [1, 2, 3]),
  html.div(
    html.button({onclick: (d) => () => (d.list[0] = 4)}),
    html.ul()(
      EchoX.For({of: (d) => d.list})(
        html.li()((d, item) => item.index + ": " + item.val)
      )
    ),
  ),
);
// Reactive appending.
const List = EchoX.component(
  EchoX.reactive().let("list", () => [1, 2, 3]),
  html.div(
    html.button({onclick: (d) => () => (d.list.push(4))}),
    html.ul()(
      EchoX.For({of: (d) => d.list})(
        html.li()((d, item) => item.index + ": " + item.val)
      )
    ),
  ),
);
// Reactive removing.
const List = EchoX.component(
  EchoX.reactive().let("list", () => [1, 2, 3]),
  html.div(
    html.button({onclick: (d) => () => (d.list.splice(1, 1))}),
    html.ul()(
      EchoX.For({of: (d) => d.list})(
        html.li()((d, item) => item.index + ": " + item.val)
      )
    ),
  ),
);
// Reactive reversing.
const List = EchoX.component(
  EchoX.reactive().let("list", () => [1, 2, 3]),
  html.div(
    html.button({onclick: (d) => () => (d.list.reverse())}),
    html.ul()(
      EchoX.For({of: (d) => d.list})(
        html.li()((d, item) => item.index + ": " + item.val)
      )
    ),
  ),
);
// Reactive filtering.
const List = EchoX.component(
  EchoX.reactive()
    .let("list", () => [1, 2, 3])
    .let("filtered", (d) => list.filter((val) => val % 2)),
  html.div(
    html.button({onclick: (d) => () => (d.list[0] = 4)}),
    html.ul()(
      EchoX.For({of: (d) => d.filtered})(
        html.li()((d, item) => item.index + ": " + item.val)
      )
    ),
  ),
);

# EchoX.<For>

The control flow for render a list of data.

Fragment wrapping

EchoX.<Fragment> is the control flow to group a list of children without adding extra nodes to DOM.

EchoX.component(
  EchoX.Fragment()(
    html.h1()("Hello, World!"),
    html.p()("This is a test.")
  )
);

# EchoX.<Fragment>

The control flow for grouping a list of children without adding extra nodes to DOM.

Slot Forwarding

EchoX.<Slot> is the control flow to pass a template fragment to a child component, and let the child component render the fragment within its own template.

<Slot> is a slot outlet that renders the template specified by the from property. For example, to render the child component from the parent:

// Slots from children.
const Div = EchoX.component(
  html.div()(
    EchoX.Slot({from: (d) => d.children})
  )
);

const App = EchoX.component(
  Div()(
    html.h1()("Hello, World!"),
    Div()(
      html.p()("This is a test.")
    )
  )
);

The children of <Slot> will be rendered if the from property is a falsy value, which is considered as the fallback content:

// Slots with fallback content.
const Div = EchoX.component(
  html.div()(
    EchoX.Slot({from: (d) => d.children})(
      html.h1()("Hello, World!")
    )
  )
);

const App = EchoX.component(Div());

It also possible to define some named slot to render template props other than children:

// Named slots.
const Layout = EchoX.component(
  EchoX.reactive().get("header").get("body").get("footer"),
  html.div()(
    html.div()(EchoX.Slot({from: (d) => d.header})),
    html.div()(EchoX.Slot({from: (d) => d.body})),
    html.div()(EchoX.Slot({from: (d) => d.footer})),
  ),
);

const App = EchoX.component(
  Layout({
    header: html.h1()("Header"),
    body: html.p()("Body"),
    footer: html.h2()("Footer"),
  }),
);

# EchoX.<Slot>

The control flow for passing a template fragment to a child component, and let the child component render the fragment specified by from property within its own template. If the from property is a falsy value, the children of <Slot> will be rendered.

Ref Bindings

Ref is particularly common used to manipulate the DOM. First declare a variable with reactive.let, then a pass a binding function as the ref attribute to the DOM you want to manipulate. Then binding function returns a setter which take the DOM element as the parameter.

// Manipulate a DOM element.
EchoX.component(
  EchoX.reactive()
    .let("divRef", null)
    .call((d) => d.divRef && (d.divRef.textContent = "hello world")),
  html.div({ref: (d) => (el) => (d.divRef = el)}),
);

Instead of manipulating a DOM element, you can expose a custom handle from a component. To do this, you'd need to assign the handle to the ref props for the component. And then pass a binding function as ref attribute to the component.

// Exposes imperative handle from component.
const MyInput = EchoX.component(
  EchoX.reactive()
    .let("inputRef", null)
    .call((d) => {
      d.ref = {
        focus: () => d.inputRef.current.focus(),
        scrollIntoView: () => d.inputRef.current.scrollIntoView(),
      };
    }),
  html.input({ref: (d) => (el) => (d.inputRef = el)}),
);

const Form = EchoX.component(
  EchoX.reactive()
    .let("inputRef", null)
    .let("handleClick", (d) => () => d.inputRef.focus()),
  MyInput({ref: (d) => (handle) => (d.inputRef = handle)}),
);

Stateful Reusing

A stateful reactive scope is reusable by binding to a existing reactive scope with the specified namespace. Make sure to call reactive.join to create a new reactive scope with the specified props:

const mouse = EchoX.reactive()
  .get("x0", 0)
  .get("y0", 0)
  .let("x", (d) => d.x0 ?? 0)
  .let("y", (d) => d.y0 ?? 0)
  .call((d) => {
    const mousemove = (e) => ((d.x = e.clientX), (d.y = e.clientY));
    document.addEventListener("mousemove", mousemove);
    return () => document.removeEventListener("mousemove", mousemove);
  });

const App = EchoX.component(
  EchoX.reactive()
    .let("x0", 100)
    .let("y0", 100)
    .let("mouse", (d) => mouse.join({x0: d.x0, y0: d.y0})),
  html.div()((d) => `${d.mouse.x}, ${d.mouse.y}`),
);

# reactive.join([props])

Instantiates a reactive scope with the specified props.

Context Sharing

A single instance stateful reactive scope can be shared by binding to a existing reactive scope with the specified namespace.

// context.js
export function createContext() {
  let context;
  return () => {
    if (context) return context;
    context = EchoX.reactive()
      .let("value", 0)
      .let("increment", (d) => () => d.value++)
      .join();
  };
}
// counter.js
import {createContext} from "./context.js";

const Counter = EchoX.component(
  EchoX.reactive()
    .let("value", 0)
    .let("counter", () => createContext()),
  html.button({
    onclick: (d) => d.counter.increment,
  })((d) => d.counter.value),
);

API Index

About

The fast, 3KB JavaScript framework for "echoing" reactive UI.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published