Cardのマークアップでもう悩まない!NestedでClickableなUIを実現する、Link Area Delegationとは

Published on December 16, 2024

本エントリでは、インタラクティブな要素がネストされたUIを、標準的な方法で実現しようという「Link Area Delegation」Proposalについて紹介します。

Table of Contents

Table of Contents

はじめに

Open UI Community Groupで、仕様の策定が検討されている Proposal の一つに、Link Area Delegationがあります。

本エントリでは、Link Area Delegation の仕様がどのような背景から検討され、何が可能となり、これから私たちがどのように利用していけるのかを紹介します。

Link Area Delegation(= リンク領域の委譲)Proposal は、コンテナがインタラクティブな領域を特定の要素に委譲することで、コンテナを「クリッカブル」にする機能を標準的に導入しようとする試みです。

例えば、提案されている手法のひとつである、linkarea属性defaultlink属性を使用すると、Link Area Delegation は次のように表現されます。

次の例では、linkarea属性のつく<div>を Click することで、defaultlink属性を持つ最初の子孫要素、つまりこの場合は、最初の<a>が活性化されます。

<div class="card" linkarea>
  <a href="/post?id=123" defaultlink>Post Title</a>
  <img ...>
  <button>Join</button>
  <button>Share</button>
</div>

歴史的背景から、<a><button>のようなインタラクティブな要素をネストした UI を実装することは、開発者にとって難しい課題でした。

インタラクティブな要素同士を子孫関係におくことができない旨は仕様に明記されており、例えば<a>の Content Model であれば次のように定義されています。

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


透明、しかしインタラクティブなコンテンツの子孫、a要素の子孫、tabindex属性が指定された子孫が存在してはならない。 https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element

<a>はインタラクティブな要素であるゆえ、<a>の中に<a>をネストすることはできません。<button>もインタラクティブな要素なので、<a>の中に<button>をネストすることもできませんし、その逆も然りです。

インタラクティブな要素 インタラクティブな要素 - 出典: HTML Standard - Kinds of content

もし仮に、次のように<a>の中に<a>をネストした場合、それぞれは兄弟関係のリンクとしてパースされます。

<a href="#hoge">
    ここは親リンクのエリア
    <a href="#fuga"> 私は子のリンク </a>
    この中に#fugaへのリンクがある
</a>

兄弟関係のa tagになる 兄弟関係のa tagになる

このように、HTML パーサは期待通りのインタラクティブな要素の解釈をしてくれないため、私たちは Nested Links を実現するために、さまざまな Hack をしてきました。

クリッカブルなコンテナを実現する既存の方法

ネストされたインタラクティブな要素の代表的なユースケースとして、Card コンポーネントが挙げられるでしょう。

例えば、本ブログの記事一覧ページでも、Card コンポーネントを使用しています。

Card本体をホバーCard内tagをホバー
Card本体をホバーした時、記事へのURLに変わるCard本体をホバーした時、記事へのURLに変わるCard内のtagをホバーした時、tagのURLに変わるCard内のtagをホバーした時、tagのURLに変わる

この Card コンポーネントは、<a>タグのネスティングを使用せず、CSS の Subgrid を使用して、クリッカブルなエリアを拡張しています。

実装を簡略化すると、次のようなマークアップとスタイリングをすることで、上記の Card コンポーネントの実現が可能です。

<div class="card">
  <a class="link" href="https://blog.sakupi01.com/dev/articles/2024-openui-advent-12">
    <h2>🎄Open UI Advent Calendar: Day 12 / Customizable Select Element Ep.10</h2>
    <p>Customizable Select Elementの関連仕様: `::picker`のデフォルト色から深掘る、system-color/ color-scheme/ prefers-color-schemeの関係</p>
  </a>
  <p class="tags">
    <a href="https://blog.sakupi01.com/dev/tag/openui">openui</a>
    <a href="https://blog.sakupi01.com/dev/tag/advent%20calendar">advent calendar</a>
  </p>
</div>
.card {
  display: grid;
}

.link {
  grid-row: 1 / 3;
  grid-column: 1;
  display: grid;
  grid-template-rows: subgrid;
}

.tags {
  grid-row: 3 / 3;
  grid-column: 1;
}

しかし、CSS の Subgrid が全てのブラウザで実装されたのは 2023/9/15 と比較的最近で、それ以前はさまざまなワークアラウンドが取られてました。

この Subgrid の例も含め、HTML のバリデーションを通過するようにしつつ、CSS や JavaScript を使ってリンクやボタンのクリッカブルな領域を拡張する方法はさまざまにあります。こうした Hacky な方法は、標準化された方法ではなく、UX やアクセシビリティの観点からさまざまな問題を引き起こす可能性があります。

既存方法とその問題

「クリッカブル」なコンテナを実現するための既存方法とそのトレードオフを、もう少し細かく見てみましょう👀

<a>でコンテンツをラップする

クリック可能にしたいコンテンツ全体を<a>でラップする方法です。

<a href="https://example.com">
  <div class="card">
    <h2>タイトル</h2>
    <p>コンテンツの説明</p>
    <button>詳細を見る</button>
  </div>
</a>
<table>
  <tr>
    <a href="https://example.com">
      <td>hoge</td>
      <td>fuga</td>
    </a>
  </tr>
</table>
トレードオフ

リンクに対して必要以上の情報を読み上げてしまう リンクに対して必要以上の情報を読み上げてしまう

2. CSSでインタラクティブな要素の配置を調整する

重複したリンクやコンテナのアクションを ::before::after に配置し、コンテナサイズまで広げる方法です。

<div class="card">
  <a class="link" href="#title">
    <h1>タイトル</h1>
  </a>
  <p>コンテンツ</p>
  <p class="tags">
    <a href="#hoge">#hoge</a>
    <a href="#fuga">#fuga</a>
  </p>
</div>
.card {
  /* elevate the links up */
  position: relative;
  a {
    position: relative;
    z-index: 1;
  }
  .link {
    position: static;
    /* expand the pseudo-element to cover the post area */
    &::before {
      content: "";
      position: absolute;
      inset: 0;
    }
  }
  .tags a {
    position: relative;
  }
}
トレードオフ

3. JavaScriptでクリックをキャプチャする

JavaScript を使って、コンテナ要素がクリックをキャプチャし、メインリンクに委譲する方法です。

<div class="card" onclick="handleClick(event)">
  <h2>タイトル</h2>
  <p>コンテンツの説明</p>
  <a>詳細を見る</a>
</div>

<script>
  function handleClick(event) {
    if (!event.target.closest('a')) {
      window.location.href = 'https://example.com';
    }
  }
</script>
トレードオフ

Link Area Delegation は、こうしたワークアラウンドによる問題を解決するために、インタラクティブな要素のネスティングに関する新しいガイドラインや標準を設けることを目指しています。

現状検討されている4つの実装方法

上記を満たす Link Area Delegation の提案として、現時点では次の 4 つの実装方法が検討されています。

HTML で表現する方法の一つとして、linkarea属性とdefaultlink属性を使用する方法が提案されています。

冒頭の例と重複しますが、次のようにlinkarea属性を持つ<div>をクリックすることで、defaultlink属性を持つ最初の子孫要素が活性化されます。つまり、linkarea属性は、クリッカブルな領域を拡張するためのコンテナを示し、defaultlink属性は、そのコンテナのクリックイベント委譲先となります。

<div class="card" linkarea>
  <a href="/post?id=123" defaultlink>Post Title</a>
  <img ...>
  <button>Join</button>
  <button>Share</button>
</div>

defaultlink属性を持つ最初の子孫要素だけではなく、ID によってlinkarea属性を持つコンテナと紐づけられた、特定の子孫要素を活性化することも可能です。

次の例では、最初の<a>がページ内パーマリンクとして機能し、コンテナクリックで発火されるアクションは「Learn More」のリンクになります。

<section class="card" linkarea="card123-cta">
  <h2><a href="#topic-123" id="card123-title">Post Title</a></h2>
  <img ... alt="">
  <p>...</p>
  <a href="/post?id=123" id="card123-cta" aria-labelledby="card123-cta card123-title">Learn More</a>
  <button>Join</button>
  <button>Share</button>
</section>

この提案が採用された場合、ARIA in HTML 仕様に新しいルールを追加し、この属性が使用される要素に許可される役割を制限する必要があるかもしれません。例えば、<div role=button linkarea>のような場合、現在では div に任意の ARIA ロールを設定できますが、このインスタンスでは何らかの検証メッセージを生成するべきだと述べられています。

2. <linkarea> Element

linkarea属性の代わりに、<linkarea>を導入する方法も提案されています。

<section class="card">
  <linkarea>
    <a href="/post?id=123" defaultlink>Post Title</a>
    <img ...>
    <button>Join</button>
    <button>Share</button>
  </linkarea>
</section>

しかし、HTML 要素の柔軟性を考えると、この方法は望ましくありません。

例えば、次のようなマークアップを実現するには、<tr>が新しい要素タイプを含むよう、HTML パーサを変更する必要があり、ブラウザ側からすると、基本的には避けたい実装です。また、全てのブラウザで<linkarea>実装による HTML パーサ変更の実装が完了しない限り、<tr>のContent Modelに反して挙動が壊れる可能性があり、段階的なサポートが難しいです。

<table>
  <tr>
    <linkarea>
      <th><a href="/details?id=1" defaultlink>Title</a></th>
      <td>...</td>
      <td>...</td>
      <td><button>Edit</button></td>
    </linkarea>
  <tr>
</table>

一方、属性を用いた手法では、UA はポリフィルを通じて段階的に新しい動作をサポートするよう実装でき、パースの問題を軽減できます。

3. Use CSS pointer-area Property

HTML ではなく CSS を使用する方法も考えられています。HTML 属性の代わりに、新しい CSS プロパティを提供することで、クリッカブルな領域を拡張する方法です。

<section class="card">
  <a href="/post?id=123">Post Title</a>
  <img ...>
  <button>Join</button>
  <button>Share</button>
</section>
.card { pointer-area: contain; }
.card a:nth-child(1) { pointer-area: expand; }

この方法では、事前に与えられた HTML の構造に CSS を適用することでクリッカブルな領域が決定されます。つまり、動的に HTML 構造が変更された場合は対応が複雑になる可能性があります。

4. Use Invokers

現在、Open UI で別 Proposal として検討が進んでいる、Invoker Commandsを使用する方法も提案されています。

commandでは、clickcontextmenutouchkeypressなどのCommandEventを Dispatch し、commandforでは、CommandEvent を受け取る ID を持つ要素との関連付けをします。

<section class="card" commandfor="card123-title" command="click">
  <a href="/post?id=123" id="card123-title">Post Title</a>
  <img ...>
  <button>Join</button>
  <button>Share</button>
</section>

この手法を使用するには、属性を2つ宣言する必要があり、かつ ID を頻繁に宣言する必要も出てくる可能性があります。 とはいえ、この手法を用いると、クリックを「リンク」以外の要素、例えば、<button>に委譲することも可能なため、前向きに検討されているようです。

おわりに

今回は、Link Area Delegation の全体像について、広く浅く紹介しました。

筆者自身が、以前カードコンポーネントを実装する際、Nested Links を実現したく、その時に読んだSara Soueidanの記事がとても勉強になりました。

Nested Links Without Nesting Links
– The personal website of Sara Soueidan, inclusive design engineer
Nested Links Without Nesting Links favicon www.sarasoueidan.com
Nested Links Without Nesting Links

その後、Link Area Delegation が Open UI で提案され、自分した Hacky な実装を標準的な方法で解決する仕様策定が検討されていることを知って興味をもっていたのが、本エントリ執筆の背景です。

Open UI Advent Calendarでは、Customizable Select Element を中心に、さらに詳しく Open UI に関する記事を書いているので、そちらも読んでいただけると嬉しいです🤸🏻‍♀️

Thank you for reading!⛄

Appendix