بنقرة واحدة
develop-component
コンポーネントを新規作成・更新する。CSS、HTML、Storybook Stories、テスト、JavaScript(Custom Elements)の実装を含む。
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
コンポーネントを新規作成・更新する。CSS、HTML、Storybook Stories、テスト、JavaScript(Custom Elements)の実装を含む。
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
| 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/global.cssを確認: 使用可能なデザイントークン(カラー、フォント、エレベーション)とユーティリティクラスを把握するBEMをベースにした命名規則を使用する。
.dads-<block-name>__<element-name>[data-<modifier-name>="<value>"]
規則:
dads- を必ず付ける__--)ではなくdata属性を使用[data-disabled])[aria-invalid="true"]、[aria-disabled="true"])/* DO: data属性でModifier */
.dads-button[data-type="solid-fill"] { }
.dads-button[data-size="lg"] { }
/* DO: ARIAステートでスタイリング */
.dads-button[aria-disabled="true"] { }
.dads-input-text__input[aria-invalid="true"] { }
/* DON'T: BEMのModifier記法 */
.dads-button_solid-fill { }
原則として、rem単位を基本とし、px単位は使用しない。calc(px / 16 * 1rem) のパターンを必ず使用し、px値から計算する。ただし、border-width にはpx単位を使用してもよい(境界線として使用する場合はpx単位、オブジェクトを構成する一要素として使用する場合はrem単位の使用を推奨)。
/* フォントサイズ・余白・サイズ: rem(calc(px / 16 * 1rem)パターン) */
.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);
}
/* 線の太さ: px */
.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;
}
全てのプロパティが毎回必要なわけではなく、コンポーネントの性質に応じて適切なものを選択する。
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);
}
UAスタイルシートやリセットCSSによって異なるmarginが適用される可能性がある要素(<hr>、<p>、<ul>等)は margin: 0 でリセットする。
.dads-divider {
margin: 0;
/* ... */
}
:where()の多用等)は避ける/* DO: 直感的で読みやすい */
.dads-button[data-type="solid-fill"]:hover {
background-color: var(--button-hover-color);
}
/* DON'T: 詳細度を下げるための過剰な :where() */
.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) /* Noto Sans JP */
var(--font-family-mono) /* Noto Sans Mono */
/* エレベーション */
var(--elevation-1) 〜 var(--elevation-5)
コンポーネント内で再利用する値は、コンポーネントスコープの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);
}
/* パブリック: 利用者がテーマカラーを変更できるAPI */
.dads-button {
--button-color: var(--color-key-900);
--button-hover-color: var(--color-key-1000);
}
/* DON'T: 全プロパティをCustom Propertiesに間接化 */
.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のみで記述する。Sass、Less等は使用しない。
全ての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>
<!-- コンポーネントのHTML -->
</body>
</html>
注意:
<link> で読み込む(例: <link rel="stylesheet" href="../button/button.css">)<script type="module" src="<component-name>.js"></script> を </body> の前に配置するStorybookの Playground ストーリーで使用する基本的なHTMLファイル。コンポーネントの代表的な構成を1つだけ含む。
<body>
<button class="dads-button" data-size="md" data-type="solid-fill">ボタン</button>
</body>
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のコントロールで対応JavaScriptやCSSにインラインで言語依存情報を含めず、HTMLのdata属性で言語情報を提供する。
作例(file-upload):
<!-- エラーメッセージをdata属性で提供 -->
<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>
// JavaScript側ではdata属性からメッセージを取得
#getMessage(category, key, variables = {}) {
const datasetKey = `${category}${key.charAt(0).toUpperCase()}${key.slice(1)}`;
let template = this.dataset[datasetKey];
// data属性になければデフォルトメッセージを使用
if (!template) {
template = FileUpload.defaultMessages[category]?.[key] || "";
}
return template.replace(/\{(\w+)\}/g, (match, variable) => {
return variables[variable] !== undefined ? variables[variable] : match;
});
}
ポイント:
data-*属性でメッセージを上書きできるようにする{count}、{max}等)で動的な値を埋め込み可能にするインタラクティブなコンポーネントでは 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) {
// ...
}
// DOM要素へのアクセスはgetterで定義
get #triggerButton() {
return this.querySelector("[data-js-trigger-button]");
}
get #someInput() {
return this.querySelector("[data-js-input]");
}
}
customElements.define("dads-component-name", ComponentName);
AbortControllerのsignalでイベントリスナーを管理する: addEventListenerの第3引数に{ signal }を渡すことで、abort()呼び出し時に自動的にリスナーが解除されるdisconnectedCallbackでabort()を呼ぶ: メモリリークを防止するdata-js-* 属性をセレクタに使用するCustomEventを使用する: detailプロパティでデータを伝搬する(例: new CustomEvent("date-selected", { detail: { date }, bubbles: true }))export class でクラスをエクスポートする#を使用する: #abort、#timers 等Custom Elementの公開APIは最小限に絞り、内部実装は # でprivate化する。
以下のみを公開APIとして扱う。
connectedCallback、disconnectedCallback、attributeChangedCallbackstatic observedAttributeselement.method() として呼び出されるfocus() のようにHTMLElement標準メソッドのオーバーライドCalendar.setSelectedDate()/setDisplayMonth()/focus() → DatePickerから呼ばれるCarouselStepNav.setSelectedIndex() → Carouselから呼ばれるFileUpload.files(ファイル一覧)、FileUpload.errors(エラー一覧)FileUpload.addFiles()、removeFile()(プログラムからのファイル操作)FileUpload.defaultMessages(デフォルトメッセージの上書き)# プレフィックス)以下はすべて # でprivate化する。
#handleClick、#handleKeydown 等#setupEventListeners、#renderCalendar、#update 等#navigateToDate、#validateFiles、#formatJapaneseYear 等#calendarTable、#fallbackInput、#opener 等#isPreviousMonthAvailable、#isOpen、#currentIndex 等「クラスの外から呼ぶ必要があるか?」「外部から使用できることでコンポーネントの有用性が高まるか?」を問う。クラス内部だけで使われるものはすべてprivate。
// ✅ public: DatePickerから this.calendar.setSelectedDate(date) と呼ばれる
setSelectedDate(date) { ... }
// ✅ public: 標準APIのオーバーライド(DatePickerから this.calendar.focus() と呼ばれる)
focus() { ... }
// ❌ private: イベントリスナー内で this.#renderCalendar() と呼ぶだけ
#renderCalendar() { ... }
// ❌ private: 同クラスの別インスタンスから呼ぶ場合もprivate化可能
// (JSではprivateフィールドは同クラスの他インスタンスからもアクセス可能)
#setExpandedDropArea(expanded) { ... }
// → activeExpandedComponent.#setExpandedDropArea(false) と呼べる
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]");
}
原則として使用しない。使用する場合は以下を満たす必要がある:
将来的に標準化が見込まれるAPIのpolyfillは使用できる。
import type { Meta, StoryObj } from "@storybook/html-vite";
import { HtmlFragment } from "../../helpers/html-fragment";
// CSSインポート(Storybookでのスタイル適用用)
import "./component-name.css";
// 依存コンポーネントのCSSもインポート
import "../button/button.css";
// HTMLファイルのインポート(?rawで文字列として取得)
import playground from "./playground.html?raw";
import withFormControlLabel from "./with-form-control-label.html?raw";
const meta = {
title: "Components/コンポーネント名(日本語)",
} satisfies Meta;
export default meta;
title: "Components/<日本語コンポーネント名>" の形式(例: "Components/ボタン"、"Components/インプットテキスト")component、decorators等)は原則不要インタラクティブなコントロールを持つメインのストーリー。
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();
// data属性の設定
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 はHTMLファイルから特定のセレクタに一致する要素を抽出するユーティリティ。
// 第1引数: HTMLファイルの文字列(?rawインポート)
// 第2引数: 抽出するセレクタ(省略可)
const fragment = new HtmlFragment(playground, ".dads-component");
// rootプロパティで抽出した要素にアクセス
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 > 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();
JavaScriptを使用するコンポーネントでは、Storiesファイル内でJSもインポートする。
import "./component-name.css";
import "./component-name.js"; // Custom Elementを登録
import playground from "./playground.html?raw";
テストは3種類に分かれる。
.vrt.js — Playwrightで実行。リセットCSS適用時にコンポーネントの見た目が変わらないことを検証する.test.js — Vitest browser modeで実行。JSを含むインタラクティブなコンポーネントの動作を検証する.unit.js — Vitest(jsdom)で実行。JSからエクスポートされた純粋関数のロジックを検証する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ファイルに対して 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"),
);
テスト結果に影響を与えるが本質的な差異ではない要素(例: アコーディオンの開閉コンテンツ)をVRTテストから除外する。
resetCssVrt("stacked", path.join(dirname, "stacked.html"), {
ignoreElements: [".dads-accordion__content"],
});
resetCssVrt は以下のテストを自動生成する:
各テスト内でベースライン(リセットCSS未適用)のスクリーンショットを取得し、リセットCSS適用後のスクリーンショットと pixelmatch で比較する。スナップショットファイルは生成されないため、--update-snapshots は不要。
resetCssVrt の第1引数はテスト名。コンポーネント内で一意にする。
JSを含むインタラクティブなコンポーネントでは、Vitest browser modeで機能テストを記述する。
new Date() 等の現在時刻に依存する処理がある場合、vi.useFakeTimers({ toFake: ["Date"] }) で時刻を固定し、実行日によって結果が変わらないようにするnot.toBe(initialValue) ではなく toBe("期待値") を使うdata-js-* 属性・ARIA属性・構造のみを含む最小限のHTMLにする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);
// テスト用の最小限のHTML(playground.html から独立)
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 = "";
});
/**
* コンポーネントをDOMにマウントして返す。
* 属性はHTML文字列に直接埋め込み、connectedCallback で1回だけ初期化させる。
*/
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");
};
// DOM helpers — テスト対象のコンポーネントに合わせて定義
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)); // 2025-06-15 に固定
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
注意:
toFake: ["Date"] を指定しないと setTimeout もフェイクになり、await new Promise(resolve => setTimeout(resolve, 0)) が永遠に解決しなくなるafterEach で vi.useRealTimers() を必ず呼び、他のテストに影響しないようにするテスト用HTMLをテストファイル内に文字列として定義し、playground.html への依存を排除する。
// HTMLを関数にして、属性を動的に埋め込めるようにする
const calendarHTML = (extraAttrs = "") => `
<dads-calendar class="dads-calendar" role="application" ${extraAttrs}>
<!-- data-js-* 属性とARIA属性を含む最小限の構造 -->
</dads-calendar>`;
const mountCalendar = async (options = {}) => {
// 属性はHTML文字列に埋め込み、connectedCallback の1回の初期化で反映させる
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 の二重初期化を避ける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は存在しない// セマンティッククエリ — locatorを変数に入れて使い回す
const opener = page.getByRole("button", { name: "メニュー" });
await opener.click();
await expect.element(opener).toHaveAttribute("aria-expanded", "true");
// DOM直接参照 — data-js-* 属性や内部状態の検証
const currentMonth = document.querySelector("[data-js-current-month]");
expect(currentMonth.textContent).toBe("6月");
// DOMヘルパー — 繰り返し使うクエリを関数化
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月") のように、何に変わるかを明示する// DO: 具体的な期待値
await page.getByRole("button", { name: "前の月" }).click();
expect(currentMonth()).toBe("5月");
// DON'T: 「変わった」だけの検証
const initial = currentMonth();
await page.getByRole("button", { name: "前の月" }).click();
expect(currentMonth()).not.toBe(initial);
// DO: イベント detail の具体的な値を検証
const date = handler.mock.calls[0][0].detail.date;
expect(date.getFullYear()).toBe(2025);
expect(date.getMonth()).toBe(5);
expect(date.getDate()).toBe(20);
// DON'T: detail が存在するかだけの検証
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("{ }"); // Spaceキー
// クリック
await opener.click(); // locator経由
await userEvent.click(dateButton); // DOM要素直接
// タブ
await userEvent.tab();
await userEvent.tab({ shift: true });
注意:
click() は aria-disabled="true" の要素でタイムアウトする(enabled になるのを待つため)。disabled 状態でのクリックをテストする場合は element.click() を使う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再接続 |
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達成基準を全て満たすことを目標とする。
aria-hidden="true" または role="img" + aria-label を付与するforced-colors: active)に対応するprefers-reduced-motion: reduce)に対応する<!-- 装飾的アイコン(テキストラベルと併用) -->
<svg aria-hidden="true">...</svg>
<!-- 意味を持つアイコン -->
<svg role="img" aria-label="新規タブで開きます">...</svg>
# Storybookの起動(開発サーバー)
npm run storybook
# 全テストの実行(Vitest + Playwright VRT)
npm test
# VRTテストのみ実行(Playwright)
npm run test:vrt
# 特定のコンポーネントのVRTテスト
npx playwright test src/components/<component-name>/<component-name>.vrt.js
# 全機能テストの実行(Vitest browser mode)
npm run test:browser
# 特定のコンポーネントの機能テスト
npx vitest run --project browser src/components/<component-name>/<component-name>.test.js
# ユニットテストの実行(Vitest jsdom)
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>/
コンポーネント開発時に確認すること:
dads- プレフィックスを使用しているcalc(px / 16 * 1rem) パターンを使用している(borderの px を除く)box-sizing: border-box を必要な要素(border/paddingとwidth/heightが共存)に指定しているdisabled と aria-disabled の両方で対応している@media (hover: hover) で囲んでいるforced-colors: active)に対応しているplayground.html がHTMLテンプレートに従っているglobal.css を読み込んでいるlang="ja" を指定しているMeta の title が "Components/<日本語名>" の形式?raw 付きでインポートしているHtmlFragment を正しく使用しているargTypes でコントロール可能なバリエーションを定義しているrender 関数でHTMLを正しく操作しているresetCssVrt で全HTMLのVRTテストを .vrt.js ファイルに定義しているignoreElements オプションを使用している.test.js ファイルにVitest browser modeで機能テストを記述しているnew Date() 等に依存する場合は vi.useFakeTimers({ toFake: ["Date"] }) で時刻を固定しているdetail の具体的な値まで検証しているpage.getByRole 等のセマンティッククエリを優先し、data-js-* 属性には document.querySelector を使用している.unit.js ファイルにユニットテストを記述しているextends HTMLElement)を使用しているAbortControllerのsignalでイベントリスナーを管理しているdisconnectedCallbackでabort()を呼んでいるdata-js-* 属性をセレクタに使用しているcustomElements.define でカスタム要素を登録している# でprivate化しているnpx @biomejs/biome format --write src/components/<component-name>/ を実行してBiomeでフォーマットしている