mcpField
B
bootstrap-component-js

bootstrap-component-js

github.com/twbs開発175.0k351 回インストール今日更新
リポジトリ

bootstrap-component-js について

Bootstrap 6 の JavaScript コンポーネントを開発するためのスキルです。TypeScript モジュールの規約に沿って、設定、イベント、データ API、クリーンアップ、登録、テストを進める方法を説明します。

主な機能

  1. 01コンポーネント構成と設定
  2. 02名前空間付きイベントとデータ API
  3. 03Floating UI とライブラリのラップ
  4. 04クリーンアップ、登録、テスト

活用シーン

  1. 01Bootstrap 6 への JavaScript コンポーネント追加
  2. 02コンポーネントの設定とイベントの接続
  3. 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 をラップしており、このパターンの参考例です。

  1. Bootstrap の設定項目を維持します。ユーザーは data-bs-* オプションを設定し、非公開のビルダー(_buildCalendarOptions())がそれをライブラリのオプション形式に変換します。
  2. コンストラクターから呼び出す非公開の初期化メソッドでインスタンスを作成し、ライブラリ独自の型を指定した非公開フィールドに保持します(protected declare _calendar: Calendar | null)。
  3. ライブラリのコールバックを名前空間付きの Bootstrap イベントとして再発行し、利用者が覚える API を 1 つにします。
  4. super.dispose() の前に dispose() でライブラリのクリーンアップ処理を呼び出し、フィールドを null にします。
  5. 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:登録してテストする

  1. 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 を検出します。

  2. 両方の型テストを拡張します。js/tests/types/api.ts はソースに対して公開 API を検証し、js/tests/types/consumer.ts は下流プロジェクトと同じようにパッケージ名を通じてインポートし、配布される js/dist/*.d.ts に対して同じ API を検証します。クラス、その設定型、メソッドを両方に追加します。宣言ファイルの生成に関するバグは、前者を通過して後者で失敗します。

  3. 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%)を下回り、ビルドが失敗します。
  4. コンポーネントのドキュメントページに説明を追加します。フロントマターに 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:確認する

  1. npm run js-lint
  2. npm run js-typecheck — js/src と型テストを対象に tsc --noEmit を実行します。
  3. npm run js-test-unit を実行するか、ツリーシェイキング可能なモジュールであることを示す Rolldown 統合バンドル 2 つも実行するには npm run js-test を実行します。Playwright のブラウザーはマシンごとに 1 回インストールする必要があります(npx playwright install chromium)。npm run js-debug を実行すると、画面に表示されたブラウザーでテストを実行できます。
  4. npm run js — Rolldown がソースを dist/js と js/dist にコンパイルし、terser が最小化して、tsc が .d.ts ファイルを生成します。バンドルのサイズが想定どおりに増えていることを確認してください。npm run bundlewatch でサイズ上限をチェックします。
  5. 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 を選んでください。そうしないと、共有要素上でインスタンスが既存のものを置き換えてしまいます。

コメント