コーディングガイドライン
ranui で_作る_ための手引きです。コンポーネントの契約とは何か、Shadow DOM の境界がどこで慣れたルールを変えてしまうのか、そして踏む前に知っておく価値のある間違いはどれか。
その見た目の側はデザインガイドライン、トークンはデザインシステムにあります。
こんなときに:ranui のコンポーネントをアプリケーションに組み込んでいるとき。import を選ぶ、イベントを結ぶ、セレクターが届かないものにスタイルを当てる、サーバーで描画する、テストを書く、といった場面で。
原則
- 要素そのものが API です。 属性、プロパティ、イベント、スロット、
::part()が契約のすべてです。外から見えるそれ以外のものは、いずれ動く実装の詳細です。 - 状態はちょうどひとつの場所が持つこと。 値を持つのはあなたのアプリで、それを押し込むか、コンポーネントが持っていて変化を知らせてくるか、どちらかです。両方向に写し合うと値がずれていきます。
- Shadow DOM の境界を越えるスタイルは、カスタムプロパティ、
::part()、sheet、スロットで。 普通のセレクターは境界を越えません。詳細度をいくら上げても変わりません。 - 使うものだけ import すること。 どのコンポーネントにも専用のエントリーがあります。バレルは便利さであって、必須ではありません。
- プラットフォームを優先すること。 これらはカスタム要素です。
addEventListener、setAttribute、hiddenはすべて仕様どおりに動き、その上に重ねるフレームワークの抽象は任意です。
エントリーポイント
各エントリーは名前が言うとおりのものだけを登録し、それ以上は登録しません。だからテーマだけがほしいページが、コンポーネントライブラリの代金を払うことはありません。
| import | 中身 |
|---|---|
ranui | すべてのコンポーネント(副作用としてすべての <r-*> を登録します) |
ranui/<component> | ひとつのコンポーネント:ranui/button、ranui/select、ranui/modal … |
ranui/theme | initTheme / setTheme / getTheme とトークンの上書き。要素なし |
ranui/i18n | 翻訳エンジン。要素なし |
ranui/fonts | 自前でホストする Geist Sans + Geist Mono(@font-face の CSS のみ) |
ranui/style | スタイルシート。あなたの環境が自動で拾わない場合に |
ranui/builder | コンポーネント自身が書かれている流暢な DOM ビルダー |
ranui/ssr、ranui/ssr-stream | サーバーサイドレンダリング |
ranui/testing | テストから閉じたシャドウルートに手を伸ばすためのヘルパー |
ranui/typings | アンビエントな型(JSX / TS の要素型定義) |
import 'ranui/button'; // 要素ひとつ
import 'ranui'; // 全部import は副作用のために。 import 'ranui/button' は <r-button> を登録します。export されたクラスが要ることは滅多にありません。例外はサーバーサイドレンダリングで、そこでは自分でインスタンス化します。
コンポーネントの契約
各要素の正確な属性、プロパティ、イベント(detail の形も)、スロット、パーツはソースから生成され、COMPONENTS.md にまとまっています。以下のルールは、その表が語ら_ない_ことです。
属性は文字列、プロパティは型を持つ
HTML の属性は小文字で文字列型、対応するプロパティはキャメルケースで実際の値を取ります。同じ状態への、ふたつの入口です。
<r-select showsearch dropdownclass="wide"></r-select>select.showSearch = true; // プロパティ — キャメルケース
select.setAttribute('showsearch', ''); // 属性 — 小文字- 真偽の属性は存在するかどうかで決まります。 ネイティブの
<button>のdisabledと同じで、disabled=""もdisabled="false"も_無効_です。切るには属性を取り除く(またはプロパティにfalseを入れる)しかありません。 - 豊かな値はプロパティを通します。 配列、オブジェクト、
Fileは属性では生き残れません。たとえばr-attachmentsのattachmentsはプロパティです。 - マークアップ中の属性名は大文字小文字を区別しません。 上の HTML が
showsearchなのにプロパティがshowSearchなのはそのためです。JSX では属性の形で書いてください。
リスナーは要素そのものに付ける
ranui のコンポーネントは CustomEvent を発火し、中身はいつも detail にあります。
select.addEventListener('change', (event) => {
const { value, label } = event.detail;
});イベントがバブリングするかどうかはコンポーネントごとの判断なので、コンテナではなく要素に結んでください。 フォームとオーバーレイの中核(r-input、r-checkbox、r-select、r-modal)は、意図的に、バブリングしないイベントを自分自身の上で発火します。あなたのフォームの中のセレクトから出た change が、フォームからの change に見えては困るからです。バブリングするものもあります(そして composed なのでシャドウ境界も越えます):r-theme-switch、r-voice-button、r-attachments、r-conversation、r-tool-card、r-markdown、r-math、r-mermaid、r-router、r-route、r-link、r-colorpicker。
要素に付けたリスナーはどちらの場合でも動きます。祖先での委譲は後者の群でしか動かず、前者では_黙って_失敗します。委譲に頼る前に、ソースか COMPONENTS.md を確かめてください。
before* のイベントはキャンセルできます。 r-modal は動く前に beforeopen / beforeclose を発火し、event.preventDefault() がその遷移に拒否権を行使します。open / close / afteropen / afterclose の組は、すでに起きたことを報告するもので、キャンセルできません。
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 属性 | 中に注入する、プログラム的・動的な CSS | el.sheet = '.ran-btn { letter-spacing: .02em }' |
| スロットの内容 | どのみちあなたが持っているマークアップ | <span slot="extra">…</span> |
カスタムプロパティが最も好ましいのは、境界を継承して越えるからです。:root に、ラッパーに、要素に、どこにトークンを置いても効きますし、それはテーマが使うのと同じトークンです。パーツと sheet は内部構造に縛られるので、本当に穴が空いているところに限って使い、アップグレードのたびに見直す前提でいてください。
どんな詳細度でも効かないもの:r-select .some-inner-class { … }、!important、コンポーネントの中への querySelector。ルートが閉じているとは、あなたの CSS にとっても、スクリプトにとっても、テストランナーのロケーターにとっても element.shadowRoot が null だということです。
状態を所有する
値ごとに、誰が持つかを決めてください。
- コンポーネントが持つ(非制御):初期値を渡し、変わったらイベントの
detailから値を読む。いちばん単純で、フォームではこれが既定です。 - あなたのアプリが持つ(制御):描画のたびにプロパティを設定し、イベントは「あなたの状態を変えてほしいという_要求_」として扱う。あなたのモデルにすでに起きた変化として扱わない。
壊れるのは両方をやることです。コンポーネントの値のコピーを自分の状態に持ち、イベントのたびに書き戻し、その状態からプロパティを設定し直す。素早い入力の下でこのふたつはずれ、イベント中の書き込みはループにもなり得ます。どちらか一方向を選んでください。
// 制御:状態が真実の出どころ、イベントは要求
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.isCustomElementにr-を加えてください。そのあとは:propがプロパティを、@changeが本物のイベントリスナーを、どちらも正しく結びます。 - Angular には
CUSTOM_ELEMENTS_SCHEMAが要ります。Svelte と Solid は属性とon:/onのリスナーをそのまま通すので、何も要りません。
TypeScript を使うなら、JSX の intrinsic element 宣言のために import 'ranui/typings' できます。
サーバーサイドレンダリング
ranui のコンポーネントは宣言的 Shadow DOM にシリアライズされるので、サーバーが本物のマークアップを吐き出せますし、JavaScript が走る前から初回描画が正しくなります。
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/ssr の renderToString(instance) は、自分で構築したコンポーネントのインスタンスひとつをシリアライズします。未知のタグはそのまま通るので、ページ全体に対して走らせても安全です。
知っておくべきことがふたつあります。
- クライアントは組み直します。再利用はしません。 ルートが閉じているため、ブラウザーはサーバーが描画したツリーをコンポーネントのために再利用できません。だからアップグレード時、各要素は同一のものを一から構築します。サーバーからは初回描画が得られますが、ハイドレーションの再利用は得られません。クライアントが読むことを期待して、サーバー描画のシャドウマークアップに状態を置いてはいけません。
- 測った値はサーバーにはありません。
getBoundingClientRectやoffsetWidthに依存するものは、マウント後にブラウザーで解決します。
パフォーマンス
- ほんの数個しか使わないページではコンポーネント単位で importしてください。バレルはライブラリの大半を使うアプリのためのものです。
- バリアントは遅延読み込みされます。
r-iconとr-loadingは名前でバリアントを実行時に取ってくるので、基礎の費用が「使っていないアイコンの数」に比例して増えることはありません。 - プロパティを設定してください。要素を作り直さないでください。 カスタム要素を置き換えるとコンストラクターがもう一度走ります。プロパティの設定はその場で更新します。
- 属性の書き込みはまとめて。 書き込みのたびに
attributeChangedCallbackが走り得ます。できるところでは、挿入する前に状態を組み立ててください。
テスト
閉じたシャドウルートはテストのロケーターも止めます。 Playwright の getByRole、getByText、querySelector はどれも境界で止まり、_何も_見つけません。だからそれらで書いた仕様は、一度も見ていない要素についてアサートしながら通ってしまいます。このリポジトリでも、誰かが気づく前にふたつのスイートがそう書かれていました。ranui/testing が、名前の付いた文書化された継ぎ目です。
import { insideShadow, settlePainted } from 'ranui/testing';
const label = await insideShadow(page, 'r-button', (root) => root.querySelector('[part=content]')?.textContent);それ以外は、内部ではなく契約をテストしてください。属性かプロパティを設定し、イベントと、利用者が知覚できるものについてアサートします。内部のクラス名に対するアサーションはリファクタリングのたびに壊れ、コンポーネントが動くかどうかについては何も語りません。
アンチパターン
| アンチパターン | なぜ失敗するのか |
|---|---|
r-input / r-select の change をコンテナで委譲する | それらはバブリングしないので、リスナーは決して発火しません。要素に結んでください。 |
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:all、pnpm -F ranui verify:design、そして pnpm verify:docs(API とトークンの表は生成物です。古いままだと CI が落ちます)。