1. Button

基本樣式

使用 styled 做出基本的樣式:

export const StyledButton = styled.button`
  display: flex;
  justify-content: center;
  align-items: center;

  padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)};
  border: none;
  border-radius: 4px;
  outline: none;
  box-sizing: border-box;

  cursor: pointer;

  transition:
    color 0.2s,
    background-color 0.2s,
    border 0.2s,
    opacity 0.2s ease-in-out;

  &:hover {
    opacity: 0.9;
  }

  &:active {
    opacity: 0.7;
  }
`;

接著就可以在外層引入這個基本結構 ,
我習慣用大寫名稱的資料夾 + index.tsx 來包裝元件,
這樣做的好處是可以將 styled.tstypes.ts、文本等等都縮限在資料夾做管理:

interface ButtonProps {
  children?: React.ReactNode;
}

function Button({ children, ...props }: ButtonProps) {
  return (
    <StyledButton type="button">
      <span>{children}</span>
    </StyledButton>
  );
}

變化模式

接下來可以慢慢加入新的 props 做外觀模式的變化,
按鈕的風格預計會有 containedoutlinedtext 三種,
所以個別設定出樣式後,再包裝成物件 variants,用傳入的 $variant 去索引到對應的樣式,
程式碼也會簡潔很多,省去一堆 if、短路、三元運算的判斷:

interface Props {
  $variant?: 'contained' | 'outlined' | 'text';
}

/**
 * variants
 */
const containedStyle = css`
  background-color: ${({ theme }) => theme.colors.primary};
`;

const outlinedStyle = css`
  border: 1px solid ${({ theme }) => theme.colors.primary};

  background-color: transparent;
`;

const textStyle = css`
  border: 1px solid transparent;
  border-radius: 0;

  background-color: transparent;

  &:hover {
    border-bottom: 1px solid ${({ theme }) => theme.colors.primary};
  }
`;

const variants = {
  contained: containedStyle,
  outlined: outlinedStyle,
  text: textStyle,
};

/**
 * 需要用 props 的東西時要引用泛型 Props,上面已經有定義好
 */
export const StyledButton = styled.button<Props>`
  // 省略...

  // 用 $variant
  ${({ $variant }) => variants[$variant || 'contained']}
`;

這樣大致上已經能透過傳入的屬性切換大部分的樣式了!


自訂顏色

除了 ThemeProvider 自訂的顏色關鍵字,還希望可以讓使用者傳入色碼,
因此要設計一個 custom hook 來處理顏色的判斷,
useTheme 的部分就依照先前設計好的 theme 格式去取值即可:

import { useTheme } from 'styled-components';

const checkIsColor = (color: string): boolean => {
  const regex = /(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^)]*\)/gi;
  return regex.test(color);
};

export const useColor = () => {
  const theme = useTheme();

  const getColor = (themeColor: string, isMuted: boolean) => {
    if (isMuted) {
      return theme.colors.text.light;
    }

    if (theme.colors) {
      return theme.colors[themeColor];
    }

    const isLegalColor = checkIsColor(themeColor);

    if (isLegalColor) {
      return themeColor;
    }

    return theme.colors.primary;
  };

  return { getColor };
};

在 Button 中使用 useColor,判斷最終要傳入什麼顏色給 StyledButton

const { getColor } = useColor();
const btnColor = getColor(themeColor, isDisabled);

return (
  <StyledButton type="button" $variant={$variant} $themeColor={btnColor} disabled={isDisabled} {...props}>
    <span>{children}</span>
  </StyledButton>
);

這樣就可以將 styled 裡面目前固定使用的 theme.colors.primary 改為 $themeColor
一樣要引用泛型:

const containedStyle = css<Props>`
  background: ${({ $themeColor }) => $themeColor};

  color: white;
`;

載入狀態

可以找現成的 css 動畫包成 styled 元件,要留意顏色會跟著 variant 改變:

export const LoadingAnimation = styled.div<Props>`
  width: 16px;
  height: 16px;
  border: 2px solid ${({ $variant, $themeColor }) => ($variant === 'contained' ? '#fff' : $themeColor)};
  border-bottom-color: transparent;
  border-radius: 50%;
  display: inline-block;
  box-sizing: border-box;
  animation: rotation 1s linear infinite;

  @keyframes rotation {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
`;

傳入 icon

可以加入 startIcon 與 endIcon 來接收傳入的 icon,並設計一個 styled 元件來包裝:

/**
 * icon
 */
export const Icon = styled.div<Props>`
  display: flex;
  justify-content: center;
  align-items: center;

  max-width: 24px;
  max-height: 24px;
`;

這邊稍微調整 isLoading 的邏輯,為簡化判斷與偷懶不想調樣式而改為用三元運算,
最後按鈕大致上的架構如下:

export function Button({
  children,
  variant = 'contained',
  themeColor = 'primary',
  isDisabled = false,
  isLoading = false,
  startIcon,
  endIcon,
  className,
  style,
  ...props
}: ButtonProps) {
  const { getColor } = useColor();
  const btnColor = getColor(themeColor, isDisabled || isLoading);

  return (
    <StyledButton
      $variant={variant}
      $themeColor={btnColor}
      disabled={isDisabled || isLoading}
      className={className}
      style={style}
      {...props}
    >
      {isLoading ? (
        <LoadingAnimation $variant={variant} $themeColor={btnColor} />
      ) : (
        <>
          <Icon>{startIcon}</Icon>
          <span>{children}</span>
          <Icon>{endIcon}</Icon>
        </>
      )}
    </StyledButton>
  );
}

屬性設計

interface ButtonProps {
  /**
   * 按鈕文字
   */
  children?: React.ReactNode;

  /**
   * 按鈕樣式
   */
  variant?: 'contained' | 'outlined' | 'text';

  /**
   * 按鈕顏色
   * 可以是色碼或是主題色關鍵字
   */
  themeColor?: string;

  /**
   * 是否禁用
   */
  isDisabled?: boolean;

  /**
   * 是否讀取中
   */
  isLoading?: boolean;

  /**
   * 按鈕左側圖示
   */
  startIcon?: React.ReactNode;

  /**
   * 按鈕右側圖示
   */
  endIcon?: React.ReactNode;

  /**
   * className
   */
  className?: string;

  /**
   * inline style
   */
  style?: React.CSSProperties;

  /**
   * 點擊事件
   */
  onClick?: () => void;
}
資訊

使用 jsDoc 的註解格式,可以讓 storybook 的 Docs 直接讀取變成 description:

gh

資訊

到目前為止,不論是初期設定、custom hook 等等都設定完畢,也設計好第一個元件了!   這些設定都可以沿用下去,不過在書中的教學一開始並不會加這麼多東西提升難度,   我覺得單純練習設計元件的話甚至可以不設定 ThemeProvider 和 TypeScript。