StimulusとTailwind CSSを用いて、アクセシビリティを考慮しつつ、キーボード・インタラクションを全て含んだアコーディオンを実装してみたいと思います。
アコーディオンとは
アコーディオンは、インタラクティブな見出しが垂直に積み重ねられたものです。それぞれの見出しには、セクションの内容を代表するタイトル、抜粋、サムネイルが含まれています。 見出しは、ユーザが関連する内容のセクションを表示したり隠したりできるように、コントロールとして機能します。 アコーディオンは、一般的に、複数のコンテンツのセクションをシングル・ページに表示するとき、スクロールの必要性を軽減するために使用されます。
テキストの改行が多いスマホサイトなどでよく見かけるイメージです。
キーボード・インタラクション全部入りアコーディオン
アクセシビリティを考慮したアコーディオンの実装方法についてはいくつか記事があるのですが、オプションになっているキーボード・インタラクション(下向き矢印キー、上向き矢印キー、ホームキー、エンドキー)も実装している記事は見当たらなかったので、今回これらも全部入れて実装してみました。
See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_1 by Yoruaki (@yoruaki) on CodePen.
HTMLを書いてみる
<div
class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
<section>
<h3>
<button
type="button"
id="vegetable"
aria-controls="panel-vegetable"
aria-expanded="false"
class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>
<img
src="https://yoruaki.com/assets/qiita/VwJBvye/triangle.svg"
width="16"
height="16"
alt=""
class="transition-all"
>やさい
</button>
</h3>
<div
id="panel-vegetable"
role="region"
aria-labelledby="vegetable"
inert
class="grid grid-rows-[repeat(1,_minmax(0,_0fr))] pl-6 overflow-hidden transition-all"
>
<ul class="pb-3">
<li>
<a
href="https://example.com/potato"
target="_blank"
class="text-blue-700 underline hover:text-red-600"
>じゃがいも</a>
</li>
<li>
<a
href="https://example.com/carrot"
target="_blank"
class="text-blue-700 underline hover:text-red-600"
>にんじん</a>
</li>
<li>
<a
href="https://example.com/onion"
target="_blank"
class="text-blue-700 underline hover:text-red-600"
>たまねぎ</a>
</li>
</ul>
</div>
</section>
<section>...</section>
<section>...</section>
</div>
要素の説明
要素 | 属性 | 説明 |
---|---|---|
<div> |
アコーディオン全体のラッパー。 | |
class="..." |
Tailwind CSSでスタイル調整。 | |
<section> |
アコーディオン・ヘッダー、アコーディオン・パネルを含んだセクション。 | |
<h3> |
アコーディオン・ヘッダー。 今回は見出し要素を使っているが、 role="heading" を持った要素なら何でもOK。aria-level は問わない(<h1-h6> どれでもOK)。 |
|
<button> |
セクションの内容を表示したり隠したりするコントローラー。 また、 heading 要素の中はこのbutton 要素のみでなければならない。 |
|
type="button" |
ボタン。 | |
id="vegetable" |
アコーディオン・パネルからのaria-labelledby の指定先用のID。 |
|
aria-controls="panel-vegetable" |
このボタンがどのアコーディオン・パネルを制御しているのか。 今回は #panel-vegetable
|
|
aria-expanded="false" |
このボタンの制御先が展開されているか(true )、折りたたまれているか(false )。今回はデフォルトは全て閉じている状態のため、 false 。 |
|
class="..." |
Tailwind CSSでスタイル調整。 | |
<img> |
class="transition-all" |
装飾用の画像。 アコーディオン・パネル非表示時は右向き三角、展開時は下向き三角にアニメーションさせる。 それ以外はいつもの属性を付与。 |
<div> |
アコーディオン・パネル。 | |
id="panel-vegetable" |
アコーディオン・ヘッダーからの制御(aria-controls )のための指定用ID。 |
|
role="region" |
重要なコンテンツであると伝えたいが、他に適切なランドマークロールがない場合に使用する(アコーディオンを意味するロールは今のところ無い)。これは、他のランドマークロールのどれも適切でないときでも、汎用のランドマークを提供することで、人々が容易にナビゲートできるようにするために使用されます。 |
|
aria-labelledby="vegetable" |
この要素(リージョン)のラベル付け。 今回だと #vegetable 。つまり、「やさい」に関するものであると示している。 |
|
inert |
パネルはデフォルトでは閉じている状態なので、この属性を付与することでフォーカス関連のイベントや支援技術からのイベント、ユーザーから送られる入力イベントもブラウザが無視するようになる。aria-hidden="true" やdisplay:none; でも同じことができるが、今回はアニメーションもさせたかったのでこちらを選択。 |
|
class="..." |
Tailwind CSSでスタイル調整。 | |
<ul> <li> <a>
|
フォーカスできれば何でもよかったので、リンクをリストで並べてスタイル調整しただけのもの。 今回は動作確認のためにフォーカス可能な要素にしたが、フォーカスできない要素でもOK。 |
Accordion Controller
まずはアコーディオンにStimulusを効かせるため、全体のラッパーにdata-controller
属性を付与します。
<div
data-controller="accordion"
class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
<section>...</section>
<section>...</section>
<section>...</section>
</div>
CSS Classes
次に、アコーディオン・ヘッダーとアコーディオン・パネルが開いているときのclassを指定します。
アコーディオン・ヘッダーでは、右向きの三角(▶)を下向き(▼)にしたいです。
なのでtransform: rotate(90deg);
が指定されているrotate-90
をセットします。
アコーディオン・パネルでは、高さ0の閉じているパネルを、開いている状態(コンテンツの高さ)にしたいです。
通常であればheight: 0;
→ height: auto;
で問題ないのですが、残念ながらこれだとtransition
プロパティでアニメーションしてくれません。
なので今回は別のアプローチで、grid-template-rows: repeat(1, minmax(0, 1fr));
になるgrid-rows-1
セットします。
grid-template-rows: repeat(1, minmax(0, 0fr));
→ grid-template-rows: repeat(1, minmax(0, 1fr));
に変化することで、意図したアニメーションになってくれます。
<div
data-controller="accordion"
data-accordion-open-header-class="rotate-90"
data-accordion-open-panel-class="grid-rows-1"
class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
<section>...</section>
<section>...</section>
<section>...</section>
</div>
このテクニックは、コリスの以下の記事を参考にしています。
Targets
次に取得したい要素にdata-accordion-target
属性を付与します。
今回はアコーディオン・ヘッダー(button
要素)とアコーディオン・パネル(div
要素)を取得します。
<div
data-controller="accordion"
data-accordion-open-header-class="rotate-90"
data-accordion-open-panel-class="grid-rows-1"
class="m-4 w-40 border border-black rounded-md overflow-hidden leading-relaxed"
>
<section>
<h3>
<button
type="button"
id="vegetable"
aria-controls="panel-vegetable"
aria-expanded="false"
data-accordion-target="header"
class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>...</button>
</h3>
<div
id="panel-vegetable"
role="region"
aria-labelledby="vegetable"
inert
data-accordion-target="panel"
class="grid grid-rows-[repeat(1,_minmax(0,_0fr))] pl-6 overflow-hidden transition-all"
>
...
</div>
</section>
<section>...</section>
<section>...</section>
</div>
基本機能
アコーディオン・ヘッダーをクリックしたらアコーディオン・パネルが開く、もう一度アコーディオン・ヘッダーをクリックしたらアコーディオン・パネルが閉じる。
これの繰り返しです。
Actions (Click Event)
アコーディオン・ヘッダーにdata-action
属性を付与します。
<button
type="button"
id="vegetable"
aria-controls="panel-vegetable"
aria-expanded="false"
data-accordion-target="header"
data-action="click->accordion#togglePanel"
class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>
...
</button>
Stimulusでは、アコーディオン・ヘッダー(button
要素)のaria-expanded
属性のtrue/falseをトグルさせ、子要素のimg
要素ではopenHeaderClass
をトグルさせます。
次にアコーディオン・ヘッダーに紐づくアコーディオン・パネルでopenPanelClass
とinert
属性をトグルさせます。
app.register('accordion', class extends Controller {
static targets = ['header', 'panel'];
static classes = ['openHeader', 'openPanel'];
togglePanel(evt) {
const btn = evt.currentTarget;
const img = btn.querySelector('img');
const panelId = btn.getAttribute('aria-controls');
const panel = this.panelTargets.find(p => p.id === panelId);
btn.ariaExpanded = (btn.ariaExpanded === 'true') ? 'false' : 'true';
if (img) {
img.classList.toggle(this.openHeaderClass);
}
if (panel) {
panel.classList.toggle(this.openPanelClass);
this.toggleInertAttribute(panel);
}
}
toggleInertAttribute(panel) {
if (panel.hasAttribute('inert')) {
panel.removeAttribute('inert');
} else {
panel.setAttribute('inert', '');
}
}
});
以上で、基本的なアコーディオンに関しては一旦完成です。
アコーディオンのキーボード・インタラクション
やっと今回のメインですが、まずはアコーディオンにどのようなキーボード・インタラクションがあるのか確認してみます。
キーボード操作 | 説明 |
---|---|
Enter or Space | フォーカスが折りたたまれたパネルのヘッダーにあるとき、対応するパネルを開く。もし仕様により一つのパネルだけ開くことを許していて、他のパネルが開かれるた場合、そのパネルを閉じる。 フォーカスが開いたパネルのヘッダーにあるとき、もし仕様が閉じることをサポートしていた場合、そのパネルを閉じる。 いくつかの仕様では、常に一つのパネルが開かれていることを要求し、一つのパネルだけが開くことを許可し、それらは閉じる機能をサポートしない。 |
Tab | フォーカスを次のフォーカス可能な要素に移動する。アコーディオンの全てのフォーカス可能な要素は、ページのタブ・シーケンスの中に含まれる。 |
Shift + Tab | Tabの逆。 |
まずはここまでが基本的なアコーディオンのキーボード・インタラクションです。
Enter or Spaceキーの説明についてですが、アコーディオンが開くパネルは常に1つで、他のパネルを開くと、すでに開いていたパネルは閉じる。また、それらは閉じる機能を持っておらず、全て閉じているという状態がない(常に1つのパネルが開かれている)アコーディオンというものです。
昔は見た記憶があるのですが、今ではそれぞれに開閉機能があり、全部開くことも全部閉じることも可能なアコーディオンが主流だと思います。公式のサンプルもそうなってますしね。
また、これらのキーボード・インタラクションはbutton
要素を使用することで全てクリアすることができます。なるべくbutton
要素を使いましょう。
オプション(任意)扱いのキーボード・インタラクション
キーボード操作 | 説明 |
---|---|
Down Arrow | アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスは次のヘッダーに移動する。 フォーカスが最後のアコーディオン・ヘッダーにある場合、最初のアコーディオン・ヘッダーに移動する。 |
Up Arrow | Down Arrowの逆。 |
Home | アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスを最初のアコーディオン・ヘッダーに移動する。 |
End | アコーディオン・ヘッダーの上にフォーカスがある場合、フォーカスを最後のアコーディオン・ヘッダーに移動する。 |
アコーディオン・ヘッダーのフォーカスに関する挙動が主ですね。
Actions (Keyboard Event)
アコーディオン・ヘッダーにdata-action
属性を付与します。
<button
type="button"
id="vegetable"
aria-controls="panel-vegetable"
aria-expanded="false"
data-accordion-target="header"
data-action="
click->accordion#togglePanel
keydown.down->accordion#moveFocusNextHeader
keydown.up->accordion#moveFocusPrevHeader
keydown.home->accordion#moveFocusHomeHeader
keydown.end->accordion#moveFocusEndHeader
"
class="flex w-full items-center px-2 py-1 font-bold hover:bg-slate-100 focus:-outline-offset-4"
>
...
</button>
属性値を見ただけで何をしたいのかが直感的に分かりやすくて良いですね!
基本的にアコーディオン・パネルをdata-accordion-target
で取得して、それらは配列で取れるので、イベントが発生した要素のインデックス番号を取得して、次に移動するか前に移動するか、最初に移動するか最後に移動するか、を制御するだけです。
app.register('accordion', class extends Controller {
static targets = ['header', 'panel'];
moveFocusNextHeader(evt) {
this.moveFocus(evt, 1);
}
moveFocusPrevHeader(evt) {
this.moveFocus(evt, -1);
}
moveFocusHomeHeader(evt) {
evt.preventDefault();
const headers = this.headerTargets;
headers[0].focus();
}
moveFocusEndHeader(evt) {
evt.preventDefault();
const headers = this.headerTargets;
headers[headers.length - 1].focus();
}
moveFocus(evt, direction) {
evt.preventDefault();
const headers = this.headerTargets;
const length = headers.length;
const index = headers.indexOf(evt.currentTarget);
let newIndex = (index + direction + length) % length;
headers[newIndex].focus();
}
});
以上でキーボード・インタラクション全部入りアコーディオンの実装は完了です。
See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_1 by Yoruaki (@yoruaki) on CodePen.
なかなか使いやすいと思うんですよね。
記事を書いてる途中で気づいたこと
今回APGのAccordion Patternに沿って実装しつつ記事を書いていたため、最初は気づかなかったのですが、今だったらdetails
要素とsummary
要素使うよなぁ、と思いましたが後の祭り😇
ただ、details
要素を使用するとブラウザがデフォルトの開閉操作を処理するため、この標準動作がCSSのトランジションを阻害する可能性はあります。試しに作り直してみましたが、パネルのアニメーション部分が上手くいきませんでした🫠
details
要素とsummary
要素で書き直したアコーディオン
See the Pen QIITA_STIMULUS_ACCESIBLE_ACCORDION_2 by Yoruaki (@yoruaki) on CodePen.
最後に
StimulusとTailwind CSSを用いて、アクセシビリティを考慮しつつ、キーボード・インタラクションを全て含んだアコーディオンを実装してみました。
基本動作部分については、button
要素やdetails
要素、summary
要素を用いれば比較的簡単に実装できるかと思います。
また、オプションのキーボード・インタラクションについても、ヘッダーとパネルがたくさんあるようなアコーディオンだと、ヘッダー間を移動したいという要望もあるのではないでしょうか?
特に既存の操作を邪魔するわけでもないので、もし可能ならばこの機能が実装してあると、いろいろな環境の人に喜ばれるんじゃないかなと思った次第です。