bootstrap-component-js について
Bootstrap 6 の JavaScript コンポーネントを開発するためのスキルです。TypeScript モジュールの規約に沿って、設定、イベント、データ API、クリーンアップ、登録、テストを進める方法を説明します。
主な機能
- 01コンポーネント構成と設定
- 02名前空間付きイベントとデータ API
- 03Floating UI とライブラリのラップ
- 04クリーンアップ、登録、テスト
活用シーン
- 01Bootstrap 6 への JavaScript コンポーネント追加
- 02コンポーネントの設定とイベントの接続
- 03コンポーネントのテストと型の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ twbs/ bootstrap --skill bootstrap-component-jsインストール後、Claude Code で/bootstrap-component-jsを入力して読み込まれたか確認します。
詳細
Bootstrap 6 コンポーネントの JavaScript
すべての Bootstrap プラグインは、BaseComponent を拡張する js/src/ 内の TypeScript モジュールです。BaseComponent は、インスタンスの登録、設定のマージ、破棄を処理します。共通の構造に従えば、getInstance()、getOrCreateInstance()、data-bs-* 設定、名前空間付きイベント、dispose() を自分で実装する必要はありません。
ソースは TypeScript ですが、実行時のパターンは v5 で使われていたものと同じで、その上に型が追加されています。Bootstrap はこれらを JavaScript にコンパイルし、独自の型宣言も出力するため、利用側で @types/bootstrap を用意する必要はありません。
コンポーネントのスタイルについては、bootstrap-component スキルを参照してください。
ワークフロー
- ステップ 1:モジュールのひな形を作成する
- ステップ 2:設定オプションを定義する
- ステップ 3:動作とイベントを実装する
- ステップ 4:データ API を接続する
- ステップ 5:Floating UI で配置する(任意)
- ステップ 6:サードパーティライブラリをラップする(任意)
- ステップ 7:dispose() でクリーンアップする
- ステップ 8:登録してテストする
- ステップ 9:検証する
ステップ 1:モジュールのひな形を作成する
js/src/<name>.ts を作成します。まず js/src/alert.ts を読んでください。これは最小の完全な例です。
/**
* --------------------------------------------------------------------------
* Bootstrap widget.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
/**
* Constants
*/
const NAME = 'widget'
const DATA_KEY = 'bs.widget'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="widget"]'
/**
* Class definition
*/
class Widget extends BaseComponent {
// Getters
static override get NAME(): string {
return NAME
}
}
export default Widget
- 必須の静的メンバーは
NAMEだけです。BaseComponentはそこからDATA_KEY(bs.<name>)とEVENT_KEY(.bs.<name>)を導出し、Data.set()で要素にインスタンスを保存し、同じ要素に紐付いている以前のインスタンスを破棄します。 - セクションコメント(
Constants、Class definition、Data API implementation)とメンバーのグループ分け(constructor、// Getters、// Public、// Private、// Static)はそのまま維持してください。js/src/内のすべてのモジュールで同じ形式を使っています。 - リポジトリのスタイルは、ES モジュールのみ、セミコロンなし、インデントはスペース 2 つ、シングルクォート、そして
BaseComponentまたはConfigのメンバーをオーバーライドする場合はすべてoverrideを付けます。 - 相対インポートでは、ファイルはディスク上で
.tsですが、ESM の.js拡張子を使います。これはtsconfig.jsonでmoduleResolution: nodenextが設定されているためです。Rolldown、Vite、Vitest はいずれも指定を.tsソースに対応付けます。 - コンパイラーには、書き始める前に知っておく価値のある厳格なルールがあります。
verbatimModuleSyntaxにより、型インポートには型を示す指定が必要です(import EventHandler, { type BootstrapEvent } from './dom/event-handler.js')。また、erasableSyntaxOnlyにより、列挙型、名前空間、コンストラクターのパラメータプロパティは使用できません。 - インスタンスの状態は
protected declare _foo: Typeとして宣言し、toast.tsと同じようにコンストラクターで代入してください。declareは型専用です。初期化子のない通常のフィールドは、useDefineForClassFieldsのもとで_foo = undefinedを出力し、基底クラスのコンストラクターが設定した値を消してしまいます。 - v6 には
defineJQueryPluginがなく、js/src/util.tsもありません。ヘルパーはjs/src/util/に、DOM ラッパーはjs/src/dom/(event-handler、selector-engine、manipulator、data)にあります。
ステップ 2:設定オプションを定義する
設定の型、デフォルト値、実行時の型をモジュール定数として宣言し、静的メンバーとして公開します。
type WidgetConfig = {
animation: boolean
delay: number
}
const Default: WidgetConfig = {
animation: true,
delay: 5000
}
const DefaultType = {
animation: 'boolean',
delay: 'number'
}
class Widget extends BaseComponent {
protected declare _config: WidgetConfig
protected declare _timeout: number | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
super(element, config)
this._timeout = null
}
// Getters
static override get Default(): WidgetConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
}
export default Widget
export type { WidgetConfig }
- 設定の型は複数の役割を担います。
Defaultはこの型で型付けされ、コンストラクターは呼び出し側が一部の値だけを渡すためPartial<>を受け取ります。また、protected declare _config: WidgetConfigを再宣言することで、BaseComponentの緩い設定型を絞り込み、this._config.delayをnumberとして扱えるようになります。DefaultTypeは通常の文字列マップのままです。これはコンパイラーではなく実行時チェックで使われます。型はエクスポートしてください。型テストでインポートされます。 Config._mergeConfigObj()が適用するマージ順(後の値が優先):Default→data-bs-config内の JSON → 個々のdata-bs-*属性 → コンストラクターに渡したオブジェクト。これらを自分で実装する必要はありません。- 読み取り時に
data-bs-*の名前はキャメルケースに変換されるため、data-bs-auto-hide="false"はautoHide: falseになります。 DefaultTypeのすべてのキーが検証され、不一致の場合はTypeErrorがスローされます。値は正規表現として照合されるため、共用体も使えます:'boolean'、'(number|string)'、'(string|element|function)'。- 検証の前に値を正規化するには、
_configAfterMerge(config)をオーバーライドします。通常は、セレクター文字列を要素に解決したり、省略形を変換したりする場所です。
ステップ 3:動作とイベントを実装する
class Widget extends BaseComponent {
// Public
show(): void {
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent.defaultPrevented) {
return
}
this._element.classList.add(CLASS_NAME_SHOW)
this._queueCallback(() => {
EventHandler.trigger(this._element, EVENT_SHOWN)
}, this._element, this._config.animation)
}
}
- イベントはペアで発生します。アクションの前に発生する不定詞形(
show.bs.widget、キャンセル可能)と、アクションの後に発生する過去分詞形(shown.bs.widget)です。「前」のイベントがdefaultPreventedの場合は、必ず処理を中断してください。 _queueCallback(callback, element, isAnimated)は、elementの CSS トランジションが完了した後にコールバックを実行し、トランジション中にインスタンスが破棄された場合はスキップします。設定からアニメーションフラグを渡すと、アニメーションしないコンポーネントではすぐに実行されます。- リスナーにコンテキストが必要な場合は、ペイロードを追加します。
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })のように指定します。trigger()はペイロードのキーを加えたEventであるBootstrapEventを返すため、ハンドラー内で値を読み取る際にキャストは不要です。 - 生の
querySelectorではなく、SelectorEngine(findOne、find、next、prev、getElementFromSelector)で検索し、属性の読み書きにはManipulatorを使用します。 - 名前空間付きの定数を使い、
EventHandler.on(this._element, EVENT_MOUSEOVER, …)で内部リスナーを登録します。これにより、dispose()でまとめて削除できます。
ステップ 4:データ API を接続する
データ API は副作用として、クラスの後、モジュールの末尾に配置します。
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
event.preventDefault()
Widget.getOrCreateInstance(this).toggle()
})
documentからイベントをデリゲートし、イベント名に.data-apiを付けて、ユーザーリスナーと区別できるようにします。- デリゲートされた要素が必要な場合は、アロー関数ではなく
functionを使用します。EventHandlerはコールバックのthisを型付けするため、thisがSELECTOR_DATA_TOGGLEに一致する要素になります。実際のクリック対象が必要な場合に限り、アロー関数を使ってevent.targetを参照してください。 - 独自のハンドラーを作るのではなく、
js/src/util/component-functions.tsの共通ヘルパーを優先してください。enableDismissTrigger(Widget)は、[data-bs-dismiss="widget"]をhide()に接続します。それ以外のメソッドを使う場合は、enableDismissTrigger(Alert, 'close')のようにメソッド名を渡します。eventActionOnPlugin(Widget, 'click', SELECTOR_DATA_TOGGLE, 'toggle')は、「トリガーがdata-bs-targetを介して 1 つ以上のターゲットを参照する」ケースを処理し、無効な要素をスキップします。
- これらは副作用であるため、データ API はモジュールをインポートした場合にのみ利用できます。単一のコンポーネントファイルをインポートすると、そのコンポーネントのデータ API のみが読み込まれます。
ステップ 5:Floating UI で配置する(任意)
メニュー、ツールチップ、ポップオーバーの配置には、オプションのピア依存関係である @floating-ui/dom を使用します。super() を呼び出す前にコンストラクターで有無を確認してください。ここでは、クラスがフィールドを初期化せずに宣言しているため、これが可能です。
import { autoUpdate, computePosition } from '@floating-ui/dom'
class Widget extends BaseComponent {
protected declare _floatingCleanup: (() => void) | null
constructor(element?: string | Element | null, config?: Partial<WidgetConfig> | null) {
if (typeof computePosition === 'undefined') {
throw new TypeError('Bootstrap\'s widgets require Floating UI (https://floating-ui.com)')
}
super(element, config)
this._floatingCleanup = null
}
}
要素が表示されたときに配置処理を作成し、キャンセルできるようにクリーンアップ関数を保持します。
class Widget extends BaseComponent {
// Private
protected _createFloating(): void {
this._updateFloatingPosition()
this._floatingCleanup = autoUpdate(this._element, this._floatingEl, () => this._updateFloatingPosition())
}
protected _disposeFloating(): void {
if (this._floatingCleanup) {
this._floatingCleanup()
this._floatingCleanup = null
}
}
}
配置処理を自前で実装する必要はありません。js/src/util/floating-ui.ts が対応します。getDefaultPlacement() は RTL の場合に -start/-end を入れ替え、parseResponsivePlacement() と getResponsivePlacement() は data-bs-placement="bottom-start md:top-end" をサポートします。また、createBreakpointListeners()/disposeBreakpointListeners() はブレークポイントの変更時に再配置します。
ステップ 6:サードパーティーライブラリをラップする(任意)
js/src/datepicker.ts は vanilla-calendar-pro をラップしており、このパターンの参考例です。
- Bootstrap の設定項目を維持します。ユーザーは
data-bs-*オプションを設定し、非公開のビルダー(_buildCalendarOptions())がそれをライブラリのオプション形式に変換します。 - コンストラクターから呼び出す非公開の初期化メソッドでインスタンスを作成し、ライブラリ独自の型を指定した非公開フィールドに保持します(
protected declare _calendar: Calendar | null)。 - ライブラリのコールバックを名前空間付きの Bootstrap イベントとして再発行し、利用者が覚える API を 1 つにします。
super.dispose()の前にdispose()でライブラリのクリーンアップ処理を呼び出し、フィールドをnullにします。package.jsonのpeerDependenciesにパッケージを追加し、存在しない場合に備えて処理を行い、コンポーネントのドキュメントページに追加のインストール手順を記載します。
ライブラリをフォークまたはベンダー化せず、そのインスタンスを公開 API として公開しないでください。ユーザーが必要とする機能は、Bootstrap クラスのメソッドまたはイベントとして提供してください。
ステップ 7:dispose() でクリーンアップする
class Widget extends BaseComponent {
// Public
override dispose(): void {
clearTimeout(this._timeout!)
this._observer?.disconnect()
this._disposeFloating()
super.dispose()
}
}
super.dispose() は Data エントリーを削除し、EVENT_KEY のもとで this._element に登録されたすべてのリスナーを削除して、インスタンス自身のプロパティを null にします。それ以外は、先に自分で解除する必要があります。タイマー、MutationObserver/ResizeObserver インスタンス、document や window に登録されたリスナー、Floating UI のクリーンアップ、サードパーティー製のインスタンスなどです。ここでのリークは、「should dispose」テストの失敗として現れます。
ステップ 8:登録してテストする
-
js/src/index.tsにエクスポートを追加します。export { default as Widget } from './widget.js'このバレルファイルはパッケージのエントリーポイントであり、
dist/js/bootstrap.jsとバンドルの両方に取り込まれます。ほかに更新が必要なものはありません。Rolldown はjs/src/index.tsをもとにビルドし、build/build-plugins.mjsはjs/src/**/*.tsを対象にjs/dist/内のプラグインごとのファイルを生成し、Vitest はjs/tests/unit/**/*.spec.jsを検出します。 -
両方の型テストを拡張します。
js/tests/types/api.tsはソースに対して公開 API を検証し、js/tests/types/consumer.tsは下流プロジェクトと同じようにパッケージ名を通じてインポートし、配布されるjs/dist/*.d.tsに対して同じ API を検証します。クラス、その設定型、メソッドを両方に追加します。宣言ファイルの生成に関するバグは、前者を通過して後者で失敗します。 -
js/tests/unit/<name>.spec.jsにテストを追加します。テストは通常の JavaScript で記述し、Vitest のブラウザーモードを使って実際の Chromium 上で実行します。import Widget from '../../src/widget.js' import { clearFixture, getFixture } from '../helpers/fixture.js' describe('Widget', () => { let fixtureEl beforeAll(() => { fixtureEl = getFixture() }) afterEach(() => { clearFixture() }) it('should expose the plugin name and data key', () => { expect(Widget.NAME).toEqual('widget') expect(Widget.DATA_KEY).toEqual('bs.widget') }) })describe、it、expect、フックはグローバルです(js/tests/vitest.config.mtsでglobals: trueに設定されているため)。そのため、インポートしないでください。スパイやフェイクタイマーが必要な場合は、vitestからviをインポートしてください。新しいテストではこちらが推奨 API です。js/tests/vitest-setup.jsは、古いテストで使われている Jasmine API を Vitest に対応付けているため、spyOn、jasmine.clock()、toHaveClass、toBeTrue、toBeFalse、toHaveSizeも引き続き使えます。注意点が 2 つあります。shim のspyOnはメソッドをスタブ化するため、本来のメソッドを実行するには.and.callThrough()を追加してください。また、Vitest 4 ではdoneコールバックが廃止されたため、非同期テストでは Promise を返し、リスナーからその Promise を解決します。- セレクターと要素の両方を使ったコンストラクター、
data-bs-*からの設定取得、各パブリックメソッド、キャンセル可能なイベントを含む両方のイベント、手動でインスタンス化しない場合のデータ API、dispose()を網羅してください。カバレッジではjs/src内のすべてのファイルが対象となるため、テストのないコンポーネントがあると、js/tests/vitest.config.mtsのしきい値(ステートメント、関数、行が 90%、分岐が 88%)を下回り、ビルドが失敗します。
-
コンポーネントのドキュメントページに説明を追加します。フロントマターに
js: optional(またはjs: required)を追加し、### Optionsセクションに<JsDataAttributes />を記載して、オプション、メソッド、イベントの表には<BsTable>を使用します。デモ用スニペットはsite/src/assets/partials/snippets.jsに置き、<JsDocs name="live-widget" file="site/src/assets/partials/snippets.js" />で表示します。
ステップ 9:確認する
npm run js-lintnpm run js-typecheck—js/srcと型テストを対象にtsc --noEmitを実行します。npm run js-test-unitを実行するか、ツリーシェイキング可能なモジュールであることを示す Rolldown 統合バンドル 2 つも実行するにはnpm run js-testを実行します。Playwright のブラウザーはマシンごとに 1 回インストールする必要があります(npx playwright install chromium)。npm run js-debugを実行すると、画面に表示されたブラウザーでテストを実行できます。npm run js— Rolldown がソースをdist/jsとjs/distにコンパイルし、terser が最小化して、tscが.d.tsファイルを生成します。バンドルのサイズが想定どおりに増えていることを確認してください。npm run bundlewatchでサイズ上限をチェックします。npm startを実行し、ドキュメントページでコンポーネントを操作して、マウスだけでなくキーボードでも確認します。
独自のプロジェクトで使う
package.json では、bootstrap/js/src/*.js をコンパイル済みの js/dist/*.js とその型宣言に対応付けているため、JavaScript と TypeScript のどちらからも同じ指定子を使用でき、カスタムコンポーネントも同じ基底クラスを継承できます。
import BaseComponent from 'bootstrap/js/src/base-component.js'
import EventHandler from 'bootstrap/js/src/dom/event-handler.js'
class Widget extends BaseComponent {
static override get NAME(): string {
return 'widget'
}
}
注意点が 2 つあります。Bootstrap のコンポーネントモジュールをインポートすると、副作用としてデータ API が実行されます。これはアプリのエントリーポイントでは望ましい動作ですが、ライブラリ内では避けるべきです。また、DATA_KEY は bs.<name> として導出されるため、Bootstrap プラグインと名前が衝突しない NAME を選んでください。そうしないと、共有要素上でインスタンスが既存のものを置き換えてしまいます。