Skip to content

コーディングガイドライン

ranui で_作る_ための手引きです。コンポーネントの契約とは何か、Shadow DOM の境界がどこで慣れたルールを変えてしまうのか、そして踏む前に知っておく価値のある間違いはどれか。

その見た目の側はデザインガイドライン、トークンはデザインシステムにあります。

こんなときに:ranui のコンポーネントをアプリケーションに組み込んでいるとき。import を選ぶ、イベントを結ぶ、セレクターが届かないものにスタイルを当てる、サーバーで描画する、テストを書く、といった場面で。

原則

  1. 要素そのものが API です。 属性、プロパティ、イベント、スロット、::part() が契約のすべてです。外から見えるそれ以外のものは、いずれ動く実装の詳細です。
  2. 状態はちょうどひとつの場所が持つこと。 値を持つのはあなたのアプリで、それを押し込むか、コンポーネントが持っていて変化を知らせてくるか、どちらかです。両方向に写し合うと値がずれていきます。
  3. Shadow DOM の境界を越えるスタイルは、カスタムプロパティ、::part()sheet、スロットで。 普通のセレクターは境界を越えません。詳細度をいくら上げても変わりません。
  4. 使うものだけ import すること。 どのコンポーネントにも専用のエントリーがあります。バレルは便利さであって、必須ではありません。
  5. プラットフォームを優先すること。 これらはカスタム要素です。addEventListenersetAttributehidden はすべて仕様どおりに動き、その上に重ねるフレームワークの抽象は任意です。

エントリーポイント

各エントリーは名前が言うとおりのものだけを登録し、それ以上は登録しません。だからテーマだけがほしいページが、コンポーネントライブラリの代金を払うことはありません。

import中身
ranuiすべてのコンポーネント(副作用としてすべての <r-*> を登録します)
ranui/<component>ひとつのコンポーネント:ranui/buttonranui/selectranui/modal
ranui/themeinitTheme / setTheme / getTheme とトークンの上書き。要素なし
ranui/i18n翻訳エンジン。要素なし
ranui/fonts自前でホストする Geist Sans + Geist Mono(@font-face の CSS のみ)
ranui/styleスタイルシート。あなたの環境が自動で拾わない場合に
ranui/builderコンポーネント自身が書かれている流暢な DOM ビルダー
ranui/ssrranui/ssr-streamサーバーサイドレンダリング
ranui/testingテストから閉じたシャドウルートに手を伸ばすためのヘルパー
ranui/typingsアンビエントな型(JSX / TS の要素型定義)
js
import 'ranui/button'; // 要素ひとつ
import 'ranui'; // 全部

import は副作用のために。 import 'ranui/button'<r-button> を登録します。export されたクラスが要ることは滅多にありません。例外はサーバーサイドレンダリングで、そこでは自分でインスタンス化します。

コンポーネントの契約

各要素の正確な属性、プロパティ、イベント(detail の形も)、スロット、パーツはソースから生成され、COMPONENTS.md にまとまっています。以下のルールは、その表が語ら_ない_ことです。

属性は文字列、プロパティは型を持つ

HTML の属性は小文字で文字列型、対応するプロパティはキャメルケースで実際の値を取ります。同じ状態への、ふたつの入口です。

html
<r-select showsearch dropdownclass="wide"></r-select>
js
select.showSearch = true; // プロパティ — キャメルケース
select.setAttribute('showsearch', ''); // 属性 — 小文字
  • 真偽の属性は存在するかどうかで決まります。 ネイティブの <button>disabled と同じで、disabled=""disabled="false" も_無効_です。切るには属性を取り除く(またはプロパティに false を入れる)しかありません。
  • 豊かな値はプロパティを通します。 配列、オブジェクト、File は属性では生き残れません。たとえば r-attachmentsattachments はプロパティです。
  • マークアップ中の属性名は大文字小文字を区別しません。 上の HTML が showsearch なのにプロパティが showSearch なのはそのためです。JSX では属性の形で書いてください。

リスナーは要素そのものに付ける

ranui のコンポーネントは CustomEvent を発火し、中身はいつも detail にあります。

js
select.addEventListener('change', (event) => {
  const { value, label } = event.detail;
});

イベントがバブリングするかどうかはコンポーネントごとの判断なので、コンテナではなく要素に結んでください。 フォームとオーバーレイの中核(r-inputr-checkboxr-selectr-modal)は、意図的に、バブリングしないイベントを自分自身の上で発火します。あなたのフォームの中のセレクトから出た change が、フォームからの change に見えては困るからです。バブリングするものもあります(そして composed なのでシャドウ境界も越えます):r-theme-switchr-voice-buttonr-attachmentsr-conversationr-tool-cardr-markdownr-mathr-mermaidr-routerr-router-linkr-colorpicker

要素に付けたリスナーはどちらの場合でも動きます。祖先での委譲は後者の群でしか動かず、前者では_黙って_失敗します。委譲に頼る前に、ソースか COMPONENTS.md を確かめてください。

before* のイベントはキャンセルできます。 r-modal は動く前に beforeopen / beforeclose を発火し、event.preventDefault() がその遷移に拒否権を行使します。open / close / afteropen / afterclose の組は、すでに起きたことを報告するもので、キャンセルできません。

js
modal.addEventListener('beforeclose', (event) => {
  if (hasUnsavedChanges) event.preventDefault();
});

スロットとパーツ

内容はスロット(既定と名前付き)から入り、あなたの文書に留まります。だからあなたのページの CSS が普通にスタイルを当てられます。手が届かないのはコンポーネントが内部で組み立てたものだけで、::part() はそのためにあります。

シャドウ境界を越えたスタイリング

ranui のコンポーネントはすべて閉じたシャドウルートに描画されます。ページの CSS が漏れ入ることはなく、セレクターも突き抜けられません。中へ入る道はちょうど 4 つ、好ましい順に並べます。

手段使いどころ
カスタムプロパティコンポーネントがトークンとして公開しているものr-button { --ran-btn-background: #7c3aed; }
::part()トークンでは届かない構造の微調整r-card::part(footer) { justify-content: flex-end; }
sheet 属性中に注入する、プログラム的・動的な CSSel.sheet = '.ran-btn { letter-spacing: .02em }'
スロットの内容どのみちあなたが持っているマークアップ<span slot="extra">…</span>

カスタムプロパティが最も好ましいのは、境界を継承して越えるからです。:root に、ラッパーに、要素に、どこにトークンを置いても効きますし、それはテーマが使うのと同じトークンです。パーツと sheet は内部構造に縛られるので、本当に穴が空いているところに限って使い、アップグレードのたびに見直す前提でいてください。

どんな詳細度でも効かないもの:r-select .some-inner-class { … }!important、コンポーネントの中への querySelector。ルートが閉じているとは、あなたの CSS にとっても、スクリプトにとっても、テストランナーのロケーターにとっても element.shadowRootnull だということです。

状態を所有する

値ごとに、誰が持つかを決めてください。

  • コンポーネントが持つ(非制御):初期値を渡し、変わったらイベントの detail から値を読む。いちばん単純で、フォームではこれが既定です。
  • あなたのアプリが持つ(制御):描画のたびにプロパティを設定し、イベントは「あなたの状態を変えてほしいという_要求_」として扱う。あなたのモデルにすでに起きた変化として扱わない。

壊れるのは両方をやることです。コンポーネントの値のコピーを自分の状態に持ち、イベントのたびに書き戻し、その状態からプロパティを設定し直す。素早い入力の下でこのふたつはずれ、イベント中の書き込みはループにもなり得ます。どちらか一方向を選んでください。

js
// 制御:状態が真実の出どころ、イベントは要求
input.value = state.query;
input.addEventListener('input', (event) => {
  state.query = event.detail.value;
  render(); // ここでまた input.value を設定するが、持ち主はひとり
});

フレームワークとの統合

これらは標準的なカスタム要素なので、フレームワーク固有のものは何も要りません。ただし 3 つの細部が噛みつきます。

  • React(19 未満)はすべての JSX の prop を属性として設定するので、豊かな値は届かず、onChange 風の prop はカスタムイベントに結ばれません。ref を使い、エフェクトの中でプロパティを設定し addEventListener してください。React 19 はプロパティが存在すればそちらを設定しますが、それでも名前でカスタムイベントに結ぶことはしないので、リスナーのための ref は残しておいてください。
  • Vue は、そう指示しない限り未知のタグをコンポーネントとしてコンパイルします。ビルド設定の compilerOptions.isCustomElementr- を加えてください。そのあとは :prop がプロパティを、@change が本物のイベントリスナーを、どちらも正しく結びます。
  • Angular には CUSTOM_ELEMENTS_SCHEMA が要ります。Svelte と Solid は属性と on: / on のリスナーをそのまま通すので、何も要りません。

TypeScript を使うなら、JSX の intrinsic element 宣言のために import 'ranui/typings' できます。

サーバーサイドレンダリング

ranui のコンポーネントは宣言的 Shadow DOM にシリアライズされるので、サーバーが本物のマークアップを吐き出せますし、JavaScript が走る前から初回描画が正しくなります。

js
import 'ranui'; // SSR のレジストリを埋めます
import { renderHTMLToString } from 'ranui/ssr-stream';

const html = await renderHTMLToString(`
  <r-button type="primary">Submit</r-button>
  <r-progress percent="65"></r-progress>
`);

renderToStream(html) は同じものを非同期ジェネレーターにしたもので、ストリーミング応答向けです。ranui/ssrrenderToString(instance) は、自分で構築したコンポーネントのインスタンスひとつをシリアライズします。未知のタグはそのまま通るので、ページ全体に対して走らせても安全です。

知っておくべきことがふたつあります。

  • クライアントは組み直します。再利用はしません。 ルートが閉じているため、ブラウザーはサーバーが描画したツリーをコンポーネントのために再利用できません。だからアップグレード時、各要素は同一のものを一から構築します。サーバーからは初回描画が得られますが、ハイドレーションの再利用は得られません。クライアントが読むことを期待して、サーバー描画のシャドウマークアップに状態を置いてはいけません。
  • 測った値はサーバーにはありません。 getBoundingClientRectoffsetWidth に依存するものは、マウント後にブラウザーで解決します。

パフォーマンス

  • ほんの数個しか使わないページではコンポーネント単位で importしてください。バレルはライブラリの大半を使うアプリのためのものです。
  • バリアントは遅延読み込みされます。 r-iconr-loading は名前でバリアントを実行時に取ってくるので、基礎の費用が「使っていないアイコンの数」に比例して増えることはありません。
  • プロパティを設定してください。要素を作り直さないでください。 カスタム要素を置き換えるとコンストラクターがもう一度走ります。プロパティの設定はその場で更新します。
  • 属性の書き込みはまとめて。 書き込みのたびに attributeChangedCallback が走り得ます。できるところでは、挿入する前に状態を組み立ててください。

テスト

閉じたシャドウルートはテストのロケーターも止めます。 Playwright の getByRolegetByTextquerySelector はどれも境界で止まり、_何も_見つけません。だからそれらで書いた仕様は、一度も見ていない要素についてアサートしながら通ってしまいます。このリポジトリでも、誰かが気づく前にふたつのスイートがそう書かれていました。ranui/testing が、名前の付いた文書化された継ぎ目です。

js
import { insideShadow, settlePainted } from 'ranui/testing';

const label = await insideShadow(page, 'r-button', (root) => root.querySelector('[part=content]')?.textContent);

それ以外は、内部ではなく契約をテストしてください。属性かプロパティを設定し、イベントと、利用者が知覚できるものについてアサートします。内部のクラス名に対するアサーションはリファクタリングのたびに壊れ、コンポーネントが動くかどうかについては何も語りません。

アンチパターン

アンチパターンなぜ失敗するのか
r-input / r-selectchange をコンテナで委譲するそれらはバブリングしないので、リスナーは決して発火しません。要素に結んでください。
document.querySelector('r-select').shadowRoot閉じたルートなので常に null です。公開 API、パーツ、ranui/testing を使ってください。
r-card .inner { … } で内部にスタイルを当てるセレクターはどんな詳細度でも境界を越えません。トークンか ::part() を使ってください。
コンポーネントに勝つための !important勝つべきカスケードの衝突がありません。ルールがそもそも当たっていません。上と同じ直し方です。
コンポーネントの値を自分の状態に写して往復させるひとつの値にふたりの持ち主。ずれますし、ループもし得ます。
更新するために要素を作り直すコンストラクターがもう一度走り、フォーカスと内部状態が落ちます。プロパティを設定してください。
テーマ対応のコンポーネントの隣で色を直に書くテーマが切り替わった瞬間に壊れます。セマンティックトークンを使ってください。
オーバーレイが開く「かもしれない」からとラッパーに一律の z-index静的な内容まで、あなた自身の外枠より永遠に上に持ち上げます。:has() で範囲を絞ってください。
テストで shadowRoot を待つ上と同じです。ranui/testing を通すか、観測できるふるまいでアサートしてください。

ranui に貢献する

リポジトリには、ライブラリのコードのための、より厳しい独自の基準があります。

  • docs/DESIGN.md:実行可能なデザイン基準。9 つのルールが pnpm -F ranui verify:design で強制されます。
  • docs/CODING.md:ライブラリのコードのためのコンポーネント設計、状態の所有、テストのルール。
  • docs/BUILDER.md:流暢な DOM ビルダーとそのリアクティブなプリミティブ。
  • パッケージ直下の CLAUDE.md:npm の tarball にも同梱される、人もコーディングエージェントも最初に読む案内のファイル。

プルリクエストを出す前に:pnpm -F ranui test:allpnpm -F ranui verify:design、そして pnpm verify:docs(API とトークンの表は生成物です。古いままだと CI が落ちます)。

MIT ライセンスのもとで公開されています。