タブやアコーディオンの非表示コンテンツにはhidden="until-found"を使うべし

広告
タブやアコーディオンの非表示コンテンツにはdisplay:none
がよく用いられますが、hidden="until-found"
を利用するほうがメリットがあります。
hidden=“until-found”で非表示にしたコンテンツはページ内検索でアクセスできる
until-found
はhidden
属性に新たに追加された属性値です。
従来のhidden
属性とは違い、until-found"
属性値を指定した場合はブラウザのページ内検索やページ内リンクでそのコンテンツが検出された場合、自動でhidden
属性が取り除かれて表示することができます。
従来のdisplay:none
を使用した非表示ではコンテンツ内にページ内検索でマッチすべきワードがあったとしても検出できませんでしたが、hidden="until-found"
を使えばページ内検索でヒットさせることが可能となります。
hidden="until-found"
は2024年4月現在ではGoogle ChromeとMicrosoft Edgeのみの対応となっております。SafariやFirefoxではサポートされていませんが、従来のhidden
属性として扱われるため、display:none
する実装と動作に変わりはありません。そのため全モダンブラウザの対応を待たずとも今から導入して差し支えないでしょう。
当ブログの目次のアコーディオンやフッターのカテゴリタブはこのhidden="until-found"
を使って実装されていますので、Chrome系ブラウザ限定にはなりますが是非ページ内検索を使ってヒットするか試してみてください。
トリガー部分はa要素で実装するのがオススメ
details
を使わないアコーディオンのボタンや、タブコンテンツのタブ部分を多くの方がbutton
要素で実装されているかと思いますが、hidden="until-found"
を使用するのならhref
属性と非表示コンテンツのid
を紐づけてrole
属性の値を適切なものに変更したa
要素で実装するのをオススメします。
<a role="button" href="#panel" aria-expanded="true" aria-controls="panel">ボタン</a>
<a role="tab" href="#tabpanel0" id="tab0" aria-selected="true" aria-controls="tabpanel0">タブ</a>
前項で触れましたが、ページ内リンクでそのコンテンツが検出された場合は自動でhidden
属性が取り除かれて表示されるというのが理由です。とは言え、JSでデフォルトの動作を無効化して制御するから関係ないじゃんと思われる方もいるとは思います。
しかし、制御しているJSが何らかの事情で動作しなくなったり、そもそもJSが動かない環境でページを閲覧している場合、button
要素ではそのコンテンツを開くことができません。一方a
要素を利用すれば前述した仕様によりJS無効環境でもコンテンツを開くことが可能になります。
しかし、これだけではhidden="until-found"
をサポートしていないSafariやFirefoxではJS無効環境下でコンテンツを開くことができません。そこで、サポートされていない場合は従来のhidden
属性として扱われる仕様を利用し、:target
擬似クラスを使用することで1行のCSS宣言でJS無効環境のフォールバックが可能となります。(トリガーのデフォルトの動作をpreventDefault()
で抑止していることが前提です)
.panel:target { display: revert;}
/* 右クリックorタップ長押しで遷移した時にパネルが開いたままなのを嫌うなら */@media (scripting: none) { .panel:target { display: revert; }}
原則的にpreventDefault()
を行っているのなら:target
擬似クラスで指定している宣言は腐るため、トリガーを押下して:target
擬似クラスが有効になる=JSが動いていないということになります。ただし、トリガーを右クリックorタップ長押しで別タブで開いた際は:target
擬似クラスが有効になりhidden="until-found"
が非対応の環境で該当箇所が開きっぱなしになってしまうため、それを嫌う場合はscriptingメディア特性でJSを無効化した場合のみに絞るとよいでしょう。
結果としてJS無効環境を考えるのならばhidden="until-found"
のサポート関係なく、アコーディオンのボタンやタブコンテンツのタブ部分はa
要素で実装するのが良いでしょう。
SPAのようにJSの利用が前提となっているWebアプリケーションであれば別ですが、一般的なWebサイトであれば実装コストとの兼ね合いにはなりますがJSが無効になった場合でもなるべく多くの情報を得られるように対応しておきたいところです。
また、a
要素をそのまま利用すると支援技術は「リンク」と読み上げるので、ボタンであればrole="button"
タブであればrole="tab"
を指定することも忘れずに。
リセットCSSのhidden属性に対するdisplay:noneには注意
古いリセットCSSやnormalize.cssを利用している場合、次のような指定が含まれているとhidden="until-found"
を指定してもページ内検索やページ内リンクでコンテンツを開くことができなくなります。
[hidden] { display: none !important;}
もしこのような指定がされている場合は次のCSSに書き換えるか、acab/reset.cssのようなhidden="until-found"
に対応した指定がされているリセットCSSを利用すると良さそうです。
[hidden]:not([hidden='until-found']) { display: none !important;}
hidden=“until-found”のデフォルトのUAスタイルシートはcontent-visibility:hiddenなので注意
従来のhidden
属性のデフォルトのUAスタイルシートはdisplay:none
ですが、hidden="until-found"
の場合はcontent-visibility:hidden
で非表示が行われます。
content-visibility:hidden
は内容物に対してはdisplay:none
に近い動きをしますが、指定している要素そのものにはmargin
, border
, padding
, background
がレンダリングされます。
現状ではSafari、Firefoxともにcontent-visibility
をサポートしていないこともあり、hidden="until-found"
を指定している要素にレンダリングされるスタイルをあてるとブラウザ間で非表示の際のスタイリングに差異が生じてしまいます。
なるべくならhidden="until-found"
を指定している要素にそのようなスタイルはあてないほうが良いでしょう。
hidden=“until-found”を利用したアコーディオンの実装例
最後にhidden="until-found"
を利用したアコーディオンの実装例を紹介します。当ブログの目次で使われているものと同じものです。
こちらのJSの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。
アコーディオンの実装には基本的にdetails
を用いると思いますが、以下のHTML構造のようにランドマークの下にトリガーとなる見出しと開閉コンテンツといった構成の場合はdetails
を使わずにhidden="until-found"
とaria-expanded
属性などで実装することを推奨します。
<section aria-labelledby="headingId"> <h2 id="headingId"> <a role="button" href="#panelId" aria-expanded="false" aria-controls="panelId">見出し</a> </h2> <div id="panelId" hidden="until-found">コンテンツ</div></section>
理由としてはdetails
だとsummary
要素の中に見出し要素を含むのはHTMLの仕様違反ではないものの、見出しのrole
が消失するアクセシビリティの問題が孕んでいるとのことなのでこちらの構成にしたほうが良さそうという判断です。
role
消失の件はMarkuplint開発者の平尾さんから以前教わりました。ありがとうございます。
動作は後に投稿した記事「details要素のname属性を使用した排他的なアコーディオンの実装例」の流用となりますので、アニメーションの動作および各オプション(アニメーションの設定や印刷時に全展開するオプションなど)についてはそちらの記事を参照してください。
details
要素を用いるアコーディオンと違う点として、open
属性の切り替えを行う代わりにhidden
属性とaria-expanded
属性の切り替えを行うようにします。
const isOpened = (button: HTMLAnchorElement): boolean => { return button.getAttribute('aria-expanded') === 'true'}
let isAnimating: boolean = false
type AnimationOptions = Omit<AccordionOptions, 'buttonSelector' | 'panelSelector' | 'printAll'>
const toggleAccordion = ( button: HTMLAnchorElement, panel: HTMLElement, options: AccordionOptions, show: boolean,): void => { if (isOpened(button) === show) return
isAnimating = true if (show) panel.removeAttribute('hidden') button.setAttribute('aria-expanded', String(show)) panel.style.overflow = 'clip'
const { blockSize } = window.getComputedStyle(panel) const keyframes = show ? [{ maxBlockSize: '0' }, { maxBlockSize: blockSize }] : [{ maxBlockSize: blockSize }, { maxBlockSize: '0' }]
const isPrefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches const animationOptions: AnimationOptions = { duration: isPrefersReduced ? 0 : Math.max(0, options.duration || 0), easing: options.easing, }
const onAnimationEnd = () => { requestAnimationFrame(() => { panel.style.overflow = '' if (!show) panel.setAttribute('hidden', 'until-found') isAnimating = false }) }
requestAnimationFrame(() => { const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd) })}
details
要素のname
属性のように排他的な動作を希望する場合は、初期化時に参照するアコーディオンの親要素にdata-name
属性を指定してください。
<section aria-labelledby="headingId" data-name="groupA"> <h2 id="headingId"> <a href="#panelId">見出し</a> </h2> <div id="panelId" hidden="until-found">コンテンツ</div></section>
任意のアコーディオン要素を開いて表示しておきたい場合はhidden
属性を取り除いておいてください。
<section aria-labelledby="heading1Id" data-name="groupA"> <h2 id="heading1Id"> <a href="#panel1Id">見出し</a> </h2> <!-- 最初に開いておきたい要素のhidden属性は取り除く --> <div id="panel1Id">コンテンツ</div></section><section aria-labelledby="heading2Id" data-name="groupA"> <h2 id="heading2Id"> <a href="#panel2Id">見出し</a> </h2> <div id="panel2Id" hidden="until-found">コンテンツ</div></section>
JS無効環境ではアコーディオンとしての機能が失われるためrole="button"
と、JSで操作することが前提のaria-expanded
属性とそれに関連するaria-controls
属性は初期化時にJSで付与するようにします。
const initializeAccordion = (element: HTMLElement, options: AccordionOptions = {}): void => { // ...
const panelId = panel.getAttribute('id') if (!panelId) { console.error('initializeAccordion: panel id is required.') return }
setAttribute(button, panel, panelId)
// ...}
const setAttribute = (button: HTMLAnchorElement, panel: HTMLElement, panelId: string): void => { button.setAttribute('role', 'button') button.setAttribute('aria-expanded', String(!panel.hasAttribute('hidden'))) button.setAttribute('aria-controls', panelId)}
加えて、button
要素やsummary
要素とは違ってa
要素はスペースキーでの操作ができないためkeydown
イベントを追加してそれらに合わせます。
const handleClick = ( event: MouseEvent | KeyboardEvent, element: HTMLElement, button: HTMLAnchorElement, panel: HTMLElement, options: AccordionOptions,): void => { event.preventDefault()
if (isAnimating) return
toggleAccordion(button, panel, options, !isOpened(button))
if (isOpened(button)) hideOtherAccordion(element, options)}
const handleKeyDown = (event: KeyboardEvent): void => { if (event.key === ' ') { handleClick(event) }}
最後に、beforematch
イベントリスナーを使用してページ内検索で開いた時にaria-expanded
の値を変えるように変更し、data-name
属性でグルーピングがされている場合には既に展開されている要素を閉じるようにします。ちなみにページ内検索時のアニメーションは邪魔なので無効にします。
const handleBeforeMatch = ( element: HTMLElement, button: HTMLAnchorElement, panel: HTMLElement, options: AccordionOptions,): void => { button.setAttribute('aria-expanded', 'true') hideOtherAccordion(element, options, false)}
details
の実装でも同様ですが、アニメーションでheight
の値を操作する際はCSS側でheight:0
を指定するのは避けて、animation
を使用しているのならfill-modeにforwards
やboth
を指定しないでください。また、transition
を使用している場合は完了したらheight
の値を初期値(auto
)に戻すのを忘れないでください。閉じている時にheight:0
が指定されているとページ内検索やJS無効環境下でのクリックで要素を開くことができなくなります。
また、閉じた後にdisplay:none
をうっかり指定しないように注意しましょう。