React Japan
共感的コーディング - ボタン・コンポーネント
Steven Sacks
スティーブン・サックス

共感的コーディング - ボタン・コンポーネント

この記事では、共感的なアプローチを使って、ゼロからButtonコンポーネントを作成します。

スタイリングにはTailwindtailwind-mergeライブラリを使用しています。

ステップ1 - 基盤

最初に最も重要な決定は、私たちの<Button>がネイティブの<button>と同じように機能する必要があるということです。任意の開発者が私たちの<Button>を他の<button>と同じように使用できるように、同じPropを受け入れるべきです。

import type {ButtonHTMLAttributes, FC} from 'react';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button: FC<ButtonProps> = (props) => (
  <button {...props} />
);
 
export default Button;

1つだけ足りないものがあります(すでに推測しているかもしれません)。ほとんどのコンポーネントはforwardRefが必要ありませんが、ボタンはしばしば必要なので、今すぐ含めるべきです。

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => (
    <button ref={ref} {...props} />
  )
);
 
Button.displayName = 'Button';
 
export default Button;

これで、私たちの<Button>はネイティブの<button>とまったく同じように機能します。便利さに焦点を当てて機能を追加し始めましょう。

ステップ2 - 合理的なデフォルト値

合理的なデフォルトプロップを設定することは、便利さを追加する簡単な方法です。

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
import {twMerge} from 'tailwind-merge';
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      type = 'button',
      ...props
    }, 
    ref
  ) => (
    <button
      ref={ref}
      className={twMerge(
        'select-none text-center whitespace-nowrap',
        className
      )}
      type={type}
      {...props}
    >
      {children}
    </button>
  )
);
 
Button.displayName = 'Button';
 
export default Button;

<button>のネイティブデフォルトタイプはsubmitですが、開発者は通常、buttonタイプを使うと思います(開発者がこれを設定するのを忘れると、予期しない動作につながる可能性があります)。

デフォルトタイプをbuttonにすることで、開発者は明示的にsubmitまたはresetにしたい場合にのみ、タイププロップを含める必要があります。

ほとんどのボタンは中央に配置された1行のテキストを持ち、選択できないようにする必要があります(クリックアクションに誤って干渉する可能性があるため)、そのため、これを処理するためのTailwindクラスを追加しました。

最も重要なのは、これらのデフォルトクラスを上書きできるようにするために、tailwind-mergeのtwMerge関数を使用していることです。

脱出口

未来を予測することはできません。戦略的な脱出口を提供することで、開発者にハッキングを回避することなく例外を許可しています。

一部の開発者は、他の開発者が「予期しないこと」をしてトラブルに巻き込まれるのを防いでいると考えて、これを行わないことがあります。代わりに、他の開発者の実装を信頼し、私たちが予期できないニーズを持っている可能性があるということを分かっていて下さい。

最も簡単な脱出口は、コンポーネントに追加できるオプションの className?: string プロパティです(できれば、twMerge を使用してください)。この1つの要素がどれだけ便利か驚くでしょう。

ステップ 3 - スタイリング

当社の Button コンポーネントは、デザイナーが提供したデザインに従うべきです。異なるサイズやバリエーションを含めて。

import type {ButtonHTMLAttributes} from 'react';
import {forwardRef} from 'react';
import {twMerge} from 'tailwind-merge';
  
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: Size;
  variant?: Variant;
}
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    }, 
    ref
  ) => (
    <button
      ref={ref}
      className={twMerge(
        'select-none whitespace-nowrap text-center',
        VARIANTS[variant],
        SIZES[size],
        className
      )}
      type={type}
      {...props}
    >
      {children}
    </button>
  )
);
 
Button.displayName = 'Button';
 
export default Button;

デザインのために Tailwind クラスを実装しました。いくつかのサイズやバリエーションを含めています。それらのための型を作成し、sizevariant のデフォルト値を設定しました。

size プロパティは、Tailwind のフォントサイズと同じ規則に従っています。これにより一貫性があり、覚えやすく、したがって便利です。

variant プロパティには、css クラスを含まない custom 値があります。これにより、開発者がボーダーや背景、テキストカラーなどを上書きする必要なく、一時的なボタンを簡単に作成できます。twMerge エスケープハッチのおかげで、彼らはベースとしていくつかのバリエーションを使用して必要な部分だけを変更できます。

開発者が同じカスタムクラスを複数回使用することに気づいた場合、それを新しい Variant として Button コンポーネントに追加するか、またはバリエーションを設定する自分自身のコンポーネント内で Button コンポーネントを構成することができます。どちらの解決策も適切であり、当社の Button コンポーネントはどちらもサポートする柔軟性があります。

制限

size プロパティには variant と同様に custom 値が含まれていません。これは、追加した最初の制限です。

私たちは常にパディングとテキストサイズを設定し、開発者が当社の定義済みのサイズのいずれかから始めて明示的に上書きする必要があると決定しました。この決定にはいくつかの理由があります。

  • size プロパティはパディングとテキストサイズのみを設定しています。twMerge 脱出口を使用すれば、開発者は必要に応じてこれらを上書きできます。
  • Variant 型にはおそらく多くの他の css プロパティ(ホバーカラー、ダークモードカラー、角丸など)が含まれるでしょう。開発者に、Variant 内のすべてのクラスを上書きまたは解除することを要求すると、負担が大きすぎます。これが、必要に応じてすべてをゼロにする custom バリエーションを提供している理由です。
  • 開発者が、デザインで定義したフォントサイズやパディングとは異なるボタンのカスタムカラーが必要になる可能性の方が高いです。

おそらく、空の文字列で custom サイズを指定することを決定するかもしれませんが、それは有効です。私の経験では、そのような状況に遭遇したことはありませんが、その場限りのカスタムボタンが必要な状況には頻繁に遭遇します。

ステップ 4 - アイコン

時々、ボタンにはアイコンが必要です。開発者が children を介してそれを渡すこともできますが、一貫性のためにデザインシステムのスペーシングとサイズを適用するためには、プロップスを使用する方が便利です。

この例では、FontAwesome アイコンを使用していますが、使用しているアイコンに関しても同じ考え方が適用されます。

import type {ButtonHTMLAttributes, ReactNode} from 'react';
import {forwardRef} from 'react';
import type {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {twJoin, twMerge} from 'tailwind-merge';
 
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
export const ICON_SIZES: Record<Size, string> = {
  base: 'px-2.5',
  lg: 'px-2.5',
  sm: 'px-2',
  xl: 'px-2.5',
  xs: 'px-1.5',
};
 
export const ICON_POSITION: Record<'left' | 'right', string> = {
  left: '',
  right: 'flex-row-reverse',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type MaybeIcon = {
  children: ReactNode;
  icon?: IconProp;
  iconPosition?: 'left' | 'right';
};
 
type OnlyIcon = {
  children?: never;
  icon: IconProp;
  iconPosition?: never;
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: Size;
  variant?: Variant;
} & (MaybeIcon | OnlyIcon);
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      icon,
      iconPosition = 'left',
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    },
    ref
  ) => {
    const iconComponent =
      icon ?
        <FontAwesomeIcon
          className={twJoin(children && 'flex-none')}
          fixedWidth={true}
          icon={icon}
          size="1x"
        />
      : null;
 
    return (
      <button
        ref={ref}
        className={twMerge(
         'select-none whitespace-nowrap text-center',
          VARIANTS[variant],
          SIZES[size],
          icon &&
            (children ?
              `flex justify-around items-center gap-2 text-left ${ICON_SIZES[size]} ${ICON_POSITION[iconPosition]}`
            : ICON_SIZES[size]),
          className
        )}
        type={type}
        {...props}
      >
        {iconComponent}
        {children}
      </button>
    );
  }
);
 
Button.displayName = 'Button';
 
export default Button;

追加した内容を見てみましょう。

  • icon プロップは FontAwesome の IconProp 型を受け入れます。これにより、開発者が独自の <FontAwesomeIcon> をレンダリングする場合と同じように icon プロップを渡すことが簡単になります。
  • ボタンにアイコンがある場合、水平パディングが若干異なります。px 値のために twMerge オーバーライド機能を活用しています。
  • ボタンにアイコンとテキストがある場合、text-center を使用したくないため、アイコンがある場合は text-left にオーバーライドし、アイコンとテキストを垂直方向に中央揃えし、間に gap-2 を使用しています。
  • ボタンがコンテンツよりも幅広くスタイリングされている場合、アイコンとテキストが水平方向に中央揃えされるように justify-around を使用しています。
  • テキストの左側または右側にアイコンを表示するための iconPosition propもサポートしています。アイコンを右側にレンダリングするには flex-row-reverse を使用できます。
  • 差別化された共用体を使用することで、TypeScript の狭めを活用し、ボタンが children と/またはアイコンを必要とし、アイコンのみがあるかアイコンが全くない場合、iconPosition propを許可しないようにすることができます。
  • 厳密に言えば、icon がない状態で iconPosition を渡すことは何もしません。なぜなら、iconPosition はアイコンがある場合にのみ使用されるからです。しかし、開発者が icon プロップを削除して iconPosition を削除しない場合、将来的に cruft につながる可能性があります。今すぐこれを強制することで、潜在的な技術的負債を防ぎ、それ自体が便利な形態の1つです!

ステップ5 - ローディング状態と無効化

ボタンはしばしば非同期イベントをトリガーするために使用されます。ボタンが無効化されたときに、ローディング状態と視覚的な明確さを追加しましょう。

import type {ButtonHTMLAttributes, ReactNode} from 'react';
import {forwardRef} from 'react';
import type {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {twJoin, twMerge} from 'tailwind-merge';
import Spinner from '~/components/Spinner';
 
type Size = 'base' | 'lg' | 'sm' | 'xl' | 'xs';
 
const SIZES: Record<Size, string> = {
  base: 'px-3 py-2 text-base',
  lg: 'px-4 py-2 text-lg',
  sm: 'px-3 py-1.5 text-sm',
  xl: 'px-4 py-2.5 text-xl',
  xs: 'px-1.5 py-1 text-xs',
};
 
export const ICON_SIZES: Record<Size, string> = {
  base: 'px-2.5',
  lg: 'px-2.5',
  sm: 'px-2',
  xl: 'px-2.5',
  xs: 'px-1.5',
};
 
const ICON_POSITION: Record<'left' | 'right', string> = {
  left: '',
  right: 'flex-row-reverse',
};
 
type Variant =
  | 'custom'
  | 'primary'
  | 'secondary'
  | 'tertiary';
  
const VARIANTS: Record<Variant, string> = {
  custom: '',
  primary:
    'border border-blue-400 bg-blue-500 text-white',
  secondary:
    'border border-blue-500 bg-white text-blue-500',
  tertiary:
    'border border-grey-600 bg-grey-500 text-white',
};
 
type MaybeIcon = {
  children: ReactNode;
  icon?: IconProp;
  iconPosition?: 'left' | 'right';
};
 
type OnlyIcon = {
  children?: never;
  icon: IconProp;
  iconPosition?: never;
};
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  isLoading?: boolean;
  size?: Size;
  variant?: Variant;
} & (MaybeIcon | OnlyIcon);
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      className,
      disabled,
      icon,
      iconPosition = 'left',
      isLoading,
      size = 'base',
      type = 'button',
      variant = 'primary',
      ...props
    },
    ref
  ) => {
    const iconComponent =
      icon ?
        <FontAwesomeIcon
          className={twJoin(children && 'flex-none')}
          fixedWidth={true}
          icon={icon}
          size="1x"
        />
      : null;
 
    return (
      <button
        ref={ref}
        className={twMerge(
         'select-none whitespace-nowrap text-center',
          VARIANTS[variant],
          SIZES[size],
          icon &&
            (children ?
              `flex justify-around items-center gap-2 text-left ${ICON_SIZES[size]} ${ICON_POSITION[iconPosition]}`
            : ICON_SIZES[size]),
          isLoading ?
            'cursor-wait' :
            'disabled:cursor-not-allowed disabled:opacity-50',
          className
        )}
        disabled={disabled || isLoading}
        type={type}
        {...props}
      >
       {isLoading ?
        <span className="relative block">
          <span className="invisible block">
            {iconComponent}
            {children}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner />
          </span>
        </span>
      : <>
          {iconComponent}
          {children}
        </>
      }
      </button>
    );
  }
);
 
Button.displayName = 'Button';
 
export default Button;

今、このButtonコンポーネントはオプションの isLoading ブーリアン型プロパティを受け取り、ネイティブの disabled プロパティは {...props} にスプレッドされるのではなく、明示的に処理されるようになりました。

  • ボタンがローディング中の場合、ボタンを無効化し、cursor-wait を使用します。
  • ボタンが無効化されており、ローディング中でない場合、半透明にし、cursor-not-allowed を使用します。
  • <Spinner> コンポーネントは回転する円をレンダリングします。重要なのは、アイコンやテキストを不可視にしても、ボタンがローディング状態に切り替わったときに同じ寸法を維持するようにすることです。注:<button> 内に <div> を配置することは技術的には有効ではありませんので、<span> を使用します。
  • <Spinner><button> 内で中央に配置するために、絶対位置指定を使用して中央に配置する必要があります。ボタンの寸法を変更しないようにするために、相対的に配置されたブロック内にすべてをラップし、inset-0 を使用して <Spinner> をボタンの幅と高さにします。

要点

このButtonコンポーネントは、チームの開発者がほぼすべての状況で使用できる便利な機能を持つようになりました。それは依然として内部的には <button> ですし、開発者が他のネイティブ機能が必要な場合や、クラスをオーバーライドする必要がある場合は、それが可能です。

コードのメンテナンスの観点からは、型や定数を別の共有ファイルに移動してButtonコンポーネントにインポートするか、そのままにしておくかを決定することができます。

サイズを追加したり、さまざまなバリエーション、角丸、ホバーカラー、ダークモードなどを追加することができます。本質的には、ボタンの動作の期待を損なうことなく、メンテナンスしやすく新しい便利な機能を追加することができる基盤を構築しました。

これは、ネイティブの型、脱出口、そして慎重に考慮された制限を通じて達成しました。