Skip to content

Instantly share code, notes, and snippets.

@x7ddf74479jn5
Last active July 17, 2023 01:32
Show Gist options
  • Save x7ddf74479jn5/4dd0b46ae33a0ce25e3eaee64cce83f1 to your computer and use it in GitHub Desktop.
Save x7ddf74479jn5/4dd0b46ae33a0ce25e3eaee64cce83f1 to your computer and use it in GitHub Desktop.
Radix UI & Tailwind CSSでUIライブラリを作る

Radix UI & Tailwind CSS で UI ライブラリを作る

概要

モチベーション

ヘッドレス UI と Tailwind CSS で作る UI ライブラリの実装について 2023 年時点でのベストプラクティを考察したい。基本コンセプトはshadcn/uiを参考にした。

機能:プロダクトに共通 UI コンポーネントライブラリとして提供する想定。関連プロダクトごとのイメージに合わせてテーマを変更したいという要件にも応える。Tailwind CSS のスタイルを上書きできる 。

制約:基本となる依存ライブラリは React, Radix UI, Tailwind CSS。利用側で作成したコンポーネントライブラリを使うためには React と Tailwind CSS を使うときに入れるやつ(autoprefixer, postcss, tailwindcss)への依存に加え、独自のセマンティックカラーシステムを構成する Tailwind CSS Plugin が必要。

成果物

スタック

環境

  • 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

Tailwind プラグインの作成

ワークスペースにpackages/tw-pluginを追加する。

psui/packages/tw-plugin at main · x7ddf74479jn5/psui · GitHub

UI ライブラリに独自のカラーシステムを付け加えるために必要。例では@x7ddf74479jn5/tw-plugin-psui というライブラリ名で作成した。以下のように設定することでtext-basebg-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 のテンプレートを選択する。

tailwind.config.jsの設定

上で作った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の設定

global.cssなどのファイルに使いたいカラーを登録する。このときhslrgbを使う。テーマの切り替えは 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 の初期化

Storybook を初期化する。Vite 環境を選ぶ。

npx storybook@latest init

スキャフォルドされたファイルを全削除。Storybook を見ながら開発するのでこのようなディレクトリ構成で作っていく。

Button
├── Button.stories.tsx
├── Button.tsx
└── index.ts

スタイリングヘルパー

スタイリングに一貫性を持たせ記述を簡略化するためtw-mergeclsxを使う。

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'"
  },

完全なpackage.json

pnpm build

これで/dist以下にビルドファイルが出力される。

npm に公開

まず npm のアカウントを作り認証を通しておく。

NPM に自分のライブラリを配布する - 自分が作った Javascript ライブラリを NPM へ配布する方法について説明します。

package.jsonには npm に公開するための情報を追記する。

package.json の設定例

以上を適切に設定した上でpnpm publishで npm に公開できる。

Storybook の公開

Publish Storybook

好きなホスティングサービスにデプロイする。

リグレッションテスト

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

スナップショットテスト

Snapshot tests

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 の併用が選択肢になる。

VRT

Visual tests

Storybook 公式では Chromatic が紹介されている。予算的に OK ならこれが一番簡単だろう。他には、設定が難しいが、Storycap や Playwright でスクリーンショットを取って reg-suit で差分比較する方法がある。

ユニットテスト

Test runner

@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 をエミュレートした環境で高速にユニットテストができる。

Import stories in tests

テスト実行には事前にビルド済みの 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\""
  }
}

a11y テスト

Accessibility tests

@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,
      },
    });
  },
};

インタラクションテスト

Interaction tests

@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@provider.com");

    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 の中でアサーションを行わずテストファイルの中でアサーションを記述する。

Import stories in tests

// 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 補足

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)

リンク

Footnotes

  1. 例えばTransition - Headless UIは、Transition を宣言的に構築できるのでオススメ。

  2. CSS-in-TS のひとつのStitchesに影響を受けており、型安全でシステマチックにスタイリングできる。TS ベースなので利用側ではもちろん補完が利く。

{
"name": "@x7ddf74479jn5/psui",
"version": "0.1.0",
"description": "An UI component library composed with Tailwind CSS and Radix-UI",
"author": {
"name": "x7ddf74479jn5",
"url": "https://github.com/x7ddf74479jn5"
},
"repository": {
"type": "git",
"url": "https://github.com/x7ddf74479jn5/psui.git",
"directory": "packages/components"
},
"bugs": {
"url": "https://github.com/x7ddf74479jn5/psui/issues"
},
"keywords": [
"components",
"ui",
"tailwind",
"radix-ui"
],
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"license": "MIT",
"sideEffects": [
"*.css"
],
"files": [
"dist",
"src",
"!src/**/*.stories.tsx"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && vite build && pnpm cpx",
"dev": "vite --watch",
"cpx": "npx cpx -C 'src/themes/*.css' 'dist/themes'",
"lint": "eslint \"src/*.{ts,tsx}\" --cache",
"lint:fix": "eslint --fix \"src/*.{ts,tsx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@storybook/addon-a11y": "7.0.24",
"@storybook/addon-essentials": "^7.0.24",
"@storybook/addon-interactions": "^7.0.24",
"@storybook/addon-links": "^7.0.24",
"@storybook/blocks": "^7.0.24",
"@storybook/react": "^7.0.24",
"@storybook/react-vite": "^7.0.24",
"@storybook/testing-library": "^0.2.0",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "4.0.3",
"@x7ddf74479jn5/tw-plugin-psui": "workspace:*",
"autoprefixer": "10.4.14",
"eslint": "^8.44.0",
"eslint-config-psui": "workspace:*",
"postcss": "8.4.24",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.0.24",
"tailwind-config": "workspace:*",
"tailwindcss": "3.3.2",
"tsconfig": "workspace:*",
"typescript": "^5.1.6",
"vite": "4.3.9"
},
"dependencies": {
"@radix-ui/react-accordion": "1.1.2",
"@radix-ui/react-alert-dialog": "1.0.4",
"@radix-ui/react-aspect-ratio": "1.0.3",
"@radix-ui/react-avatar": "1.0.3",
"@radix-ui/react-checkbox": "1.0.4",
"@radix-ui/react-collapsible": "1.0.3",
"@radix-ui/react-context-menu": "2.1.4",
"@radix-ui/react-dialog": "1.0.4",
"@radix-ui/react-dropdown-menu": "2.0.5",
"@radix-ui/react-hover-card": "1.0.6",
"@radix-ui/react-icons": "1.3.0",
"@radix-ui/react-label": "2.0.2",
"@radix-ui/react-menubar": "1.0.3",
"@radix-ui/react-navigation-menu": "1.1.3",
"@radix-ui/react-popover": "1.0.6",
"@radix-ui/react-progress": "1.0.3",
"@radix-ui/react-radio-group": "1.1.3",
"@radix-ui/react-scroll-area": "1.0.4",
"@radix-ui/react-select": "1.2.2",
"@radix-ui/react-separator": "1.0.3",
"@radix-ui/react-slider": "1.1.2",
"@radix-ui/react-switch": "1.0.3",
"@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-toggle": "1.0.3",
"@radix-ui/react-tooltip": "1.0.6",
"class-variance-authority": "0.6.1",
"clsx": "1.2.1",
"lucide-react": "0.257.0",
"tailwind-merge": "1.13.2"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment