mit einem Klick
develop-component
// コンポーネントを新規作成・更新する。CSS、HTML、Storybook Stories、テスト、JavaScript(Custom Elements)の実装を含む。
// コンポーネントを新規作成・更新する。CSS、HTML、Storybook Stories、テスト、JavaScript(Custom Elements)の実装を含む。
| 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-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-primitive-blue-900);
--button-hover-color: var(--color-primitive-blue-1000);
}
/* DON'T: 全プロパティをCustom Propertiesに間接化 */
.dads-button {
--dads-button-bg-color: var(--color-primitive-blue-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でフォーマットしている