English

JavaScript のライブラリ

地図、グラフ、リッチテキストエディタは、ブラウザで JavaScript のライブラリとして動きます。カスタム要素で包むと、ページのシグナルから値を受け取り、イベントを型付きの値としてページに返せます。

ライブラリをカスタム要素で包む

ページとカスタム要素は 3 つのメソッドでつながります。Page::script は要素を定義するスクリプトを読み込み、bind_attr は要素の属性をシグナルの値に合わせ、El::on_custom は要素が投げたイベントで文を実行します。イベントの detail は型付きの式として文に渡ります。イベントは、型引数を detail の型にした CustomEvent として 1 回宣言します。

下の評価は、34 行の JavaScript で書いたカスタム要素です。星をクリックすると要素が rate を投げ、文がその detail をページのシグナルに入れ、シグナルが要素の value 属性に戻ります。クリアのボタンはシグナルだけを変え、星の表示がそれに合わせて変わります。

実行結果

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(
        "評価",
        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("クリア"),
            ),
    )
    .child(
        p().bind_text(if_else(
            stars.equals(0),
            "まだ評価していません",
            stars.to_text().concat(" / 5 つ星"),
        )),
    )
}
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);

コンパイラが検査する範囲

イベント名と detail の型はスクリプトの側の取り決めなので、スクリプトと突き合わせる検査はありません。スクリプトが投げないイベントでは何も実行されず、detail にないフィールドを読むと null になります。検査するのは、ページが detail をどう使うかです。型の違うシグナルに入れると、コンパイルエラーになります。

要素の中に描くライブラリ

Leaflet のようにカスタム要素ではないライブラリは、渡された要素の中に描き、子要素やクラス、インラインスタイルを足します。サーバーのパッチがその要素に届くと、サーバーが描いた形に戻してしまいます。El::foreign を付けると、パッチはその要素をそのまま残し、要素の束縛は動き続けます。兄弟要素の中では node ref の名前でその要素を見つけるので、前に要素が増えても地図は作り直されません。

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