タッチエリアとは
タッチエリア (touch target) とは、UIコンポーネントがクリックやタップなどの座標指定の操作に反応する範囲のことです。操作性をよくするために、タッチエリアは意図的に見た目上の大きさよりも大きく確保されることがあります。
Touch targets are the parts of the screen that respond to user input. They extend beyond the visual bounds of an element.
https://m2.material.io/develop/web/supporting/touch-target
Wantedlyにおけるタッチエリア
Wantedlyでは独自のUIデザインシステムを運用しています。これについては以前以下のような記事で触れています:
UIデザインシステムでは、一貫したUIを作るためにまずいくつかの原則を定義し、その原則にしたがって共通コンポーネントを定義しています。実際に提供される画面は、なるべくこの原則と共通コンポーネントにしたがって作られます。
Web frontendをはじめとした各フロントエンド技術はこの構成をなぞり、原則と共通コンポーネントがコード上で表現されるように作られています。
そして、Wantedlyではそのような共通コンポーネントのひとつとして "TouchArea" のようなものがあると考えています。
WantedlyのUIデザインシステムでは、TouchAreaコンポーネントの「レイアウト上の境界」と「タッチエリア」は一致するものとして定義されています。つまり、タッチエリアは各TouchAreaの専有する領域であり、他の要素の侵入は許さないようになっています。TouchAreaを並べると、タッチエリアは隙間や重なりなく敷き詰められることになります。
デザインシステムを実装するには、この「汎用タッチエリア」をうまくコードで表現する必要があります。
内側か、外側か
TouchAreaの基本的なインターフェースは次のようなものであることが期待されます。
// buttonのタッチエリアを拡大する
<TouchArea>
{/* 実際にはこちらのコンポーネントがインタラクションを担う */}
<Button>
{...}
</Button>
</TouchArea>
ただ、TouchAreaが必要になるときは上記のコードのようにインタラクティブコンテントに属する要素とセットで出てくることは仮定できます。そこで、内外を逆転して、以下のような形式でTouchAreaを実装するということも考えられます。
// buttonの本体 (スタイルを持たない)
<Button {...touchAreaProps}>
{/* buttonの見た目を実装 */}
<ButtonInner>
{...}
</ButtonInner>
</Button>
HTMLで書くと次のようなイメージです。
<button style="all: unset; padding: 4px;">
<div style="/* ボタンのスタイルを当てる */;">
...
</div>
</button>
この方法の利点は、特別なテクニックを用いなくても自然にタッチエリアを拡大できることにあります。一方で、これには以下の欠点があります。
- TouchAreaのコード上の表現としては素直ではない。
- <input> や <textarea> は内部に要素を持てないため、この方法が適用できない。
特に後者の問題が致命的であり、本稿でいうところの「汎用TouchArea」のアプローチとしては選択肢から外れることになります。
Wantedlyの初期の実装ではこの方法も見られましたが、デザインシステムに合わせてUIライブラリをシステマティックに再設計する過程でこの方法は使われなくなりました。
自力でのイベントハンドリング
上記の議論から、TouchAreaは本来のインタラクティブコンテントに属する要素とは別の要素として実現する必要があります。つまり、TouchAreaに対する操作を本来の要素に対する操作として扱うために何らかの仕組みが必要になります。
その方法のひとつとして、TouchAreaに対するイベントを捕捉して適切にハンドルするという方法が考えられます。
const Button = (props) => {
return (
<TouchArea onClick={props.onClick}>
<button onClick={props.onClick}>
{...}
</button>
</TouchArea>
);
};
ただ、この方法にはいくつかの注意が必要です。たとえば上の例ではonClickをbuttonとTouchAreaの両方に紐付けています。これはマウス起因ではないbutton操作を適切にハンドルするために必要ですが、するとイベントバブリングによりprops.onClickが2回呼ばれてしまうことになります。
また、既知のイベント以外は委譲漏れを起こす可能性があります、たとえば内側の要素が <a> の場合、各ブラウザはCtrl+Clickや中ボタンクリック、ドラッグアウトなど様々な操作を独自に提供している場合があります。TouchAreaはこのようなカスタムの動作をスキップしてしまうことになります。
CSSの擬似クラスの対応にも工夫が必要です。JavaScriptレベルで代替となるクラスを用意するか、TouchArea要素を含めたセレクタをうまく書く必要があります。これはCSS-in-JSでコンポーネントごとにスタイルの責務を分割しようとしたときに問題が複雑化します。
この方法もWantedlyのデザインシステム実装の古いバージョンで使われたことがありますが、上記のような理由から現在は使われていません。
擬似要素による拡大
タッチエリアの拡大でよく使われるテクニックのひとつに、擬似要素を用いる方法があります。これは次のようなDOMを生成させる戦略です。
<button style="position: relative;">
<!-- CSSによる擬似要素 -->
<::before
style="position: absolute; top: -4px; bottom: -4px; left: -4px; right: -4px"
></::before>
Click here
</button>
このようにすると ::before 擬似要素は親要素のcontent-boxよりも大きなboxを持つことになります。主要ブラウザではこのような場合に親要素のbox境界には関係なく子要素のboxの当たり判定を行うため、buttonの少し外側をクリックしてもバブリングによりbuttonのonClickが発火することが期待されます。
Wantedlyの場合は、タッチエリアだけを拡大するのではなく、レイアウト上の境界もあわせて拡大する必要があります。そのため、擬似要素とは別に外側にも要素を足します。
<!-- TouchAreaに対応する要素 -->
<div class="TouchArea" style="position: relative; padding: 4px;">
<button style="position: static;">
<!-- CSSによる擬似要素 -->
<::before
style="position: absolute; top: 0; bottom: 0; left: 0; right: 0;"
></::before>
Click here
</button>
</div>
この方法を取る場合、外側のdivと内側の擬似要素の間でサイズを同期させる必要があります。たとえば上の実装方法では、インタラクティブコンテント要素をposition: static; (デフォルトの値) で配置しています。すると擬似要素はインタラクティブコンテント要素を飛び越えて、position: relative; が指定されているTouchArea要素をレイアウトコンテナ(closest positioned ancestor)としてレイアウトされることになります。position: absolute; を指定すれば、レイアウトコンテナに対してフルサイズでサイズを決定することができます。
この方法の問題点は、やはり <input> や <textarea> に対しては動作しない場合が多いという点です。
labelによる拡大
<button> や <input> などのフォームコントロールには、そのインタラクションの領域を拡大するための古典的な仕組みがあります。それが <label> 要素です。
<label> にはfor属性を使う方法と対象要素を囲う方法の2つがあります。対象要素を囲う場合、以下のような使い方になります。
<label>
Email:
<input type="text" name="email">
</label>
この場合、 "Email: 〜" 全体がinput要素のラベルとみなされます。この範囲内をクリックした場合、それは当該input要素に対する何らかのアクションとみなされます。
ラベルはテキストでなければいけないわけではなく、主要ブラウザでは単にinputをlabelでラップしただけの場合でもラベルとして扱ってもらえます。つまり、この方法はデザインシステムで求められているタッチエリアの拡大の手段として利用する余地があります。
<label> が利用できる要素は決まっていて、ラベル可能要素と呼ばれています。ラベル可能要素には <input>, <textarea>, <select> などが含まれています。これはインタラクティブコンテントに属する要素のうち無または特殊なコンテントモデルを持つものをほぼカバーしているため、「擬似要素による拡大」とある種の補完関係にあるといえます。
label要素を使った方法にもいくつかの欠点があります。まず、これはあくまで「ラベル」であるため、全ての座標操作 (クリック、ホバー、タッチなど) が同じように委譲されることを要求しているものではありません。ラベルをクリックしたときにどのように振る舞うかはあくまでプラットフォームに依存して決まります。
また、labelは名前のとおりフォームコントロールの説明を付与することが想定されるため、アクセシビリティー属性のヒントに使われることがあります。タッチエリアの拡大にlabelを使う場合は説明がテキストとして含まれないため、意図しない挙動になる可能性があります。実験したところ、ブラウザによってはlabelに由来する空のテキストがplaceholderテキストなどより優先して使われてしまうことがあるようです。
HTML仕様では <label> のコンテントモデルに一定の制約を設けているため、それにも注意が必要です。特に <label> をネストさせることは禁止されているので、コンポーネント側で <label> を付与してしまうとその外でユーザー側の意思で <label> をつけるのが困難になってしまいます。このようなケースではライブラリのユーザー側で付与した <label> がコンポーネントのタッチエリアを含んでいる可能性が高いので、内側の <label> を取り除けば期待した挙動になるはずです。
2つの方法を組み合わせる
結局、筆者が調べた範囲内では、全ての要素に使える単一の優れた方法はありませんでした。そこで現在のデザインシステム実装では、最後に紹介した2つの方法を組み合わせるという手法をとっています。
内側の要素が <input> または <textarea> の場合は、 <label> を使ってタッチエリアを拡大します。ただし、呼び出し側で明示的にオプトアウトした場合は <label> のかわりに <div> を置きます。この場合は呼び出し側で要素を別途 <label> で囲うことを想定しています。
内側の要素が <input> と <textarea> 以外の場合は、擬似要素を使ってタッチエリアを拡大します。ただしこの判定はReactのレイヤで確実に行うのは難しいため、ラッパーコンポーネントは <input> / <textarea> ではないと判定します。 <input> や <textarea> をラップしたコンポーネントを指定する場合には呼び出し側で明示的に <label> による方法にオプトインする必要があります。
ブラウザ差異について
HTMLやCSSは様々な環境やユーザーの事情にあわせてレンダリングされることを想定しています。CSSを使えばレイアウトを細かく正確に指定できるかのように見えますが、実際にはプラットフォームごとの事情にあわせて細部まで規定されていない部分も多く、本稿で書いたような部分の挙動は実際のブラウザに任されている面も大きいです。
したがって、本稿で書かれているような方法をとっても、全てのブラウザで同じように動作するという保証はありません。ただ、仮に本稿の手法がうまく動かないブラウザがあってもインタラクション全体が壊れることはおそらくなく、せいぜいタッチエリアが拡大されない程度の影響で済むのではないかと考えています。
まとめ
- タッチエリアとレイアウト上の境界を同時に拡大する汎用コンポーネントを作りたい。
- 調べたところ、この用を達成する単一の優れた方法はなさそうだった。
- ::before 擬似要素 と label を使い分ける手法がよさそう。
今回紹介した手法がどのように動くのかを確認できるサンプルも作りました。以下で動かせます。