ヘッドレス UI と Tailwind CSS で作る UI ライブラリの実装について 2023 年時点でのベストプラクティを考察したい。基本コンセプトはshadcn/uiを参考にした。
機能:プロダクトに共通 UI コンポーネントライブラリとして提供する想定。関連プロダクトごとのイメージに合わせてテーマを変更したいという要件にも応える。Tailwind CSS のスタイルを上書きできる 。
制約:基本となる依存ライブラリは React, Radix UI, Tailwind CSS。利用側で作成したコンポーネントライブラリを使うためには React と Tailwind CSS を使うときに入れるやつ(autoprefixer, postcss, tailwindcss)への依存に加え、独自のセマンティックカラーシステムを構成する Tailwind CSS Plugin が必要。
- Introduction – Radix UI
- Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.
- node: 18.14.1
- react: 18.2.0
- react-dom: 18.2.0
- storybook: 7.0.24
- tailwindcss: 3.3.2
カラー以外のデザイントークンは Tailwind CSS と Radix UI に準拠。関連ライブラリも提供するためモノレポ構成にする。
作るパッケージ
- @x7ddf74479jn5/psui: UI コンポーネントの本体
- @x7ddf74479jn5/tw-plugin-psui: セマンティックカラーシステムを適用するためのプラグイン
これ以降、具体的な実装方法について記述する。
モノレポの構成や各ツールの設定は省くのでレポジトリを参照。
x7ddf74479jn5/psui: An UI Component Library composed of Radix UI & Tailwind CSS
ワークスペースにpackages/tw-plugin
を追加する。
psui/packages/tw-plugin at main · x7ddf74479jn5/psui · GitHub
UI ライブラリに独自のカラーシステムを付け加えるために必要。例では@x7ddf74479jn5/tw-plugin-psui というライブラリ名で作成した。以下のように設定することでtext-base
やbg-primary
といった好きなユーティリティを追加できる。ここで使われている css 変数は定義する場所は、後述のコンポーネントパッケージや UI ライブラリの利用側のグローバルな CSS ファイルを想定している。
const plugin = require("tailwindcss/plugin");
module.exports = plugin(
function ({ matchUtilities, theme }) {
matchUtilities(
{
color: (value) => ({
color: value,
}),
},
{ values: theme("color") },
);
},
{
theme: {
extend: {
colors: {
base: "var(--base)",
primary: "var(--primary)",
secondary: "var(--secondary)",
// ...
},
},
},
},
);
利用するもの
- React
- Vite: Storybook のビルドとライブラリのビルド
- Storybook: コンポーネントカタログ兼開発時のプレビュー
packages/components
以下で作業。
pnpm create vite .
React のテンプレートを選択する。
上で作ったpackages/tw-plugin
(例のレポジトリでは@x7ddf74479jn5/tw-plugin-psui)をplugins
に設定する。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "./docs/**/*.{js,ts,jsx,tsx,mdx}"],
plugins: [require("@x7ddf74479jn5/tw-plugin-psui")],
};
global.css
などのファイルに使いたいカラーを登録する。このときhsl
やrgb
を使う。テーマの切り替えは html 要素のdata-theme
属性で出し分けるようにする。テーマの切り替えにはnext-themeなどのライブラリを使う。
@layer base {
:root,
html[data-theme="light"] {
--base: hsl(240, 7%, 11%);
--primary: hsl(18, 100%, 51%);
--secondary: hsl(84, 97%, 41%);
...;
}
,
html[data-theme="dark"] {
--base: hsl(240, 7%, 98%);
--primary: hsl(18, 100%, 98%);
--secondary: hsl(84, 97%, 98%);
...;
}
}
Storybook を初期化する。Vite 環境を選ぶ。
npx storybook@latest init
スキャフォルドされたファイルを全削除。Storybook を見ながら開発するのでこのようなディレクトリ構成で作っていく。
Button
├── Button.stories.tsx
├── Button.tsx
└── index.ts
スタイリングに一貫性を持たせ記述を簡略化するためtw-mergeとclsxを使う。
import clsx, { type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
tw-merge は利用側で基本のスタイルを上書きするときにコンフリクトを抑止する。例えばbg-red
の基本スタイルに対してbg-green
で上書きしたい場合、後ろの方で付与したbg-green
のユーティリティを優先させる挙動になる。
const defaultStyle = "bg-red";
const customStyle = "bg-green";
// bg-green
<div className={cn(defaultSytle, customStyle)} />;
コンポーネントと Story ファイルの仮組みをした後、Storybook を立ち上げて調整していく。
pnpm storybook
Radix UIのコンポーネントの中から好きなものを選び、ドキュメントとにらめっこしながら作る。もちろん、Headless UIなど別のヘッドレス UI ライブラリを組み合わせてもいい1。スタイリングには Tailwind CSS のデフォルトのユーティリティと拡張したカラーユーティリティを組み合わせてスタイリングする。
プリミティブな UI コンポーネントには variants を持たせたくなる。Class Variance Authorityを使えばリーダビリティ良く variants が定義できる2。
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../utils";
const buttonVariantsConfig = {
variants: {
variant: {
default: "bg-primary text-primary-content hover:bg-primary-focus",
secondary: "bg-secondary text-secondary-content hover:bg-secondary-focus",
// ...
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-2 rounded-md",
xs: "h-fit py-1 px-2 rounded-md",
lg: "h-11 px-8 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
} as const;
const buttonVariants = cva(
"inline-flex w-fit items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-line focus:ring-offset-2",
buttonVariantsConfig,
);
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, size, variant, ...props }, ref) => {
return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = "Button";
export { Button, buttonVariants, buttonVariantsConfig };
Components / Button - Docs ⋅ Storybook
上記global.css
とほぼ同様の記法でテーマを作成できる。@tailwind
のディレクティブは利用側で設定するので必要ない。例ではsrc/themes
に置いている。
ここでは Vite のライブラリモード + tsc を使う方法で説明する。他のバンドラー(esbuild, parcel, tsup など)を使ってもいい。->tsup でビルドする方法
Vite はプラグインを使わなければ型定義ファイルを出力しないので型定義ファイルの出力は tsc に任せる。ビルド専用の tsconfig を設定し、ビルドスクリプト内で指定する。
vite.config.ts
import { resolve } from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
// ビルドチェーンで型定義ファイルを削除しないように
emptyOutDir: false,
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es", "cjs"],
// distにindex.js, index.mjsを出力
fileName: "index",
},
rollupOptions: {
// peerDependeciesを外す
external: ["react"],
output: {
globals: {
react: "React",
},
},
},
},
});
tsconfig.build.json
{
"extends": "./tsconfig.json",
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "src/**/*.stories.tsx"],
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true
}
}
package.json
"scripts": {
"build": "tsc -p tsconfig.build.json && vite build && pnpm cpx",
// テーマを同梱する
"cpx": "npx cpx -C 'src/themes/*.css' 'dist/themes'"
},
pnpm build
これで/dist
以下にビルドファイルが出力される。
まず npm のアカウントを作り認証を通しておく。
NPM に自分のライブラリを配布する - 自分が作った Javascript ライブラリを NPM へ配布する方法について説明します。
package.json
には npm に公開するための情報を追記する。
以上を適切に設定した上でpnpm publish
で npm に公開できる。
好きなホスティングサービスにデプロイする。
Radix UI に依存しているため Radix UI 側の API 変更でリグレッションの可能性がある。コンポーネント追加やリファクタリングでの保証のためにもリグレッションテストの導入を推奨する。
選択肢はざっと以下の通り。
- スナップショットテスト
- @storybook/addon-storyshots
- Playwright
- VRT
- Chromatic
- Storycap + reg-suit
- Playwright + reg-suit
- ユニットテスト
- @storybook/test-runner
- Jest
- Vitest
- Playwright
- E2E
- Playwright
Storyshots は各 Story に対し Jest のスナップショットテストを行うというもの。DOM 構造の検査しかできないが、Radix UI への追従目的なら一番コストが低い。
// Storyshots.test.ts
import initStoryshots from "@storybook/addon-storyshots";
initStoryshots();
"scripts": {
"storyshots": "jest"
}
@storybook/addon-storyshots は Jest でしか動かないため注意。->Vitest で Storyshots を代替する方法
より丁寧にテストしたいなら VRT と@storybook/test-runner の併用が選択肢になる。
Storybook 公式では Chromatic が紹介されている。予算的に OK ならこれが一番簡単だろう。他には、設定が難しいが、Storycap や Playwright でスクリーンショットを取って reg-suit で差分比較する方法がある。
- 安全安心の開発体験のために Visual Regression Testing はじめました。 - Uzabase for Engineers
- Playwright + reg-suit を使ったビジュアルリグレッションテストを導入する
- GitHub Actions で実行する storycap / reg-suit の高速化 - ROUTE06 Tech Blog
@storybook/test-runner は Jest + Playwright ベースのテスト実行環境を提供する。ヘッドレスブラウザ上で Storybook に対してテストを実施するので、通常のユニットテストで採用されるようなブラウザをエミュレートした環境における Jest や Vitest のテストよりオーバーヘッドが大きい。
@storybook/test-runner 単体では Story のレンダリングがエラーなく実行できるかテストする。a11y テストやインタラクションテストをやりたい場合は、追加の設定が必要になる。
- axe-playwright
- a11y テストを組み込みことができる。
- @storybook/jest
- play function 内でアサーションテストができる。
<Form />
や<Dialog />
のようなインタラクションを伴う複合的なコンポーネントで有効。
@storybook/jest を使わず vitest や jest のテストファイルに Story をインポートすることで DOM をエミュレートした環境で高速にユニットテストができる。
テスト実行には事前にビルド済みの Storybook が必要。
pnpm add --save-dev @storybook/test-runner
ローカルでビルドした Storybook をテストする。
pnpm stroybook
// 別のターミナルで
pnpm test-storybook
"scripts": {
"storybook": "storybook dev -p 6006",
"test-storybook": "test-storybook",
"test-storybook:watch": "test-storybook --watch"
}
CI で実行。
psui/.github/workflows/storybook.yaml at storybook-test · x7ddf74479jn5/psui · GitHub
ワークフロー内でビルドしてテストする方法とデプロイした環境に対してテストする方法が考えられる。GitHub Pages は preview 環境がないためワークフローの実行トリガーによっては後者の方法ができないので注意。
デプロイ済みの Storybook へテストしたいときには--url
フラグもしくはTARGET_URL
変数に指定する。
pnpm run test-storybook --url https://the-storybook-url-here.com
// or
TARGET_URL=https://the-storybook-url-here.com pnpm test-storybook
また、Storybook をビルドしてローカルにサーブしてからテストを実行するパターン。
pnpm add -D concurrently http-server wait-on
{
"scripts": {
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn build-storybook --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && yarn test-storybook\""
}
}
@storybook/addon-a11y と同様の結果になるので、開発時は Storybook を見ながらチェックし、CI でテストを実行するような使い分けになるだろう。以下のように設定することで Story ごとにアクセシビリティチェックを行う。
pnpm add --save-dev axe-playwright
// .storybook/test-runner.js
const { injectAxe, checkA11y } = require("axe-playwright");
module.exports = {
async preRender(page, context) {
await injectAxe(page);
},
async postRender(page, context) {
await checkA11y(page, "#root", {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
},
};
@storybook/test-runner を使う場合はシナリオごとに Story を作成し、その中の play function でアサーションテストを行う。
pnpm add --save-dev @storybook/testing-library @storybook/jest @storybook/addon-interactions
// LoginForm.stories.ts|tsx
import type { Meta, StoryObj } from "@storybook/react";
import { within, userEvent } from "@storybook/testing-library";
import { expect } from "@storybook/jest";
import { LoginForm } from "./LoginForm";
const meta = {
component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const EmptyForm: Story = {};
/*
* See https://storybook.js.org/docs/react/writing-stories/play-function#working-with-the-canvas
* to learn more about using the canvasElement to query the DOM
*/
export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 👇 Simulate interactions with the component
await userEvent.type(canvas.getByTestId("email"), "[email protected]");
await userEvent.type(canvas.getByTestId("password"), "a-random-password");
// See https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
await userEvent.click(canvas.getByRole("button"));
// 👇 Assert DOM structure
await expect(
canvas.getByText("Everything is perfect. Your account is ready and we should probably get you started!"),
).toBeInTheDocument();
},
};
@storybook/test-runner を使わずに他のテストランナーでテストする場合は、play function の中でアサーションを行わずテストファイルの中でアサーションを記述する。
// Form.test.ts|tsx
import { render, fireEvent } from "@testing-library/react";
import { composeStory } from "@storybook/react";
import Meta, { InvalidForm } from "./LoginForm.stories"; //👈 Our stories imported here.
it("Checks if the form is valid", () => {
const ComposedInvalidForm = composeStory(InvalidForm, Meta);
const { getByTestId, getByText } = render(<ComposedInvalidForm />);
fireEvent.click(getByText("Submit"));
const isFormValid = getByTestId("invalid-form");
expect(isFormValid).toBeInTheDocument();
});
Playwright 単体でもローカルやホスティング先の Storybook に E2E テストが可能だ。UI だけのテストならモックも必要ないので Playwright でほとんどのテストケールをカバーできるだろう。そして Story 単位でテストを集約できるというメリットがある。欠点といえば、ヘッドレスブラウザを起動するためどうしてもオーバーヘッドが大きかったり、Stroybook の外側からしか設定ができずに自分で調整する箇所が多くなる。
以下の例ではローカルに立ち上げた Storybook 環境に対してテストしている。
id の決定方法について:Storybook Tips
// e2e/utils.ts
export const storyUrl = (id: string) => `http://localhost:6006/iframe.html?id=${id}`;
// e2e/storybook.spec.ts
import { test, expect } from "@playwright/test";
import { storyUrl } from "./utils";
test.describe("components/ui/Button", () => {
test("initial render", async ({ page }) => {
await page.goto(storyUrl("components-ui--default"));
expect(await page.getByRole('button')).toBeVisible();
});
- 自動リリースフローの構築
- 依存ライブラリの自動更新(renovate, dependabot)
- x7ddf74479jn5/psui: An UI Component Library composed of Radix UI & Tailwind CSS
- PSUI / Storybook
- Introduction – Radix UI
- Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.
Footnotes
-
例えばTransition - Headless UIは、Transition を宣言的に構築できるのでオススメ。 ↩
-
CSS-in-TS のひとつのStitchesに影響を受けており、型安全でシステマチックにスタイリングできる。TS ベースなので利用側ではもちろん補完が利く。 ↩