0. 事前準備

本次挑戰參考 30 天擁有一套自己手刻的 React UI 元件庫 的出版叢書 哎呀!不小心刻了一套 React UI 元件庫 實作。

實作前需要安裝好:

  1. styled-components
  2. storybook
  3. TypeScript(我想練習所以有加)

主題設定

設計 styled 元件時盡量引用 定義好的 theme,
後續加入深色模式或其他進階功能時,才不會因為都是用寫死的值而造成擴充的障礙。

我懶得找顏色所以先參考 daisyUI 的 retro 主題:

// src/styles/theme.ts
// reference daisyUI retro: https://daisyui.com/docs/themes/
export const theme = {
  colors: {
    primary: '#ef9995',
    secondary: '#a4cbb4',
    accent: '#e9b84e',
    neutral: '#2e282a',

    success: '#91c4a8',
    warning: '#e9b84e',
    error: '#ef9995',

    text: {
      primary: '#2e282a',
      secondary: '#635956',
      disabled: '#9ca3af',
    },

    bg: {
      default: '#ece3d3',
      paper: '#e6d7c3',
      muted: '#d8c8b0',
    },

    border: '#d8c8b0',
  },

  spacing: (rate: number) => `${4 * rate}px`,
};

在 App.ts 測試是否可以正確引入主題:

import styled, { ThemeProvider } from 'styled-components';
import { theme } from './styles/theme';

// Card component example
const Card = styled.div`
  background-color: ${({ theme }) => theme.colors.bg.paper};
  border: 1px solid ${({ theme }) => theme.colors.border};
`;

function App() {
  return (
    <div>
      <ThemeProvider theme={theme}>
        <Card>test card</Card>
      </ThemeProvider>
    </div>
  );
}

export default App;

Storybook 設定

書中不會詳細示範怎麼設定 storybook,但在實作元件時又很想看效果,
所以一開始花了一些時間搞懂怎麼設定 storybook。

整合 styled-components

Storybook 本來就不會去吃 ThemeProvider 的設定, 展示 styled 元件時,引用的 theme 變數就會找不到而報錯,
官方的解決方案:# Integrate Styled Components with Storybook

.storybook/preview.ts 加入設定:

// .storybook/preview.ts
import { withThemeFromJSXProvider } from '@storybook/addon-themes';

export const decorators = [
  withThemeFromJSXProvider({
    themes: {
      light: theme,
    },
    defaultTheme: 'light',
    Provider: ThemeProvider,
  }),
];

元件展示

至少要 export 一個 Story 物件,否則會報錯。

以 Button 元件為例:

import { fn } from '@storybook/test';
import { Button } from '../components/Button';

import type { Meta, StoryObj } from '@storybook/react';

const meta = {
  title: 'Inputs/Button',
  component: Button,

  // 產生說明總覽文件
  tags: ['autodocs'],

  parameters: {
    layout: 'centered', // 讓元件在畫面中置中
  },

  // Story 物件的預設值
  args: {
    children: '按鈕',
    onClick: fn(), // 觸發 Storybook 控制面板的 Action
  },

  // 定義哪些 props 可以在 Storybook 控制面板中調整
  argTypes: {
    variant: {
      control: 'select',
      options: ['contained', 'outlined', 'text'],
    },
    themeColor: {
      control: 'text',
    },
    isDisabled: {
      control: 'boolean',
    },
    isLoading: {
      control: 'boolean',
    },
    children: {
      control: 'text',
    },
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof Button>;

export const Default: Story = {
  args: {
    variant: 'contained',
    themeColor: 'primary',
  },
};

export const WithIcon: Story = {
  args: {
    startIcon: '📧',
    variant: 'contained',
    themeColor: 'primary',
  },
};

export const Loading: Story = {
  args: {
    isLoading: true,
    variant: 'contained',
    themeColor: 'primary',
  },
};

export const Variants: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: '1rem' }}>
      <Button variant="contained">Contained</Button>
      <Button variant="outlined">Outlined</Button>
      <Button variant="text">Text</Button>
    </div>
  ),
};

型別

styled-components 為了預防命名衝突,自定義的 props 都要加上 $ 前綴,
而在 styled 元件裡面要引用這些 props 的話必須加上泛型。

原本我想要外層的 index.ts 和內層的 styled 元件都共用同一個型別,
但是 $ 前綴就會影響到上層在使用元件時可能會頻繁看到 $ 的可讀性,
並且不是所有的 props 都會傳入內層做樣式變化的判斷,
硬要引用同一個型別反而還需要考慮「是不是必傳」、extendsOmit 等:

// 外層 Button 的型別
interface ButtonProps {
  variant: 'contained' | 'outlined' | 'text';
  // ...其他一堆屬性
}

// styled 的型別
interface StyledProps extends ButtonProps Omit<ButtonProps, 'variant'> {
  $variant: 'contained' | 'outlined' | 'text';
}

看起來是不是很彆扭?為了符合 $ 前綴的安全性還有是不是必傳的問題,
大費周章地用 Omit 抽掉一樣的 props,然後再重新命名一次… 因此個人覺得外層與內層的型別其實分開寫就可以了,
內層需要什麼 props 就補什麼到型別裡面,寫起來比較乾淨。


慣例

props 的設計與命名盡量不要違反直覺,
那「直覺」的基準是什麼?其實在 JSX 裡面已經提示很多,如 onChangeonClick 等。

UI 互動所觸發的 DOM Event,通常以 on 開頭,
如果屬性代表一個布林值的狀態,通常以 is 開頭…
諸如此類的慣例(conventions)有很多,多看一些 code 應該會很有印象的!

元件要不要做成單個閉合標籤,也取決於需不需要傳入 children 以及使用慣例,
例如設計了一個 Button 元件時,應該會希望它像原生的 button 來做撰寫,
因此文本內容應該當成 children 傳入:

// 用 children 當作文本
<Button>
  我是一個按鈕
</Button>

// 用 props 當作文本
// 這樣設計的元件庫也是有的
<Button
  label="我是一個按鈕"
/>

參考資料


關聯主題:[[Storybook]]