buttonはdivで代用できる

TL;DR(buttonをdivで代用はできるが、やるなら覚悟が必要)

button[type="button"]と同等のものをdivで代用は可能だが、実装には覚悟が必要。アクセシビリティの問題やフォーカススタイルの追加、キーボードイベントの実装など、ネイティブのbutton要素と同等の機能を実装するためには多くの工夫が必要となるためである。

通常のボタン

<button type="button" onClick="alert('Clicked!')">Pure Div Button</button>

divで代用したボタン

import { useState, ReactNode, useCallback, KeyboardEvent, MouseEvent, HTMLAttributes } from 'react';

import { createRoot } from 'react-dom/client';

const Button = ({ onClick, children, disabled = false, ...props }: ButtonProps) => {
  // キーボードイベントのハンドラー
  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLDivElement>) => {
      if (disabled) return;

      if (e.key === ' ' || e.key === 'Enter') {
        e.preventDefault();
        onClick?.(e as unknown as MouseEvent<HTMLDivElement>);
      }
    },
    [onClick, disabled],
  );

  const handleClick = (e: MouseEvent<HTMLDivElement>) => {
    if (disabled) return;
    onClick?.(e);
  };

  return (
    <div
      {...props}
      role="button"
      tabIndex={disabled ? -1 : 0}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      aria-disabled={disabled}
      aria-pressed={isPressed}
      className={`
        inline-block px-4 py-2
        text-sm
        bg-blue-500 text-white
        rounded cursor-pointer select-none
        transition-all duration-200

        /* ホバー時のスタイル */
        hover:bg-blue-600

        /* フォーカス時のスタイル */
        focus:outline-none
        focus:ring-2
        focus:ring-blue-400
        focus:ring-offset-2

        /* アクティブ時のスタイル */
        active:bg-blue-700

        /* 非活性時のスタイル */
        ${
          disabled &&
          `
          bg-gray-200
          text-gray-400
          cursor-not-allowed
          hover:bg-gray-200
          hover:shadow-none
          focus:ring-0
          focus:ring-offset-0
          pointer-events-none
          opacity-80
        `
        }
      `}
    >
      {children}
    </div>
  );
};

const App = () => {
  return (
    <>
      <Button onClick={() => alert('Clicked!')}>Pure Div Button</Button>
      <Button disabled onClick={() => alert('Clicked!')}>
        Pure Div Button (disabled)
      </Button>
    </>
  );
};

const rootElement = document.getElementById('root');
if (rootElement) {
  const root = createRoot(rootElement);
  root.render(<App />);
}

※スタイルは説明を割愛するためTailwind CSSを使用している。

button要素と同等の機能をある程度同じものを実装するだけでもこれぐらいは実装しなければならない。

解説

button type="button" の代わりにdivを使って同等の機能を実装するためには、以下の考慮が必要となる。

デモ

アクセシビリティの問題

button要素はインタラクティブな要素なため、フォーカスが可能である。一方でdiv要素は、デフォルトでインタラクティブな要素ではないため、フォーカスはできず、スクリーンリーダーなどの支援技術から適切に認識されない。

role属性の設定

role="button"を設定するとスクリーンリーダーなどの支援技術に対して要素をボタンとして識別する。

<div role="button"></div>

フォーカス可能な設定

button要素はデフォルトでフォーカス可能だが、divだとデフォルトではフォーカスされない。それはrole="button"を設定しても変わらない。そのため、tabindex="0"を設定することでキーボードでのフォーカス移動が可能な状態にする。

<div role="button" tabindex="0"></div>
// リアクティブにする場合
const Button = ({ disabled }) => {
  return <div role="button" tabIndex={disabled ? -1 : 0} />;
};

tabIndexの値については、-1はフォーカス可能だがタブキーでのフォーカス移動ができない状態、0はタブキーでのフォーカス移動が可能な状態を表す。

フォーカススタイルの追加

div要素はデフォルトでフォーカススタイルを持っていない。キーボードナビゲーションを使用するユーザーがどの要素にフォーカスしているかを視覚的に示すためには、カスタムのフォーカススタイルを追加する必要がある。

div:focus {
  outline: 2px solid blue;
}

キーボードイベント

button要素はEnterSpaceキーで操作できることが期待される。しかし、div要素にrole="button"を付与しただけではEnterSpaceキーで操作ができない。

EnterSpaceキーが押された時にクリックイベントを発火させるためには、onKeyDownイベントでこれらのキーが押された際にクリックイベントを発火させる必要がある。

const handleKeyDown = useCallback(
  (e: KeyboardEvent) => {
    if (e.key === ' ' || e.key === 'Enter') {
      e.preventDefault(); // デフォルトのスクロールなどを防止
      onClick?.(e as unknown as MouseEvent); // クリックイベント発火
    }
  },
  [onClick],
);

合わせてクリックイベント(onClick())が必要であれば、onKeyDownイベントを受け付けるようにしておく。

const handleClick = (e: MouseEvent) => {
  if (disabled) return;  // disabled状態の場合、onClickの指定に関わらず抜ける
  onClick?.(e);
};

return (<div onClick={handleClick}>)

その他

ARIA Authoring Practices Guide (APG)Button Patternによるとキーボード操作には以下の要件も求められるようである。

When the button has focus:

  • Space: Activates the button.
  • Enter: Activates the button.
  • Following button activation, focus is set depending on the type of action the button performs. For example:
    • If activating the button opens a dialog, the focus moves inside the dialog. (see dialog pattern)
    • If activating the button closes a dialog, focus typically returns to the button that opened the dialog unless the function performed in the dialog context logically leads to a different element. For example, activating a cancel button in a dialog returns focus to the button that opened the dialog. However, if the dialog were confirming the action of deleting the page from which it was opened, the focus would logically move to a new context.
    • If activating the button does not dismiss the current context, then focus typically remains on the button after activation, e.g., an Apply or Recalculate button.
    • If the button action indicates a context change, such as move to next step in a wizard or add another search criteria, then it is often appropriate to move focus to the starting point for that action.
    • If the button is activated with a shortcut key, the focus usually remains in the context from which the shortcut key was activated. For example, if Alt + U were assigned to an "Up" button that moves the currently focused item in a list one position higher in the list, pressing Alt + U when the focus is in the list would not move the focus from the list.

視覚的な押下状態の設定

aria-disabled

aria-disabledは、要素が無効状態であることをスクリーンリーダーに伝える。

aria-disabled={disabled}

disabled状態の場合は、aria-disabled="true"を設定する。

もし、さらにtype="submit"を考慮する場合

ここまでbutton type="button"の仕様をベースにdivを再現しようとしていたが、仮にbutton type="submit"も考慮する場合は他にも考慮が必要になる。

type="submit"をサポートする場合に考慮するものの1つとして、フォーム内でEnterキー押下時に自動的に送信される機能がある。

  1. フォーム要素の送信処理

    • divがクリックされた際、またはキーボードでのEnterキー押下時に、フォーム全体をsubmitする処理
    • onClickonKeyDownイベントで、親フォームのsubmit関数を呼び出す
  2. 親フォームの参照取得

    • Reactの場合はuseRefを使いdiv要素から親フォームを取得できるようにする
      closest('form')を使って親フォームを動的に見つけ、そのフォームのsubmit()メソッドを呼び出す

      const handleClick = (e: MouseEvent) => {
        const form = (e.target as HTMLElement).closest('form');
        if (form) {
          form.requestSubmit(); // フォームを送信
        }
      };
      
      const handleKeyDown = (e: KeyboardEvent) => {
        if (e.key === 'Enter') {
          e.preventDefault();
          const form = (e.target as HTMLElement).closest('form');
          form?.requestSubmit();
        }
      };
      
    • JavaScript の場合は、親にさかのぼってform要素を取得し、submit()メソッドを呼び出す

      const handleClick = (e: MouseEvent) => {
        const form = (e.target as HTMLElement).closest('form');
        form?.submit(); // フォームを送信
      };
      

このように単なるbutton type="button"とは異なり、button type="submit"の場合はフォームの送信処理を考慮する必要がある。

まとめ

代用するためにはボタン要素にrole="button"を付与するだけでは解決せず、先述までのようにいくつも考慮する点がある。そのため、可能な限りネイティブのボタンを使用することが望ましい。当然ながらネイティブのボタンは、すべてのブラウザと支援技術でサポートされており、カスタマイズなしでキーボードとフォーカスの要件を満たしている。

参考