JavaScript のライブラリ
地図、グラフ、リッチテキストエディタは、ブラウザで JavaScript のライブラリとして動きます。カスタム要素で包むと、ページのシグナルから値を受け取り、イベントを型付きの値としてページに返せます。
ライブラリをカスタム要素で包む
ページとカスタム要素は 3 つのメソッドでつながります。Page::script は要素を定義するスクリプトを読み込み、bind_attr は要素の属性をシグナルの値に合わせ、El::on_custom は要素が投げたイベントで文を実行します。イベントの detail は型付きの式として文に渡ります。イベントは、型引数を detail の型にした CustomEvent として 1 回宣言します。
下の評価は、34 行の JavaScript で書いたカスタム要素です。星をクリックすると要素が rate を投げ、文がその detail をページのシグナルに入れ、シグナルが要素の value 属性に戻ります。クリアのボタンはシグナルだけを変え、星の表示がそれに合わせて変わります。
実行結果
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 つ星"),
)),
)
}// 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)),
);