Détail du package

yandel

zzzeros0417ISC1.2.1

A reactive lightweight TS frontend framework

framework, web, web-ui, render

readme

Yandel

Yandel is reactive lightweight typescript library that eases the creation of small to complex user interfaces without the complexity of project setup with 0-deps.

Table of Contents

Installation

Add this package to your npm project:

npm install yandel

Introduction

Yandel is a client frontend library that helps you create web UI.

Tags

The default HTML Tag elements. They let you create different HTML Elements and customize them.

import { div, span, section } from "yandel";

div();
/**
 * <div/>
 */
div({ id: "1234" });
/**
 * <div id="1234"/>
 */
div({ id: "1234", "aria-hidden": "true" }, span());
/**
 * <div id="1234" aria-hidden="true">
 *      <span/>
 * </div>
 */
div(
  { id: "1234", className: "myClass", style: { backgroundColor: "red" } },
  span(),
  section()
);
/**
 * <div id="1234" class="myClass" style="background-color: red;">
 *      <span/>
 *      <section/>
 * </div>
 */

SVG support

HTML SVG elements are also allowed under the svg function and namespace:

import { svg } from "yandel";

svg(
  {
    viewBox: "0 0 100 100",
    width: 100,
    height: 100,
  },
  svg.circle({
    cx: 50,
    cy: 50,
    r: 30,
    fill: "red",
  }),
  svg.path({
    d: "M 10 10 H 90 V 90 H 10 Z",
  })
);
/**
 * <svg viewbox="0 0 100 100" width="100px" height="100px">
 *  <circle cx="50" cy="50" r="30" fill="red"/>
 *  <path d="M 10 10 H 90 V 90 H 10 Z">
 * </svg>
 */

Text

HTML text node.

import { h1, div, p } from "yandel";

h1("I'm the text inside h1");
/**
 * <h1>I'm the text inside h1</h1>
 * */

div(p("I'm the text inside p"), "I'm the text inside div");
/**
 * <div>
 *      <p>I'm the text inside p</p>I'm the text inside div
 * </div>
 * */

Portal

Allows you to mount elements over a different parent, remaining in the same tree.

import { Portal, div, span, h1 } from "yandel";


// Renders a div inside body
Portal(document.body, div());

Portal(document.querySelector("#someid")!, div(), span(), h1(), ...);

Example:

import { Portal, div, span, ValidTemplateReturn } from "yandel";

function Notifications(): ValidTemplateReturn {
  return div(
    Portal(document.body, span("Notification 1"), span("Notification 2")),
    button("Remove notifications")
  );
}

// The span will be rendered inside body instead of the parent div.
// When the parent div is deleted, the portal is deleted as well.

Templates

Templates are functions that return ValidNodeChild. They help you customize and modularize:

import {
  aside,
  div,
  p,
  strong,
  span,
  button,
  ValidTemplateReturn,
} from "yandel";

function TotalPriceButton(
  qTy: number,
  uPrice: number,
  onClick: VoidFunction
): ValidTemplateReturn {
  return div(
    p(strong("Total is:"), span(`${qTy * uPrice} $`)),
    button({ onclick: onClick }, "Buy")
  );
}

aside(TotalPriceButton(4, 2, () => buy()));
/**
 * <aside>
 *  <div>
 *      <p><strong>Total is:</strong>8 $</>
 *      <button>Buy</button>
 *  </div>
 * </aside>
 */

Templates can return an array, when no parent is needed:

function NotificationList(notifications: string[]): ValidTemplateReturn {
  return notifications.map((notification) => div(icon(), span(notification)));
}

function AppNotifications() {
  const notifications: string[] = [...];
  return div(...NotificationList(notifications));
}

Future node

Future nodes are templates (functions) used as a child and will be created at render time (as components and text). This applies for all type of functions that return a ValidTemplateReturn; also the generic tags like div, span, etc. are allowed

import { div, main, nav } from "yandel";

function MyGridOfPhotos () {
  return div(...);
}

function App () {
  return main(
    nav(...),
    MyGridOfPhotos // This function will be executed at render time
  )
}

This might be necessary in some kind of situations, like explained in the Contexts section.

In this example:

import { button, div, p } from "yandel";

function BuyButton(onclick: EventListener) {
  return button(
    {
      onclick,
      className: "button",
    },
    "Buy"
  );
}

function TotalPriceButton(price: number) {
  const handleBuy = () => {
    ...
  };
  return div(p(`Total is: ${price}`), BuyButton(handleBuy));
}

The tree will be completely generated when you call TotalPriceButton.

return div(p(`Total is: ${price}`), () => BuyButton(handleBuy));

Now, BuyButton will be created at render time.

Components

Components add the reactivity to the game. They are defined extending the Component class and must provide a render method (a template):

import { Component, div, p, strong, button, ValidTemplateReturn } from "yandel";

class TotalPriceButton extends Component {
  private readonly total: number;
  private readonly onClick: VoidFunction;
  constructor(qTy: number, uPrice: number, onClick: VoidFunction) {
    super();
    this.total = qTy * uPrice;
    this.onClick = onClick;
  }
  public render(): ValidTemplateReturn {
    return div(
      p(strong("Total is:"), ` ${this.total} $`),
      button({ onclick: this.onClick }, "Buy")
    );
  }
}

To use a component, instantiate just like a common class:

section(new TotalPriceButton(4, 2, buyFn));
/**
 * <section>
 *      <div>
 *          <p><strong>Total is:</strong>8 $</>
 *          <button>Buy</button>
 *      </div>
 * </section>
 */

Components can return an array:

public render(): ValidTemplateReturn {
  return [
    p(strong("Total is:"), span(`${this.total} $`)),
    button({ onclick: this.onClick }, "Buy"),
  ];
}

Reactivity

State object type is defined using the Component's generic argument. To set the initial state, defineState must be called in the constructor.

import { Component, div, p, strong, button, ValidTemplateReturn } from "yandel";

class TotalPriceButton extends Component<{
  loading: boolean;
}> {
  private readonly total: number;
  private readonly onClick: VoidFunction;
  constructor(qTy: number, uPrice: number, onClick: VoidFunction) {
    super();
    this.total = qTy * uPrice;
    this.onClick = onClick;
    // `defineState` must be called in the constructor.
    // It should not be called elsewhere.
    this.defineState({
      loading: false,
    });
  }
  public handleClick() {
    // Perform state update
    this.setState({
      loading: true,
    });
    this.onClick();
  }
  public render(): ValidTemplateReturn {
    return div(
      p(strong("Total is:"), ` ${this.total} $`),
      button(
        {
          onclick: this.handleClick.bind(this), // Don't forget to bind if needed
          disabled: this.state.loading,
        },
        "Buy"
      )
    );
  }
}

State can be changed and accessed via this.state, but only this.setState will perform an update:

// This will set the value, but won't perform an update
this.state.loading = true;

// this will set the value and perform an update
this.setState({
  loading: true,
});

This is usefull when you want to change a state value without performing an state update.

setState can receive a function that provides the current value and returns the new value:

this.setState((lastValue) => ({
  ...lastValue,
  loading: true,
}));

Important: Do not overwrite state:

this.state = { loading: false }; // state is a read-only property!

Effects are callbacks that are executed when the component is attached or updated. They are created providing a callback to this.effect and should be called inside the render method. Components can have more than one effect/cleanup and they're executed in the order they are defined (FIFO).

import { Component, p, ValidTemplateReturn } from "yandel";
interface User {
  name: string;
}
class UserData extends Component<{
  user: User | undefined;
}> {
  private userId: string;
  constructor(id: string) {
    super();
    this.userId = id;
    this.defineState({ user: undefined });
  }
  public render(): ValidTemplateReturn {
    this.effect(() => {
      // Executed when the component is mounted
      if (!this.state.user)
        requestUser(this.userId).then((user) => {
          this.setState({
            user,
          });
        });

      if (this.state.user) {
        // If a callback is returned in the effect
        // It will be executed when the component unmounts
        return () => {
          sendUserAnalytics(this.state.user);
        };
      }
    });
    return p(
      this.state.user
        ? `Username is: ${this.state.user.name}`
        : "User is undefined"
    );
  }
}

Do not forget that calling setState inside an effect will perform an update. This example will create a never ending update loop:


public render(): ValidTemplateReturn {
  this.effect(() => {
    this.setState(
      // some state...
    );
  });
  return ...;
}

Add a condition to avoid a loop:


public render(): ValidTemplateReturn {
  this.effect(() => {
    if (!condition)
      this.setState(
        // some state...
      );
  });
  return ...;
}

Important: You must handle calling setState or other actions if the component has been removed.


public render(): ValidTemplateReturn {
  this.effect(() => {
    setTimeout(() => {
      this.setState(
        // some state
      );
    }, 2000)
  });
  return ...;
}

In this example, if the component is removed or updated by other reasons, the timeout will still run and an error will be thrown (Error: Component is deleted). Handle timeouts and more in the cleanups to prevent weird behavior:


public render(): ValidTemplateReturn {
  this.effect(() => {
    this.timeout = setTimeout(() => {
      this.setState(
        // some state
      );
    }, 2000)
    return () => {
      if (this.timeout) clearTimeout(this.timeout);
    }
  });
  return ...;
}

Contexts

Sharing data is easier with contexts. Define your context using createContext:

import { createContext } from "yandel";

const userContext = createContext<{
  user: User | null;
  login(): void;
}>();

Then, inside of your render function, call provide to hydrate your context. Argments are:

  • The component's instance.
  • The value to store in the context.

Contexts that have not been provided are inaccesible. A context can't be provided by more than one component at the same time.


class UserProvider extends Component<{user: User | null}> {
  public render(): ValidTemplateReturn {
    userContext.provide(this, {
      user: this.state.user,
      login: () => ...
    });
    return ...;
  }
}

Once it has been provided, it's accesible everywhere, even for those that are not children (if so, you must be responsible of the context life-time, and when/where is or not aviable). Note: when a component provider changes (for example, a setState update), the context value will be again provided (re-hydrated) by the render function. Don't forget that nodes that are not a child of the component's tree won't be updated:

To consume a context, call consume;

function UserConsumer() {
  const { user } = userContext.consume();
  return ...;
}

class UserConsumer extends Component {
  public render () :ValideTemplateReturn {
    const { user } = userContext.consume();
    return ...;
  }
}

function nonUiFunction () {
   const { user } = userContext.consume();
   ...;
}

You can check if the context is correctly provided:

function UserConsumerWithError() {
  if (!userContext.ok) return p("There was an error");
  const { user } = userContext.consume();
  return ...;
}

Important: calling consume over a non provided context will return undefined. Destructuration will throw an error:

function UserConsumerWithError() {
  const { user } = userContext.consume(); // Error: cannot destructure property as it's undefined!!
  return ...;
}

Future nodes

As explained in the section of future nodes, components, text and future nodes are created at render time. You have to understand how they are being created:

function UserConsumer() {
  const { user } = userContext.consume();
  return ...;
}
class App extends Component {
  public render(): ValidTemplateReturn {
    return [new UserProvider(), UserConsumer()];
  }
}

When a component is created, its render method will be called at render time. In this example, UserConsumer will be called before the UserProvider render method at render time, so the context will be undefined.

public render(): ValidTemplateReturn {
  return [
    new UserProvider(),
    UserConsumer // or () => UserConsumer()
  ];
}

Now, UserConsumer will be able to consume, because It will be created at render time right after the UserProvider render method, so it will be accesible.

Ref

The ref prop allows you to have access to the tag's reference:

import { Component, ElementRef, Stores, input } from "yandel";

class InputWithRef extends Component {
  private readonly inputRef: ElementRef<"input"> = new Stores(undefined);
  public render() {
    this.effect(() => {
      console.log(this.inputRef.stores); // HTMLInputElement
    });
    return input({
      ref: this.inputRef,
    });
  }
}

Key

The key prop allows you to identify elements. Elements with the same key won't be diffed.

// If keys are the same, node diffing won't be done,
// even if they are different tags
class WithKey extends Component {
  public render() {
    return condition
      ? div({
          key: "key1",
        })
      : section({
          key: "key1",
        });
  }
}

Usage

Define your UI: components, templates, nodes... then, use createRoot.render to start rendering and done!

import {
  Component,
  ElementRef,
  Stores,
  ValidTemplateReturn,
  button,
  createRoot,
  div,
  form,
  input,
  p,
} from "yandel";

function Message(msg: string) {
  return p(`The message is: ${msg}`);
}

class MessageInput extends Component<{ message: string }> {
  private readonly inputRef: ElementRef<"input"> = new Stores(undefined);
  constructor() {
    super();
    this.defineState({
      message: "Default message",
    });
  }
  private handleSubmit(v: SubmitEvent) {
    v.preventDefault();
    if (
      this.inputRef.stores &&
      this.inputRef.stores.value !== this.state.message
    ) {
      this.setState({
        message: this.inputRef.stores.value,
      });
    }
  }
  public render(): ValidTemplateReturn {
    this.effect(() => {
      return () => {
        // Reset the input when user
        // sets new message
        if (this.inputRef.stores) this.inputRef.stores.value = "";
      };
    });
    return div(
      form(
        {
          onsubmit: this.handleSubmit.bind(this),
        },
        input({
          ref: this.inputRef,
          type: "text",
        }),
        button(
          {
            type: "submit",
          },
          "Set new message"
        )
      ),
      Message(this.state.message)
    );
  }
}

// Entry point
createRoot(document.body).render(new MessageInput());

// Renders inside of body:
// <div>
//  <form>
//    <input type="text">
//    <button type="submit">Set new message</button>
//  </form>
//  <p>The message is: Default message</p>
// </div>