日本語

JavaScript libraries

A map, a chart, or a rich text editor runs in the browser as a JavaScript library. Wrapped in a custom element, it takes values from the page's signals and hands its events back as typed values.

Wrap the library in a custom element

Three methods connect a page to a custom element. Page::script loads the script that defines the element, bind_attr keeps an attribute of the element equal to a signal, and El::on_custom runs a statement on an event the element dispatches, with the event's detail as a typed expression. Declare the event once as a CustomEvent whose type parameter is the type of its detail.

The rating below is a custom element written in 34 lines of JavaScript. A click on a star dispatches rate, the statement puts its detail into the page's signal, and the signal goes back into the element's value attribute. The clear button changes only the signal, and the stars follow.

Result

rating.rs
use hypersig::html::{CustomEvent, div, el, p};
use hypersig::style::pattern::hstack;
use hypersig::style::value::rem;
use hypersig::{Page, Signals, if_else};
use hypersig_ui::{ButtonSize, ButtonVariant, button};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
pub struct Review {
    stars: i32,
}

// `star-rating.js` dispatches it with the number of the star clicked.
const RATE: CustomEvent<i32> = CustomEvent::new("rate");

pub fn page() -> Page<Review> {
    let stars = Review::stars();
    Page::new(
        "Rating",
        Review { stars: 3 },
    )
    .script(site.star_rating())
    .child(
        div()
            .css(hstack(rem(1)))
            .child(
                el("star-rating")
                    .bind_attr("value", stars.to_text())
                    .on_custom(RATE, move |rated| stars.set(rated)),
            )
            .child(
                button(ButtonVariant::Outline, ButtonSize::Sm)
                    .on_click(stars.set(0))
                    .text("Clear"),
            ),
    )
    .child(
        p().bind_text(if_else(
            stars.equals(0),
            "Not rated yet",
            stars.to_text().concat(" of 5 stars"),
        )),
    )
}
star-rating.js
// A star rating written as a plain custom element, for the demo of a script
// on a hypersig page. It shows the `value` attribute as filled stars and
// dispatches `rate` with the number of the star clicked as its `detail`.
class StarRating extends HTMLElement {
  static observedAttributes = ["value"];

  constructor() {
    super();
    const root = this.attachShadow({ mode: "open" });
    root.innerHTML = `<style>
      button { font: inherit; font-size: 1.75rem; line-height: 1; padding: 0 0.1rem;
        border: 0; background: none; cursor: pointer; color: #d4d4d8; }
      button.on { color: #f59e0b; }
    </style>`;
    for (let star = 1; star <= 5; star++) {
      const button = document.createElement("button");
      button.textContent = "★";
      button.setAttribute("aria-label", `${star} of 5`);
      button.addEventListener("click", () => {
        this.dispatchEvent(new CustomEvent("rate", { detail: star }));
      });
      root.append(button);
    }
  }

  attributeChangedCallback() {
    const value = Number(this.getAttribute("value") ?? 0);
    this.shadowRoot.querySelectorAll("button").forEach((button, i) => {
      button.classList.toggle("on", i < value);
    });
  }
}

customElements.define("star-rating", StarRating);

What the compiler checks

The event's name and the type of its detail describe the script, so nothing checks them against it. An event the script never dispatches runs nothing, and a field the detail lacks reads as null. What the page does with the detail is checked: putting it into a signal of another type is a compile error.

A library that draws into an element

A library that is not a custom element, such as Leaflet, draws into the element it is given and adds children, classes, and inline styles to it. A server patch that reaches the element would set it back to what the server rendered. Mark the element with El::foreign, and a patch keeps it as it is while its bindings keep running. Among its siblings a patch finds it by its node ref's name, so an element added before it does not make the map start over.

use hypersig::html::{CustomEvent, div};
use hypersig::{NodeRef, Page, Signals};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
struct Center {
    lat: f64,
    lng: f64,
}

#[derive(Signals, Serialize, Deserialize)]
struct Office {
    center: Center,
}

// The script in `/map.js` draws a map into the element named `MAP`, reads
// `data-lat` and `data-lng`, and dispatches `map-moved` with the new center.
const MAP: NodeRef<Office> = NodeRef::new("map");
const MOVED: CustomEvent<Center> = CustomEvent::new("map-moved");

let center = Office::center();
let page = Page::new("Office", Office { center: Center { lat: 35.68, lng: 139.77 } })
    .script("/map.js")
    .child(
        div()
            // A patch keeps the tiles and classes the script added.
            .foreign(MAP)
            .bind_attr("data-lat", center.at(Center::lat()).to_text())
            .bind_attr("data-lng", center.at(Center::lng()).to_text())
            .on_custom(MOVED, move |moved| center.set(moved)),
    );