| name | develop-component |
| description | コンポーネントを新規作成・更新する。CSS、HTML、Storybook Stories、テスト、JavaScript(Custom Elements)の実装を含む。 |
コンポーネント開発スキル
コンポーネントを開発するためのガイドライン。CSS、HTML、Stories、テストファイル、および必要に応じてJavaScriptの実装を対象とする。
コンポーネントのファイル構成
各コンポーネントは src/components/<component-name>/ ディレクトリに以下のファイルで構成する。
src/components/<component-name>/
├── <component-name>.css # 必須: スタイル
├── playground.html # 必須: Playground用の基本HTML
├── <variation>.html # 任意: 使い方が異なるバリエーション用HTML
├── <component-name>.stories.css # 任意: Storybookストーリー専用のスタイル調整
├── <component-name>.stories.ts # 必須: Storybookストーリー
├── <component-name>.vrt.js # 必須: VRT(Visual Regression Test、Playwright)
├── <component-name>.test.js # 任意: 機能テスト(Vitest browser mode、JSを含むコンポーネント)
├── <component-name>.unit.js # 任意: ユニットテスト(Vitest jsdom、純粋関数のロジック検証)
├── <component-name>.mdx # 必須: ドキュメント(write-documentスキルを使用)
└── <component-name>.js # 任意: Custom Element(インタラクティブな場合)
ファイル名規則
- ケバブケースを使用する(例:
date-picker、chip-label)
- ディレクトリ名とファイル名のプレフィックスを一致させる
開発の前提作業(必須)
コンポーネントを新規作成・更新する前に、以下を確認する。
新規作成時
- 既存のコンポーネント一覧を確認: src/components/ ディレクトリ直下の各コンポーネントディレクトリを把握する
- 類似の既存コンポーネントを確認: 同種の既存コンポーネントの全ファイルを読み、パターンを把握する
src/global.cssを確認: 使用可能なデザイントークン(カラー、フォント、エレベーション)とユーティリティクラスを把握する
更新時
- 対象コンポーネントの全ファイルを読み込む: CSS、全HTML、Stories、テスト、JS(存在する場合)
- 既存のパターンを維持: 既存コンポーネントのコーディングスタイルに合わせる
CSS
クラス命名規則
BEMをベースにした命名規則を使用する。
.dads-<block-name>__<element-name>[data-<modifier-name>="<value>"]
規則:
- 接頭辞
dads- を必ず付ける
- 単語はケバブケース
- Block-Elementの区切りはアンダースコア2つ
__
- ModifierはBEM記法(
--)ではなくdata属性を使用
- 真偽値Modifierは値を省略(例:
[data-disabled])
- ARIAステートをスタイリングに使用することを許容(例:
[aria-invalid="true"]、[aria-disabled="true"])
.dads-button[data-type="solid-fill"] { }
.dads-button[data-size="lg"] { }
.dads-button[aria-disabled="true"] { }
.dads-input-text__input[aria-invalid="true"] { }
.dads-button_solid-fill { }
単位の規則
原則として、rem単位を基本とし、px単位は使用しない。calc(px / 16 * 1rem) のパターンを必ず使用し、px値から計算する。ただし、border-width にはpx単位を使用してもよい(境界線として使用する場合はpx単位、オブジェクトを構成する一要素として使用する場合はrem単位の使用を推奨)。
.dads-component {
font-size: calc(16 / 16 * 1rem);
padding: calc(8 / 16 * 1rem) calc(16 / 16 * 1rem);
border-radius: calc(8 / 16 * 1rem);
min-height: calc(48 / 16 * 1rem);
gap: calc(4 / 16 * 1rem);
}
.dads-component {
border: 1px solid currentcolor;
}
コンポーネントルートで継承プロパティをリセットする
リセットCSSの有無を問わず動作させるため、コンポーネントのルート要素で継承されるプロパティをリセットする。
.dads-component {
color: var(--color-neutral-solid-gray-800);
font-weight: normal;
font-size: calc(16 / 16 * 1rem);
line-height: 1.7;
font-family: var(--font-family-sans);
letter-spacing: 0.02em;
}
全てのプロパティが毎回必要なわけではなく、コンポーネントの性質に応じて適切なものを選択する。
box-sizingの初期化
borderまたはpaddingと、widthまたはheightが共存する要素には box-sizing: border-box を明示的に指定する。
.dads-component__input {
box-sizing: border-box;
width: 100%;
padding: calc(12 / 16 * 1rem);
border: 1px solid var(--color-neutral-solid-gray-600);
}
marginの初期化
UAスタイルシートやリセットCSSによって異なるmarginが適用される可能性がある要素(<hr>、<p>、<ul>等)は margin: 0 でリセットする。
.dads-divider {
margin: 0;
}
セレクタの詳細度
- セレクタは原則としてクラス名を使用する。IDセレクタや要素セレクタは使用しない
- 詳細度を最小限にするための過度な工夫(
:where()の多用等)は避ける
- 記述が多くの開発者にとって理解しやすいことを優先する
.dads-button[data-type="solid-fill"]:hover {
background-color: var(--button-hover-color);
}
.dads-button:where([data-type="solid-fill"]:hover) {
background-color: var(--button-hover-color);
}
デザイントークンの使用
src/global.css で定義されたCSS Custom Propertiesを使用する。
var(--color-key-900)
var(--color-primitive-blue-900)
var(--color-neutral-solid-gray-800)
var(--color-neutral-white)
var(--color-neutral-black)
var(--color-semantic-error-1)
var(--font-family-sans)
var(--font-family-mono)
var(--elevation-1) 〜 var(--elevation-5)
コンポーネントスコープのCustom Properties
コンポーネント内で再利用する値は、コンポーネントスコープのCustom Propertiesとして定義できる。ただし、過度な抽象化は避ける。
プレフィックスの使い分け:
--_ プレフィックス: プライベート(コンポーネント内部でのみ使用、外部から設定されることを想定しない)
-- プレフィックス(_なし): パブリックAPI(コンポーネント利用者が外部から上書きできる)
.dads-checkbox[data-size="sm"] {
--_gap: calc(4 / 16 * 1rem);
--_checkbox-size: calc(24 / 16 * 1rem);
}
.dads-checkbox[data-size="md"] {
--_gap: calc(8 / 16 * 1rem);
--_checkbox-size: calc(32 / 16 * 1rem);
}
.dads-button {
--button-color: var(--color-key-900);
--button-hover-color: var(--color-key-1000);
}
.dads-button {
--dads-button-bg-color: var(--color-key-900);
--dads-button-text-decoration: none;
background-color: var(--dads-button-bg-color);
text-decoration: var(--dads-button-text-decoration);
}
フォーカススタイル
フォーカスが可視な要素には統一的なフォーカスリングを適用する。
.dads-component:focus-visible {
outline: calc(4 / 16 * 1rem) solid var(--color-neutral-black);
outline-offset: calc(2 / 16 * 1rem);
box-shadow: 0 0 0 calc(2 / 16 * 1rem) var(--color-primitive-yellow-300);
}
無効状態
disabled属性とaria-disabled属性の両方に対応する。
.dads-component:disabled,
.dads-component[aria-disabled="true"] {
}
エラー状態
:user-invalid擬似クラスとaria-invalid属性に対応する。
.dads-component:is(:user-invalid, [aria-invalid="true"]) {
border-color: var(--color-semantic-error-1);
}
メディアクエリ
レスポンシブ(モバイルファースト)
.dads-component { }
@media (min-width: 48rem) {
.dads-component { }
}
ホバー
タッチ端末でホバーが発動しないよう、ホバースタイルは @media (hover: hover) で囲む。
@media (hover: hover) {
.dads-component:hover {
}
}
強制カラーモード
Windowsのコントラストテーマに対応する。
@media (forced-colors: active) {
.dads-component {
}
}
使用できるシステムカラー: GrayText、Canvas、ButtonText、Highlight、HighlightText 等。
視覚効果低減モード
フェード以外のアニメーションがある場合に対応する。
@media (prefers-reduced-motion: reduce) {
.dads-component {
transition: none;
}
}
論理的プロパティの不使用
margin-block、padding-inline等の論理的プロパティは使用しない。margin-top、padding-left 等の物理的プロパティを使用する。
カスケードレイヤーの不使用
@layer は使用しない。
CSSプリプロセッサの不使用
標準的なCSSのみで記述する。Sass、Less等は使用しない。
HTML
HTMLファイルのテンプレート
全てのHTMLファイルは以下のテンプレートに従う。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Component Name</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../global.css">
<link rel="stylesheet" href="<component-name>.css">
</head>
<body>
</body>
</html>
注意:
- 他コンポーネントに依存する場合、その CSS も
<link> で読み込む(例: <link rel="stylesheet" href="../button/button.css">)
- JSファイルがある場合は
<script type="module" src="<component-name>.js"></script> を </body> の前に配置する
HTMLファイルの種類と用意の判断基準
playground.html(必須)
Storybookの Playground ストーリーで使用する基本的なHTMLファイル。コンポーネントの代表的な構成を1つだけ含む。
<body>
<button class="dads-button" data-size="md" data-type="solid-fill">ボタン</button>
</body>
playground.htmlのみで十分な場合:
- data属性の値を変えるだけでバリエーションが表現できる
- 要素の足し引き(アイコン追加等)で拡張できる
- StoriesのPlaygroundコントロールでパラメータを調整すれば全パターンが確認できる
追加のHTMLファイルが必要な場合
コンポーネントの使い方やHTML構造自体が大きく異なるバリエーションがある場合に、追加のHTMLファイルを用意する。
追加HTMLが必要な例:
with-form-control-label.html: フォームコントロールラベルと組み合わせた使用例
with-existing-files.html: 既存ファイルが事前に設定された状態のファイルアップロード
readonly.html: 読み取り専用状態(構造や表示が大きく変わる場合)
stacked.html: 複数のコンポーネントを積み重ねた表示
追加HTMLが不要な例:
- サイズ違い(
data-size="sm" vs data-size="lg")→ playgroundのコントロールで対応
- タイプ違い(
data-type="solid-fill" vs data-type="outline")→ playgroundのコントロールで対応
- disabled状態 → playgroundのコントロールで対応
セマンティックマークアップ
- HTML要素は目的に沿って使用する(見た目のためではなく意味のために)
- ネイティブHTML要素で実現できる機能はARIAよりHTML要素を優先する
多言語対応の配慮
JavaScriptやCSSにインラインで言語依存情報を含めず、HTMLのdata属性で言語情報を提供する。
作例(file-upload):
<dads-file-upload
class="dads-file-upload"
max-files="5"
max-total-size="10MB"
max-file-size="5MB"
data-error-invalid-type="PNG/JPEG/GIF形式の画像、Excel/Word/PowerPoint/PDF形式のドキュメントだけが選択できます。"
>
</dads-file-upload>
#getMessage(category, key, variables = {}) {
const datasetKey = `${category}${key.charAt(0).toUpperCase()}${key.slice(1)}`;
let template = this.dataset[datasetKey];
if (!template) {
template = FileUpload.defaultMessages[category]?.[key] || "";
}
return template.replace(/\{(\w+)\}/g, (match, variable) => {
return variables[variable] !== undefined ? variables[variable] : match;
});
}
ポイント:
- デフォルトのメッセージはJavaScript内に定義しておく
- HTMLの
data-*属性でメッセージを上書きできるようにする
- テンプレート変数(
{count}、{max}等)で動的な値を埋め込み可能にする
JavaScript(Custom Elements)
インタラクティブなコンポーネントでは Custom Elements を使用する。
基本構造
export class ComponentName extends HTMLElement {
#abort = null;
connectedCallback() {
this.#abort = new AbortController();
this.#setupEventListeners();
}
disconnectedCallback() {
this.#abort.abort();
}
#setupEventListeners() {
const signal = this.#abort.signal;
this.#triggerButton.addEventListener(
"click",
(e) => this.#handleClick(e),
{ signal },
);
this.#someInput.addEventListener(
"change",
(e) => this.#handleChange(e),
{ signal },
);
this.addEventListener(
"click",
({ target }) => {
if (!target.closest("[data-js-remove-button]")) return;
this.#handleRemove(target);
},
{ signal },
);
}
#handleClick(e) {
}
#handleChange(e) {
}
#handleRemove(target) {
}
get #triggerButton() {
return this.querySelector("[data-js-trigger-button]");
}
get #someInput() {
return this.querySelector("[data-js-input]");
}
}
customElements.define("dads-component-name", ComponentName);
規則
- Shadow DOMは使用しない
AbortControllerのsignalでイベントリスナーを管理する: addEventListenerの第3引数に{ signal }を渡すことで、abort()呼び出し時に自動的にリスナーが解除される
disconnectedCallbackでabort()を呼ぶ: メモリリークを防止する
- DOM要素へのアクセスはgetterで定義する:
data-js-* 属性をセレクタに使用する
- カスタムイベントは
CustomEventを使用する: detailプロパティでデータを伝搬する(例: new CustomEvent("date-selected", { detail: { date }, bubbles: true }))
- ES Moduleとしてエクスポートする:
export class でクラスをエクスポートする
- プライベートフィールドは
#を使用する: #abort、#timers 等
公開インターフェースの設計
Custom Elementの公開APIは最小限に絞り、内部実装は # でprivate化する。
公開API(public)として残すもの
以下のみを公開APIとして扱う。
- Web Components仕様のライフサイクルメソッド(必須):
connectedCallback、disconnectedCallback、attributeChangedCallback
static observedAttributes
- 他のコンポーネントや外部スクリプトから呼び出されるメソッド:
- 別のコンポーネントクラスから
element.method() として呼び出される
focus() のようにHTMLElement標準メソッドのオーバーライド
- 例:
Calendar.setSelectedDate()/setDisplayMonth()/focus() → DatePickerから呼ばれる
- 例:
CarouselStepNav.setSelectedIndex() → Carouselから呼ばれる
- インテグレーター向けの状態アクセスAPI(状態を外部に公開する必要がある場合):
- 例:
FileUpload.files(ファイル一覧)、FileUpload.errors(エラー一覧)
- 例:
FileUpload.addFiles()、removeFile()(プログラムからのファイル操作)
- 静的メソッド・プロパティ(外部カスタマイズのエントリポイント):
- 例:
FileUpload.defaultMessages(デフォルトメッセージの上書き)
privateにするもの(# プレフィックス)
以下はすべて # でprivate化する。
- イベントハンドラ:
#handleClick、#handleKeydown 等
- 初期化・更新メソッド:
#setupEventListeners、#renderCalendar、#update 等
- 内部ロジックメソッド:
#navigateToDate、#validateFiles、#formatJapaneseYear 等
- DOMアクセスゲッター:
#calendarTable、#fallbackInput、#opener 等
- 状態計算ゲッター:
#isPreviousMonthAvailable、#isOpen、#currentIndex 等
判断の基準
「クラスの外から呼ぶ必要があるか?」「外部から使用できることでコンポーネントの有用性が高まるか?」を問う。クラス内部だけで使われるものはすべてprivate。
setSelectedDate(date) { ... }
focus() { ... }
#renderCalendar() { ... }
#setExpandedDropArea(expanded) { ... }
data-js-* 属性
JavaScriptからDOM要素を取得するためのセレクタには data-js-* 属性を使用する。CSSのスタイリング用 data-* 属性とは区別する。
<button data-js-calendar-button>カレンダー</button>
<div data-js-calendar-popover>...</div>
<input data-js-input type="file">
get #calendarButton() {
return this.querySelector("[data-js-calendar-button]");
}
外部ライブラリ
原則として使用しない。使用する場合は以下を満たす必要がある:
- アクセシブルであること
- HTML中心の開発スタイルと合致すること
- スコープが明確で、一つのことをうまくやること
- 実装の複雑さが著しく、メンテナンスコストが正当化されること
将来的に標準化が見込まれるAPIのpolyfillは使用できる。
Storybook Stories(.stories.ts)
基本構造
import type { Meta, StoryObj } from "@storybook/html-vite";
import { HtmlFragment } from "../../helpers/html-fragment";
import "./component-name.css";
import "../button/button.css";
import playground from "./playground.html?raw";
import withFormControlLabel from "./with-form-control-label.html?raw";
const meta = {
title: "Components/コンポーネント名(日本語)",
} satisfies Meta;
export default meta;
metaオブジェクト
title: "Components/<日本語コンポーネント名>" の形式(例: "Components/ボタン"、"Components/インプットテキスト")
- 他のメタ情報(
component、decorators等)は原則不要
Playgroundストーリー
インタラクティブなコントロールを持つメインのストーリー。
interface ComponentPlaygroundProps {
variant: string;
size: string;
disabled: boolean;
}
export const Playground: StoryObj<ComponentPlaygroundProps> = {
render: (args) => {
const fragment = new HtmlFragment(playground, ".dads-component");
const component = fragment.root;
const input = component.querySelector(".dads-component__input");
const errorText = component.querySelector(".dads-component__error-text");
if (!input) throw new Error();
if (!errorText) throw new Error();
component.setAttribute("data-type", args.variant);
component.setAttribute("data-size", args.size);
if (args.disabled) {
input.setAttribute("disabled", "");
}
if (!args.errored) {
errorText.remove();
}
return fragment.toString({ trimBlankLines: true });
},
argTypes: {
variant: {
control: { type: "radio" },
options: ["solid-fill", "outline", "text"],
},
size: {
control: { type: "radio" },
options: ["lg", "md", "sm"],
},
disabled: { control: "boolean" },
},
args: {
variant: "solid-fill",
size: "md",
disabled: false,
},
};
HtmlFragmentの使い方
HtmlFragment はHTMLファイルから特定のセレクタに一致する要素を抽出するユーティリティ。
const fragment = new HtmlFragment(playground, ".dads-component");
const component = fragment.root;
component.setAttribute("data-size", "lg");
component.removeAttribute("data-disabled");
const input = component.querySelector(".dads-component__input");
fragment.toString();
fragment.toString({ trimBlankLines: true });
セレクタの選択:
- コンポーネントのルートクラス:
.dads-component
- body直下のラッパー:
"body > div"
- 親コンポーネントのクラス:
.dads-form-control-label(別コンポーネントとの組み合わせ表示時)
追加ストーリー(バリエーション表示等)
export const WithFormControlLabel = () =>
new HtmlFragment(withFormControlLabel, ".dads-form-control-label").toString();
export const Readonly = () =>
new HtmlFragment(readonly, ".dads-form-control-label").toString();
JSを含むコンポーネントのStories
JavaScriptを使用するコンポーネントでは、Storiesファイル内でJSもインポートする。
import "./component-name.css";
import "./component-name.js";
import playground from "./playground.html?raw";
テスト
テストは3種類に分かれる。
- VRT(Visual Regression Test):
.vrt.js — Playwrightで実行。リセットCSS適用時にコンポーネントの見た目が変わらないことを検証する
- 機能テスト:
.test.js — Vitest browser modeで実行。JSを含むインタラクティブなコンポーネントの動作を検証する
- ユニットテスト:
.unit.js — Vitest(jsdom)で実行。JSからエクスポートされた純粋関数のロジックを検証する
VRT(.vrt.js)
基本形(CSS-onlyコンポーネント)
import path from "node:path";
import { resetCssVrt } from "../../../tests/helpers/reset-css-vrt";
const { dirname } = import.meta;
resetCssVrt("component-name", path.join(dirname, "playground.html"));
複数のHTMLをテスト
各HTMLファイルに対して resetCssVrt を呼び出す。
import path from "node:path";
import { resetCssVrt } from "../../../tests/helpers/reset-css-vrt";
const { dirname } = import.meta;
resetCssVrt(
"input-text-playground",
path.join(dirname, "playground.html"),
);
resetCssVrt(
"input-text-with-form-control-label",
path.join(dirname, "with-form-control-label.html"),
);
ignoreElementsオプション
テスト結果に影響を与えるが本質的な差異ではない要素(例: アコーディオンの開閉コンテンツ)をVRTテストから除外する。
resetCssVrt("stacked", path.join(dirname, "stacked.html"), {
ignoreElements: [".dads-accordion__content"],
});
resetCssVrtの仕組み
resetCssVrt は以下のテストを自動生成する:
- Normalize.css適用時の表示に変化がないこと
- Bootstrap Reboot適用時の表示に変化がないこと
- Tailwind Preflight適用時の表示に変化がないこと
- Eric Meyer's Reset CSS適用時の表示に変化がないこと
- kiso.css適用時の表示に変化がないこと
- 継承プロパティやグローバルスタイルが定義済みの時の表示に変化がないこと
各テスト内でベースライン(リセットCSS未適用)のスクリーンショットを取得し、リセットCSS適用後のスクリーンショットと pixelmatch で比較する。スナップショットファイルは生成されないため、--update-snapshots は不要。
テスト名
resetCssVrt の第1引数はテスト名。コンポーネント内で一意にする。
機能テスト(.test.js)
JSを含むインタラクティブなコンポーネントでは、Vitest browser modeで機能テストを記述する。
設計原則
- 決定的なテスト:
new Date() 等の現在時刻に依存する処理がある場合、vi.useFakeTimers({ toFake: ["Date"] }) で時刻を固定し、実行日によって結果が変わらないようにする
- 具体的なアサーション: 「変わったこと」ではなく「何に変わったか」を検証する。
not.toBe(initialValue) ではなく toBe("期待値") を使う
- playground.html から独立したHTML: テスト用のHTMLをテストファイル内にインラインで定義し、playground.html のデモ用変更がテストに影響しないようにする。必要な
data-js-* 属性・ARIA属性・構造のみを含む最小限のHTMLにする
- 1テスト1振る舞い: 各テストは1つのアクションと、それに対する具体的な期待結果を検証する
- 不変条件の検証: 「tabindex="0" のボタンが常に1つだけ存在する」のような不変条件を、状態変更のたびに検証する
基本構造
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import "./component-name.js";
const FAKE_NOW = new Date(2025, 5, 15, 12, 0, 0);
const componentHTML = (extraAttrs = "") => `
<dads-component class="dads-component" ${extraAttrs}>
<button data-js-trigger-button>開く</button>
<div data-js-content>コンテンツ</div>
</dads-component>`;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(FAKE_NOW);
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
const mountComponent = async (options = {}) => {
const attrs = [];
if (options.minDate) attrs.push(`min-date="${options.minDate}"`);
if (options.maxDate) attrs.push(`max-date="${options.maxDate}"`);
document.body.innerHTML = componentHTML(attrs.join(" "));
return document.querySelector("dads-component");
};
const enabledButtons = () =>
[...document.querySelectorAll("[data-js-button]:not(:disabled)")];
const selectedButton = () =>
document.querySelector('[data-selected="true"]');
describe("ComponentName", () => {
test("ボタンクリックで開閉する", async () => {
await mountComponent();
await page.getByRole("button", { name: "開く" }).click();
await expect
.element(page.getByRole("button", { name: "開く" }))
.toHaveAttribute("aria-expanded", "true");
});
});
時刻固定(vi.useFakeTimers)
new Date() に依存するコンポーネント(カレンダー等)では、テスト結果が実行日によって変わる問題がある。vi.useFakeTimers({ toFake: ["Date"] }) を使い、Date コンストラクタのみをフェイクにする。setTimeout や requestAnimationFrame はそのまま動作する。
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2025, 5, 15, 12, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
注意:
toFake: ["Date"] を指定しないと setTimeout もフェイクになり、await new Promise(resolve => setTimeout(resolve, 0)) が永遠に解決しなくなる
afterEach で vi.useRealTimers() を必ず呼び、他のテストに影響しないようにする
- テストファイルの冒頭に、固定した日付でのカレンダー等の想定状態をコメントで書いておくと可読性が上がる
インラインHTMLによるセットアップ
テスト用HTMLをテストファイル内に文字列として定義し、playground.html への依存を排除する。
const calendarHTML = (extraAttrs = "") => `
<dads-calendar class="dads-calendar" role="application" ${extraAttrs}>
<!-- data-js-* 属性とARIA属性を含む最小限の構造 -->
</dads-calendar>`;
const mountCalendar = async (options = {}) => {
const attrs = [];
if (options.minDate) attrs.push(`min-date="${options.minDate}"`);
if (options.maxDate) attrs.push(`max-date="${options.maxDate}"`);
document.body.innerHTML = calendarHTML(attrs.join(" "));
return document.querySelector("dads-calendar");
};
ポイント:
- 属性は
innerHTML に直接埋め込むことで、connectedCallback → attributeChangedCallback の二重初期化を避ける
- CSS クラスやSVG の装飾的属性は、JS の動作に影響しない限り省略してよい
要素の取得方法
- セマンティッククエリ(Vitest locator): ロール・ラベルで取得できる要素に使用する。テスト内で変数に入れて使い回せる(locatorは毎回最新のDOMを参照する)
page.getByRole("button", { name: "送信" })
page.getByRole("combobox", { name: "年" })
page.getByRole("menuitem", { name: "項目1" })
page.getByText("メッセージ")
document.querySelector: data-js-* 属性やCSSセレクタでの取得が必要な場合に使用する。Vitest browser modeには page.locator() のようなCSSセレクタlocatorは存在しない
- DOMヘルパー関数: テスト全体で繰り返し使うクエリは、ファイル上部にヘルパー関数として定義する
const opener = page.getByRole("button", { name: "メニュー" });
await opener.click();
await expect.element(opener).toHaveAttribute("aria-expanded", "true");
const currentMonth = document.querySelector("[data-js-current-month]");
expect(currentMonth.textContent).toBe("6月");
const enabledButtons = () =>
[...document.querySelectorAll("[data-js-date-button]:not(:disabled)")];
const buttonFor = (day) =>
enabledButtons().find((b) => b.textContent === String(day));
const selectedButton = () =>
document.querySelector('[data-selected="true"]');
アサーション
expect.element(locator): locatorに対するリトライ付きアサーション(非同期DOM更新の待機が必要な場合)
expect(value): 同期的な値の検証(即座に反映されるDOM変更の場合)
- 具体的な期待値を使う:
not.toBe(initialValue) ではなく toBe("5月") のように、何に変わるかを明示する
await page.getByRole("button", { name: "前の月" }).click();
expect(currentMonth()).toBe("5月");
const initial = currentMonth();
await page.getByRole("button", { name: "前の月" }).click();
expect(currentMonth()).not.toBe(initial);
const date = handler.mock.calls[0][0].detail.date;
expect(date.getFullYear()).toBe(2025);
expect(date.getMonth()).toBe(5);
expect(date.getDate()).toBe(20);
expect(handler.mock.calls[0][0].detail.date).toBeTruthy();
ユーザー操作
userEvent(CDPベースの実キーストローク)を使用する。
import { userEvent } from "vitest/browser";
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{ }");
await opener.click();
await userEvent.click(dateButton);
await userEvent.tab();
await userEvent.tab({ shift: true });
注意:
- Playwright locator の
click() は aria-disabled="true" の要素でタイムアウトする(enabled になるのを待つため)。disabled 状態でのクリックをテストする場合は element.click() を使う
- カレンダーなどDOM再描画が行われるコンポーネントでは、クリック後にボタン参照が古くなる。再描画後は
document.querySelector で再取得する
イベントハンドラの検証
vi.fn() を使用し、イベントの detail の具体的な値まで検証する。
const handler = vi.fn();
cal().addEventListener("date-selected", handler);
await userEvent.click(buttonFor(20));
expect(handler).toHaveBeenCalledOnce();
const date = handler.mock.calls[0][0].detail.date;
expect(date.getFullYear()).toBe(2025);
expect(date.getMonth()).toBe(5);
expect(date.getDate()).toBe(20);
テストのカテゴリ
機能テストでは以下のカテゴリを網羅的にカバーする。
| カテゴリ | 検証内容の例 |
|---|
| 初期表示 | デフォルト状態での表示内容、属性値、範囲 |
| 描画 | 要素の数、enabled/disabled状態、行数 |
| ユーザー操作 | クリック、キーボードナビゲーション、選択/解除 |
| イベント | CustomEvent の発火、detail の値、bubbles |
| ナビゲーション制約 | 範囲外への移動が無視されること |
| 外部API | public メソッドの正常系・異常系 |
| 属性の動的変更 | attributeChangedCallback による再描画 |
| アクセシビリティ | aria-label、aria-selected、tabindex管理、ライブリージョン |
| エッジケース | 無効な入力、境界値、うるう年、DOM再接続 |
ユニットテスト(.unit.js)
JSからエクスポートされた純粋関数(DOM操作を伴わないロジック)を検証する。jsdom環境で実行される。
import { describe, test, expect } from "vitest";
import { parseSize, formatSize } from "./component-name.js";
describe("parseSize", () => {
test("MB単位を正しくパースするべき", () => {
expect(parseSize("10MB")).toBe(10 * 1024 * 1024);
});
test("不正な文字列の場合はnullを返すべき", () => {
expect(parseSize("abc")).toBe(null);
});
});
アクセシビリティ
目標
WCAG 2.2のレベルAおよびレベルAA達成基準を全て満たすことを目標とする。
実装上の注意
- ネイティブHTML要素の機能を優先する(ARIAより)
- 必要最小限のARIA属性を使用する
- アイコンには適切な
aria-hidden="true" または role="img" + aria-label を付与する
- フォームコントロールには必ずラベルを関連付ける
- 強制カラーモード(
forced-colors: active)に対応する
- 視覚効果低減モード(
prefers-reduced-motion: reduce)に対応する
- キーボード操作を確保する
- スクリーンリーダーでの動作を確認する
SVGアイコンのアクセシビリティ
<svg aria-hidden="true">...</svg>
<svg role="img" aria-label="新規タブで開きます">...</svg>
開発コマンド
npm run storybook
npm test
npm run test:vrt
npx playwright test src/components/<component-name>/<component-name>.vrt.js
npm run test:browser
npx vitest run --project browser src/components/<component-name>/<component-name>.test.js
npx vitest run --project unit
npx vitest run --project unit src/components/<component-name>/<component-name>.unit.js
npx @biomejs/biome format --write src/components/<component-name>/
チェックリスト
コンポーネント開発時に確認すること:
CSS
HTML
Stories
テスト
JavaScript(該当する場合)
フォーマット