要素は、ページの中で操作に応じて表示を変える部品です。sobria は、要素の関数から Custom Element を作成し、サーバーで描画した HTML をブラウザで起動します。
要素を作成する
src/elements/ にファイルを配置し、関数を default export します。この関数が要素になります。
// src/elements/stepper.tsx
import { signal } from "sobria";
type Props = {
label: string;
step?: number;
};
export default function Stepper(props: Props) {
const [value, setValue] = signal(0);
return (
<button onClick={() => setValue(value() + (props.step ?? 1))}>
{props.label}: {value()}
</button>
);
}要素を使用するときは、ファイルを import して、コンポーネントとして JSX に記述します。ページの中でも、別の要素の中でも、記述の方法は同じです。
// src/routes/page.tsx
import Stepper from "../elements/stepper.tsx";
export default function Page() {
return <Stepper label="数" step={2} />;
}要素の関数は、要素が起動したときに 1 回だけ実行します。表示を更新する方法は リアクティビティ で説明します。
props
要素の関数は、1 番目の引数で props を受け取ります。引数には Props の型を注釈します。注釈には、同じファイルで宣言した型も、別のファイルから import した型も指定できます。sobria は、この型から、どの props を属性で受け取るかを決定します。
props は props.label のように読みます。読み取りは追跡されるので、props が変化すると、その値を使用している箇所が更新されます。関数の引数で props を分割代入すると、ビルドのエラーになります。関数の本体で分割代入した変数は、分割代入した時点の値を保持し、後の変化には追従しません。
属性で受け取る props
型が string、number、boolean の props は、属性でも受け取ります。属性の値は、Props の型に変換します。Props の型ごとの受け取り方と変換の規則は props の型と受け取り方 に、属性の名前の決まり方は props の名前と属性の名前 に記述しています。
props にできない名前
title、id、class、hidden のように、HTML の要素で意味を持つ名前は、props の名前にできません。使用できない名前の一覧は、props にできない名前 に記述しています。
Props の型を Props<T> で包むと、使用できない名前をエディタで型エラーとして確認できます。
// src/elements/card.tsx
import type { Props } from "sobria";
type CardProps = Props<{
heading: string;
header?: JSX.Element;
children?: JSX.Element;
}>;ref、key、load:* は sobria が使用する props なので、要素の Props の型に記述できません(Props の型に記述しなくても受け付ける props)。load:* は、要素を起動するタイミングを指定します(読み込みと値の受け渡し)。
ホストの属性
Props の型に無い属性(id、class、aria-*、data-* など)を記述すると、ホストの要素にそのまま付与されます。例えば、次の id と class は、sobria-stepper の要素に付与されます。
// src/routes/page.tsx
<Stepper label="数" id="stepper" class="wide" />要素の中の JSX では、Props の型に無い onClick のような DOM の標準のイベントの名前に関数を渡すと、ホストのイベントリスナーになります。ホストに記述できる属性とイベントの名前は、Props の型に記述しなくても受け付ける props に記述しています。
サーバーから渡す値
ページから要素に渡した props は、HTML に埋め込んでブラウザに送信します。送信できる値と、送信できずにエラーになる値は、サーバーコンポーネントから渡す値 に記述しています。
イベント
名前が on と大文字で始まり、Props の型で関数として宣言した props は、関数が渡されていなければイベントになります。要素がその props を呼ぶと、ホストが CustomEvent を発行します。
// src/elements/like-button.tsx
import { signal } from "sobria";
type Props = {
count: number;
onLike?: (count: number) => void;
};
export default function LikeButton(props: Props) {
const [count, setCount] = signal(props.count);
const like = () => {
const next = count() + 1;
setCount(next);
props.onLike?.(next);
};
return <button onClick={like}>いいね {count()}</button>;
}onLike のイベント名は like で、イベントの detail は props を呼んだときの最初の引数です。イベント名の決まり方と、JSX で関数を渡したときの動作は、関数の props とイベント に記述しています。
// sobria を使用しないページの JS
document.querySelector("sobria-like-button").addEventListener("like", (e) => {
console.log(e.detail);
});onLike の型は省略可能なので、要素では props.onLike?.(next) のように呼び出します。
children とスロット
要素は、子の内容を props.children で受け取ります。Props の型で JSX.Element 型を宣言した props は、その名前のスロットになります。
// src/elements/card.tsx
import type { Props } from "sobria";
type CardProps = Props<{
heading: string;
header?: JSX.Element;
children?: JSX.Element;
}>;
export default function Card(props: CardProps) {
return (
<section>
<header>{props.header ?? <h2>{props.heading}</h2>}</header>
<div>{props.children}</div>
</section>
);
}{props.children} は名前の無い <slot> に、{props.header} は <slot name="header"> になります。?? の右辺は、内容が渡されなかったときに表示する内容になります。
// src/routes/page.tsx
<Card heading="お知らせ" header={<h1>重要なお知らせ</h1>}>
<p>本文</p>
</Card>渡した内容は、要素の Light DOM に配置されます。名前付きのスロットに渡した要素には slot="header" が付与され、テキストは <span slot="header"> で包まれます。そのため、::slotted(h1) のようなセレクタは、渡した要素に一致します。
スロットには次の制約があります。
- スロットは、要素の関数が返す JSX の子に、
{props.header}か{props.header ?? 内容}の形で直接記述します。{open() && props.children}のように論理演算子の式の中に記述したり、三項演算子の枝に直接記述したり、.mapに渡す関数の中に記述したりすると、ビルドのエラーになります - 1 つのスロットは、JSX に 1 回だけ配置できます。2 回配置すると、ビルドのエラーになります
- 要素は、スロットに渡された要素やテキストを参照できません。スロットの props の値を読んで加工する記述は、ビルドのエラーになります
スロットの表示を切り替えるときは、<div hidden={!open()}>{props.children}</div> のように、スロットを囲むタグの属性を切り替えます。非表示の間も、渡した内容は Light DOM に残ります。
内容を加工したいときは、renderItem のような関数の props で受け取ります。
ref
ネイティブのタグの ref に関数を渡すと、sobria はノードを作成した時点で、そのノードを引数に関数を呼びます。この時点では、ノードはまだ DOM に接続されていません。フォーカスのように接続した後に実行する処理は、ctx.onMount か effect(リアクティビティ)の中で実行します。
ref の関数が関数を返すと、sobria はその関数を、ノードが条件の分岐で消えるときと、要素がページから外れるときに呼びます。外部のライブラリを取り付ける処理と、取り外す処理を 1 か所に記述できます。
// src/elements/size-label.tsx
import { signal } from "sobria";
export default function SizeLabel() {
const [width, setWidth] = signal(0);
return (
<div
ref={(el) => {
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
observer.observe(el);
return () => observer.disconnect();
}}
>
{width()}px
</div>
);
}ref は、要素の中の JSX でだけ使用できます。サーバーで描画するときは、ref の関数を呼びません。
メソッドを公開する
ctx.expose に渡したオブジェクトのプロパティは、要素のインスタンスのプロパティになります。開く、再生する、フォーカスするといった操作を、メソッドとして公開します。データは props で受け渡します。
// src/elements/dialog.tsx
import { signal, type Ctx } from "sobria";
type Props = { heading: string; children?: JSX.Element };
type Methods = { open(): void; close(): void };
export default function Dialog(props: Props, { expose }: Ctx<Methods>) {
const [isOpen, setOpen] = signal(false);
expose({ open: () => setOpen(true), close: () => setOpen(false) });
return (
<div hidden={!isOpen()}>
<h2>{props.heading}</h2>
{props.children}
<button onClick={() => setOpen(false)}>閉じる</button>
</div>
);
}公開するメソッドの型は Ctx<Methods> で宣言します。
別の要素から操作するときは、要素の ref でインスタンスを受け取ります。インスタンスの型は ElementRef<typeof Dialog> です。要素に渡した ref は、その要素が起動して expose を実行した後に呼ばれます。
// src/elements/settings.tsx
import { signal, type ElementRef } from "sobria";
import Dialog from "./dialog.tsx";
export default function Settings() {
const [dialog, setDialog] = signal<ElementRef<typeof Dialog>>();
return (
<>
<button onClick={() => dialog()?.open()}>設定を開く</button>
<Dialog heading="設定" ref={setDialog}>
<p>設定の内容</p>
</Dialog>
</>
);
}focus のように HTMLElement に既にあるメソッドと同じ名前で公開すると、そのメソッドを置換します。remove、setAttribute、addEventListener のように sobria が使用するメソッドの名前と、同じ要素の props の名前は、公開できません。開発時にこれらの名前を公開すると、エラーになります。
要素の ref で受け取れるのはインスタンスだけで、Shadow DOM の内側のノードは受け取れません。
ctx
要素の関数は、2 番目の引数で ctx を受け取ります。ctx は分割代入するか、ctx.host のようにプロパティを参照して使用します。ctx を別の関数に渡すと、ビルドのエラーになります。
| 名前 | 内容 |
|---|---|
host | ホストの要素 |
onMount | 最初の内容を Shadow DOM に表示した後に呼ぶ関数を登録する |
expose | インスタンスにメソッドとプロパティを追加する |
css | Shadow DOM にスタイルを追加する(スタイル) |
form | 要素をフォームの部品にする(フォーム) |
internals | ホストの ElementInternals(フォーム) |
// src/elements/clock.tsx
import { effect, signal, type Ctx } from "sobria";
export default function Clock(props: { interval?: number }, { host, onMount }: Ctx) {
const [now, setNow] = signal(new Date().toLocaleTimeString());
onMount(() => {
host.dataset.started = "";
});
effect(
() => props.interval ?? 1000,
(ms) => {
const timer = setInterval(() => setNow(new Date().toLocaleTimeString()), ms);
return () => clearInterval(timer);
},
);
return <time>{now()}</time>;
}要素の関数はサーバーでも実行します。サーバーで要素の関数を実行している間に host のプロパティを参照すると、エラーになります。host は onMount か effect の中で使用します。サーバーでは、onMount に登録した関数は呼ばれず、expose は何も実行しません。
補助コンポーネント
src/elements/ の中で、_ で始まるファイルとディレクトリは要素になりません。これらのファイルには、要素の中で使用する普通の関数コンポーネントを記述します。この章では、これを補助コンポーネントと呼びます。補助コンポーネントは Custom Element にならず、使用した要素の Shadow DOM の中に描画されます。
補助コンポーネントに ref を渡すと、ほかの props と同じく関数の引数に渡ります。受け取った ref を内側のタグに付与すれば、そのタグのノードを呼び出し側に渡せます。ref を受け取る補助コンポーネントは、Props の型に ref を宣言します。
// src/elements/_parts/text-field.tsx
export default function TextField(props: { label: string; ref?: (el: HTMLInputElement) => void }) {
return (
<label>
{props.label}
<input ref={props.ref} />
</label>
);
}// src/elements/search.tsx
import type { Ctx } from "sobria";
import TextField from "./_parts/text-field.tsx";
export default function Search(_props: {}, { onMount }: Ctx) {
let input: HTMLInputElement | undefined;
onMount(() => input?.focus());
return <TextField label="検索" ref={(el) => (input = el)} />;
}共通のコンポーネント
ページと要素の両方で使用する、マークアップだけのコンポーネントは、src/elements/ と src/routes/ の外(src/components/ など)に配置します。共通のコンポーネントは、ページの中ではサーバーで、要素の中ではブラウザで描画します。
// src/components/badge.tsx
export default function Badge(props: { label: string; children?: JSX.Element }) {
return (
<span class="badge">
{props.label}
{props.children}
</span>
);
}共通のコンポーネントには、ネイティブのタグと属性、ほかの共通のコンポーネント、children を記述できます。共通のコンポーネントはページの中でも描画され、ページの中では JS が動作しないので、イベントハンドラーと ref は記述できません。共通のコンポーネントから、要素とページのコンポーネントは import できません。共通のコンポーネントの戻り値の型を明示するときは、sobria の SharedNode を使います。
コンテキストと id
createContext で作成したコンテキストを使用すると、親の部品から子の部品に値を渡せます。createContext が返すコンポーネントに value を渡して子を包むと、子孫の useContext はその値を返します。createId は、ページでは描画ごとに、要素ではインスタンスごとに一意な id を返します。
// src/components/field.tsx
import { createContext, createId, useContext } from "sobria";
const FieldIds = createContext({ input: "", error: "" });
export function Field(props: { children?: JSX.Element }) {
return (
<FieldIds value={{ input: createId(), error: createId() }}>
<div class="field">{props.children}</div>
</FieldIds>
);
}
export function FieldLabel(props: { children?: JSX.Element }) {
return <label for={useContext(FieldIds).input}>{props.children}</label>;
}
export function FieldInput(props: { name: string }) {
const ids = useContext(FieldIds);
return <input name={props.name} id={ids.input} aria-describedby={ids.error} />;
}
export function FieldError(props: { children?: JSX.Element }) {
return <p id={useContext(FieldIds).error}>{props.children}</p>;
}- 子孫でない位置の
useContextは、createContextに渡した値を返します - 要素の関数と、要素の Shadow DOM の中の部品では、ページと外側の要素で渡した値を読めません。要素に値を渡すには、props を使います。props に記述した式と、要素の子として記述した内容は、要素を記述した位置の値を読めます
valueは、コンポーネントを描画した時点の値です。変化する値を渡すには、Signal(リアクティビティ)を読む関数をvalueに渡し、子孫はuseContextが返した関数を呼んで値を読みます- ページと要素の描画の外(サーバー関数の handler など)で
useContextとcreateIdを呼ぶと、エラーになります
タグ名
タグ名は、接頭辞と、src/elements/ からのファイルのパスから決定します。例えば、src/elements/forms/DatePicker.tsx のタグ名は sobria-forms-date-picker です。タグ名の決まり方と、ビルドのエラーになるファイルは、タグ名 に記述しています。
接頭辞と、要素を配置するディレクトリは、Vite プラグインの prefix と elementsDir で変更できます。
// vite.config.ts
import { sobria } from "sobria/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sobria({ prefix: "app" })],
});要素は import して使用するので、アプリのコードにタグ名を記述する必要はありません。タグ名は、DevTools で要素を探すときと、HTML や sobria の外のコードに要素を記述するときに使用します(外部からの利用)。