Skip to content

Instantly share code, notes, and snippets.

@wakamsha
Last active March 10, 2024 16:33
Show Gist options
  • Save wakamsha/e01ca4fd6372708ea2810cd336f8e545 to your computer and use it in GitHub Desktop.
Save wakamsha/e01ca4fd6372708ea2810cd336f8e545 to your computer and use it in GitHub Desktop.
React でより実用的な Button コンポーネントを作る Tips

ベースとなる Button コンポーネント

type Props = Partial<{
  children: ReactNode;
  type: ButtonHTMLAttributes<HTMLButtonElement>['type'];
  disabled: ButtonHTMLAttributes<HTMLButtonElement>['disabled'];
  tabIndex: ButtonHTMLAttributes<HTMLButtonElement>['tabIndex'];
  onClick: (e: MouseEvent<HTMLButtonElement>) => void;
}>;

export const Button = ({ children, type, disabled, tabIndex, onClick }: Props) => (
  <button
    type={type}
    disabled={disabled}
    tabIndex={tabIndex}
    onClick={onClick}
  >
    {children}
  </button>
);

フォームやアクションのトリガーといった本来の用途であれば、この実装で問題ない。

<Button onClick={handleClick}>Submit</Button>

Button クリックで画面遷移したいとき

const handleClick = () => {
  window.location.href = 'http://example.com';
};

<Button onClick={handleClick}>Link</Button>

これだと cmd キー付きクリックやコンテキストメニューによる「新しいタブで開く」といった <a /> が持つ機能まで再現出来ない。

Button を で包む

<a href="http://example.com">
  <Button>Link</Button>
</a>

Button 部分はあくまで意匠としての役割に徹し、画面遷移といったビジネスロジックの責務は素直に <a /> に委ねる。これなら「アンカーリンク」に期待される振る舞いは全て得られるが、 <a /> の子要素に Button のようなインタラクティブ要素を持たせるのは NG と HTML の仕様書に明記されている。

Content model: Transparent, but there must be no interactive content descendant, a element descendant, or descendant with the tabindex attribute specified.

HTML5 Spec Document from W3C:

つまりこのようにレンダリングされるのは NG ということである。

<!-- 結果 -->
<a href="http://example.com">
  <button>Link</button>
</a>

Button コンポーネントを としてレンダリングしないよう拡張する

type Props = Partial<
  {
    children: ReactNode;
  } & XOR<
    {
      type: ButtonHTMLAttributes<HTMLButtonElement>['type'];
      disabled: ButtonHTMLAttributes<HTMLButtonElement>['disabled'];
      tabIndex: ButtonHTMLAttributes<HTMLButtonElement>['tabIndex'];
      onClick: (e: MouseEvent<HTMLButtonElement>) => void;
    },
    {
      /**
       * button 要素としての機能を全て無効化する。
       * <a /> でラップしたいときなどに指定すること。
       */
      noop: true;
    }
  >
>;

export const Button = ({ type, children, disabled, onClick, tabIndex, noop }: Props) =>
  !noop ? (
    <button
      type={type}
      tabIndex={onClick ? tabIndex : -1}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  ) : (
    <span tabIndex={-1}>
      {children}
    </span>
  );

noop というプロパティを生やす。これを指定すると <button> ではなく <span> としてレンダリングされる。

<a href="http://example.com">
  <Button noop>Link</Button>
</a>
<!-- 結果 -->
<a href="http://example.com">
  <span>Link</span>
</a>

noop を指定して span としてレンダリングということは、onClick のような button 要素特有のプロパティや tabIndex は不要となるため、型パズルを駆使してプロパティの排他制御を行っている。

型の排他制御にはts-xorを使うのがおすすめ。

@wakamsha
Copy link
Author

HTML のインタラクティブ要素

  • <a>
  • <audio> (if the controls attribute is present)
  • <button>
  • <details>
  • <embed>
  • <iframe>
  • <img> (if the usemap attribute is present)
  • <input> (if the type attribute is not in the hidden state)
  • <keygen>
  • <label>
  • <menu> (if the type attribute is in the toolbar state)
  • <object> (if the usemap attribute is present)
  • <select>
  • <textarea>
  • <video> (if the controls attribute is present)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment