sobria
ページの一覧

要素

要素は、ページの中で操作に応じて表示を変える部品です。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インスタンスにメソッドとプロパティを追加する
cssShadow 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 の外のコードに要素を記述するときに使用します(外部からの利用)。