リンク機能を持つ Button UI を賢く正しく実装するテクニック

wakamsha
12

はじめに

本来 "Button UI" とはフォームやアクションのトリガーを用途とした要素であり、URL 遷移にはアンカーリンク( a 要素)を使うのがセオリーですが、「ボタンのような意匠をしたアンカーリンク」という UI もしばしば目にします。

CSS フレームワークである Bootstrap には .btn という CSS クラスを付与することで Button UI を表現しますが、この CSS クラスは button 要素や input 要素に限らず a 要素や span 要素にも使えるので、「ボタンの意匠をしたアンカーリンク」も簡単に実現出来るわけです。

基本的に Bootstrap のような CSS フレームワークは意匠のみを抽象化しており、機能面には関与しないことでこういった柔軟性を実現しています。一方 JavaScript フレームワークを用いるような web アプリケーション開発においては、button 要素を拡張して意匠と機能をひとまとめにしたカスタムコンポーネントとして定義することが多いため、CSS フレームワークの設計方針とは異なるアプローチが求められます。

今回はそういった条件下でアンカーリンク機能を持った Button UI を賢く正しく実装するテクニックをご紹介します。

本エントリに登場するサンプルコードは React + TypeScript を使用しておりますが、設計思想自体は Vue や Angular など他のフレームワークにも流用可能ですので、ぜひとも参考にしてみてください。

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

type Props = {
  children: ReactNode;
  disabled?: boolean;
  tabIndex?: number;
  type?: 'submit' | 'reset' | 'button';
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};

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

const styleButton = css`
  /* ボタンのスタイル定義 */
  ...
`;

リンクボタンのアンチパターン 三選

「ボタンをクリックして URL 遷移させる」という要件を満たすだけならいくらでもやりようはありますが、意図せずアンチパターンを踏んでしまっているケースに多々遭遇してきました。まずはそんな事例を三つご紹介します。

1. JavaScript を使って遷移させる

クリックイベントをトリガーにし、 JavaScript を使って他 URL へ遷移するテクニックです。おそらく真っ先に挙がる手法ではないでしょうか。Single Page App など比較的厚みのある web フロントエンドコードを書く人ほどこの実装を思いつくことでしょう。

const handleClick1 = () => {
  // 同一タブ内で遷移
  window.location.href = 'https://example.com';
};

const handleClick2 = () => {
  // 新規タブを開いて遷移
  window.open('https://example.com');
};

return (
  <>
    <MyButton onClick={handleClick1}>Link 1</MyButton>
    <MyButton onClick={handleClick2}>Link 2</MyButton>
  </>
);

確かに URL 遷移という要件は満たしていますが、アクセシビリティの観点から不十分です

Chrome や Firefox の場合、通常の a 要素にカーソルを合わせるとウィンドウの左下部分に遷移先の URL が表示されます。これによりユーザはこの要素がリンク要素であること、遷移先の URL 情報、クリックすることで現在の URL から離脱することを事前に認知出来るわけですが、button 要素ですとそれが叶いません。JavaScript による遷移ですとクリックイベントが発火するまでどこに遷移するのか(というより URL 遷移すること自体を)ブラウザは知りようがないため、事前に遷移先 URL を可視化できないわけです。

また、対象が a 要素であれば右クリックすることでそれに適したコンテキストメニューが展開されます。Ctrl ( or )キーを押しながらクリックすれば強制的に新規タブで遷移先 URL を開くことも出来ます。JavaScript による遷移ですと、これらを全て自前で実装せねばなりません。もちろんコストを惜しまなければ実現可能ですが、web ブラウザ本来のそれと同等の水準を満たすことがいかに困難なことであるかは想像に難くないでしょう。

2. 一つのコンポーネントに対し button と a 二つの機能を持たせる

button 要素と a 要素の二つを一つのカスタムコンポーネントにまとめ、使用側でどちらの機能を使うのか選択出来るようにするというものです。いわゆる「ニコイチ(2 in 1)」ですね。

type Props = {
  children: ReactNode;
  disabled?: boolean;
  tabIndex?: number;
  type?: 'submit' | 'reset' | 'button' | 'link';
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
  href?: 'string';
};

export const MyButton = ({ children, tabIndex, disabled, type, href }: Props) =>
  type === 'link'
    ? <a href={href} className={styleButton}>{children}</a>
    ? <button type={type} onClick={onClick} className={styleButton}>{children}</button>;
//アクションボタン
<MyButton onClick={e => {...}}>Do Action</MyButton>

// リンクボタン
<Button href="https://example.com">Link</MyButton>

利用する側から見ればこの MyButton ひとつで button 要素と a 要素の両方をカバー出来るようになりました。渡すプロパティを変えるだけでいずれかの機能を選択出来るのでとても便利に思えますが、 これはアンチパターンと言わざるを得ません

要は全く異なる二種類の処理を一つの関数に無理やり押し込めてるに過ぎず、そもそも buttona は、それぞれが担う役割がまるで違います。一つの関数が二つ以上の役割を持てば、それだけテストが困難なものとなります。関数(≒ コンポーネント)は原則として一つのことだけを行うべきです。

では Button コンポーネントとは別に LinkButton なる a を拡張したカスタムコンポーネントを実装すればよいのでしょうか?一理ありますが、たかがそれだけのために Button コンポーネントの亜種を増やすのも考えものです。意匠が同じならスタイル定義のみを抽象化して使い回したくなるところですが、コンポーネント間に中途半端な結合が発生してしまうのもイケてません。

3. Button コンポーネントを a 要素でラップする

ニコイチにするなど難しく考えずに MyButton コンポーネント自体を a 要素でラップしてしまえばいかがでしょうか。MyButton にイベントハンドラーを持たせなければ、クリックしても a 要素のみが反応します。

<a href="https://example.com">
  <MyButton>Link</MyButton>
</a>

MyButton はあくまで意匠としての役割に徹し、画面遷移といったビジネスロジックの責務は素直に a に委ねています。一見とても良さそうに思えます。しかし残念ながら a の子要素に button のようなインタラクティブな要素を持たせるのは、 HTML の仕様として禁じられています

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

つまりこのようにレンダリングされるようなマークアップを書いてはいけないということです。

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

2021年2月現在はこのようなマークアップでも良しなに表示してくれるようですが、公式の仕様に反している以上いつ壊れてもおかしくありませんし、ブラウザの(半ば過剰な)ホスピタリティに甘んじ過ぎるのもフロントエンドディベロッパーとしていかがなものでしょうか。

【解決策】そもそも button 要素を使わなければいい

button としての機能が不要なら button 要素ではなく span 要素に差し替えれば良いのです。

type Props = {
  children: ReactNode;
  disabled?: boolean;
  tabIndex?: number;
  type?: 'submit' | 'reset' | 'button';
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
  as?: 'span';
};

export const MyButton = ({ children, tabIndex, disabled, type, onClick, as }: Props) =>
  as !== 'span' ? (
    <button
      className={styleButton}
      type={type}
      tabIndex={tabIndex}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  ) : (
    <span className={styleButton} tabIndex={-1} aria-disabled={disabled}>
      {children}
    </span>
  );

as?: 'span; というプロパティを新たに追加しました。これを指定すると MyButton は意匠はそのままに span 要素としてレンダリングされるため、先程の HTML 仕様違反を回避出来ます。

<a href="https://example.com">
  <MyButton as="span">Link</MyButton>
</a>
<!-- 結果 -->
<a href="http://example.com">
  <span>Link</span>
</a>

内部構造を button から span に切り替えられるようにするとなると「a の機能を持たせるニコイチ手法と同じでは?」と思われるかもしれません。しかし全く別の機能を同居させるのではなく button 本来の振る舞いを( span 要素に差し替えることで)無効化しているところに違いがあります。言うなればイベントハンドラーを渡していない状態を更に突き詰めたものとなります。少なくともここに「複雑性」というものは無いと言えるでしょう。

プロパティをこのようにしたのは "MyButton as span." と自然言語らしい表現を狙ったものに過ぎないため、非インタラクティブな HTML 要素に切り替わることが伝われば他の名称や型でも構いません。例えば「一切の機能を無効化(≒ ハリボテ化)する」という意味を込めて noop とするのもありかもしれません。

type Props = {
  // ...
  /**
   * true を指定すると、span 要素としてレンダリングされ button 要素特有の機能を完全に無効化する。
   * <a /> でラップするときなど Button の意匠だけが欲しいときに指定すること。
   */
  noop?: boolean;
};

TypeScript の型パズルを駆使して Props を整理する

機能要件は満たせましたが、 as="span" プロパティを指定したのに typeonClick プロパティが指定可能なままなのはコンポーネント設計としてイマイチです。

<MyButton as="span" onClick={e => {...}}>Click me!</MyButton>

例えばコンポーネント利用者がこのように書いてしまったらどうなるでしょうか?内部実装を読めば as="span" が優先されて onClick の指定は意味を成さないことが分かりますが、それならばそもそも onClick を指定出来ないようにしてあげるべきです。

プロパティ指定を誤って混乱しないためにも Props をブラッシュアップしましょう。

type Props = {
  children: ReactNode;
  disabled?: boolean;
  tabIndex?: number;
  type?: 'submit' | 'reset' | 'button';
  onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
  as?: 'span';
};
  • children, disabled は共通して使えるようにする
  • tabIndex, type, onClickbutton 要素として使用するときのみ使えれば良い
  • asspan 要素として使用するときのみ使えれば良い

つまり { children, disabled } を共通プロパティとしつつ { tabIndex, type, onClick }{ as } を排他制御すれば良いことになります。今回は ts-xor という OSS ライブラリを使用します。

  • maninak/ts-xor
  • このライブラリを使って Props の型を定義すると下記のようになります。

    import { XOR } from 'ts-xor';
    
    type Props = {
      children: ReactNode;
      disabled?: boolean;
    } & XOR<
      {
        onClick: (e: MouseEvent<HTMLButtonElement>) => void;
        tabIndex?: number;
        type?: 'submit' | 'reset' | 'button';
      },
      {
        /**
         * span 要素としてレンダリングすることで button 要素特有の機能を完全に無効化する。
         * <a /> でラップするときなど Button の意匠だけが欲しいときに指定すること。
         */
        as: 'span';
      }
    >;
    

    XOR というカスタム Utility Type に2つの型定義オブジェクトを渡すと、双方で排他的になります。つまり tabIndex, type, onClick のいずれかを指定すると as は指定できなくなり、逆に as を指定すると tabIndex, type, onClick 全てが指定できなくなります。

    // OK
    
    <MyButton onClick={e => { ... }} type="button">Click me!</MyButton>
    
    <a href="https://example.com">
      <MyButton as="span">Link</MyButton>
    </a>
    
    // NG
    
    <MyButton as="span" onClick={e => { ... }}>Click me!</MyButton>
    <MyButton as="span" type="button">Click me!</MyButton>
    

    これで安心して利用出来ますね。