details要素のname属性を使用した排他的なアコーディオンの実装例

カテゴリ:
Patterns
投稿日:

広告

details要素のname属性を使用した排他的なアコーディオンの実装メモです。

details要素で新たに追加されたname属性を使用すればJavaScriptを使用せずともWeb標準で排他的なアコーディオンを実現できます。

details要素のname属性とは

Open UIでの議論を発端として追加された、オープンされているdetails要素をひとつに限定する(どれかのdetails要素が開かれたら開いている他のdetails要素は閉じる)排他的なアコーディオンを実現できる属性です。

HTML Standard

html.spec.whatwg.org

使用方法はフォームパーツのname属性同様に、排他的なアコーディオンを実装したいdetails要素をname属性でグループ化するだけです。name属性にはユニークな値を指定できますが、空の文字列を使用するのは禁じられています。

マークアップの例
<section aria-labelledby="title">
<h1 id="title">アコーディオンの実装例</h1>
<details name="sample" open>
<summary>Details 1</summary>
<div>...</div>
</details>
<details name="sample">
<summary>Details 2</summary>
<div>...</div>
</details>
<details name="sample">
<summary>Details 3</summary>
<div>...</div>
</details>
</section>

上記のマークアップだけで次のような排他的なアコーディオンの実装を行うことができます。

アコーディオンの実装例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

現在のHTMLの仕様ではname属性を使用して複数のdetails要素をグループ化する場合には、details要素とそれらの関連要素を包含要素 (section要素やarticle要素など) にまとめて保持することが推奨されています。

また、グループを見出しで紹介することが合理的である場合は、包含要素に見出し要素を配置するようにします。

よくある質問のマークアップの例
<section aria-labelledby="title">
<h2 id="title">よくある質問</h2>
<details name="faq" open>
<summary>入会の方法について</summary>
<div>...</div>
</details>
<details name="faq">
<summary>入会金の支払い方法について</summary>
<div>...</div>
</details>
<details name="faq">
<summary>再入会について</summary>
<div>...</div>
</details>
</section>

実装時の注意点

details要素のname属性は低コストで排他的なアコーディオンを実装できるため非常に便利ですが、以下のような注意点があるため留意してください。

現時点で全モダンブラウザではサポートされていない

details要素のname属性は2024年4月時点ではFirefoxでサポートされていません。また、iOS含むSafariも17.2からサポートが開始されたばかりです。

従ってname属性を使用するアコーディオンは現時点では移行期間であると認識しておいたほうがいいでしょう。

name属性がサポートされていない環境でも排他的なアコーディオンを実現したい場合は、JSを使用してdetails要素のopen属性を切り替える必要があります。例えばChrome for Developersの文献では次のようなポリフィルが紹介されています。

ポリフィルの例
document.querySelectorAll('details[name]').forEach(($details) => {
$details.addEventListener('toggle', (e) => {
const name = $details.getAttribute('name')
if (e.newState == 'open') {
document.querySelectorAll(`details[name=${name}][open]`).forEach(($openDetails) => {
if (!($openDetails === $details)) {
$openDetails.removeAttribute('open')
}
})
}
})
})

もしくはname属性がサポートされていなくてもdetails要素の基本的な機能は失われないため、プログレッシブエンハンスメントの考え方に基づいてname属性をそのまま導入することも選択肢の一つだと思います。

open属性はグループにひとつしか存在してはいけない

「排他的」なので当たり前の話ではありますが、同じname属性値を指定したdetails要素のグループに複数のopen属性を指定することはNGです。

❗NG
<section aria-labelledby="title">
<h1 id="title">アコーディオンの実装例</h1>
<details name="sample" open>
<summary>Details 1</summary>
<div>...</div>
</details>
<details name="sample" open>
<summary>Details 2</summary>
<div>...</div>
</details>
<details name="sample">
<summary>Details 3</summary>
<div>...</div>
</details>
</section>

同じname属性値を指定したdetails要素のグループに複数のopen属性を指定した場合、グループの最初のdetails要素のみが開かれ、他のopen属性が指定されたdetails要素は閉じたままになります。また、JSで操作する場合も最後に操作されたdetails要素のopen属性のみが開いた状態になり、その他は問答無用で閉じられます。

このことは現在のHTMLの仕様にて以下のように記述されています。

The group of elements that is created by a common name attribute is exclusive, meaning that at most one of the details elements can be open at once. While this exclusivity is enforced by user agents, the resulting enforcement immediately changes the open attributes in the markup. This requirement on authors forbids such misleading markup.

つまり、同じname属性値を持つdetails要素のグループ内でopen属性を複数指定することは仕様に反する誤解を招くマークアップであるとみなされます。また、この排他性はユーザーエージェントによって強制され、強制が適応される場合はマークアップ内のopen属性が即座に変更されます。

同じname属性を内包してはいけない

次のマークアップのように同じname属性を内包するのは仕様で禁止されています。

❗NG
<section aria-labelledby="title">
<h1 id="title">アコーディオンの実装例</h1>
<details name="sample" open>
<summary>Details 1</summary>
<div>
<details name="sample">
<summary>Details 2</summary>
<div>...</div>
</details>
</div>
</details>
</section>

Forbid documents from nesting <details> in the same exclusive accordion. by dbaron · Pull Request #10004 · whatwg/html

Fixes #9968. This affects document conformance only but not implementation conformance. I left the prose short, rather than clutter the spec with exa...

github.com

実際に上記のようなマークアップをした場合、子要素のdetails要素を開くと親要素が閉じてしまうため、結果として子要素の中身を表示することができなくなります。

開閉のアニメーションを同時に行うことは仕様上不可能

前提として、details要素に閉じる時のアニメーションを加える場合はアニメーションの終了を待ってからopen属性を外すという処理が必要になります。

そのため、排他的なアコーディオンにおいてdetails要素が開かれる時のアニメーションと他のdetails要素が閉じる時のアニメーションを両立する場合、それぞれにopen属性を持たせる必要があります。しかし、name属性を用いる場合は先述した「open属性はグループにひとつしか存在してはいけない」制約により不可能ということになります。

ただし、ある工夫をすれば開閉のアニメーションを両立することは可能です。name属性を使用しつつ開閉のアニメーションを両立したい場合は次の実装例を参考にしてください。

開閉のアニメーションを両立した排他的なdetails要素の実装例

details要素のname属性を使用しつつ開閉のアニメーションを両立させる実装例です。当ブログで使用しているアコーディオンの実装を流用したものとなります。

開閉のアニメーションを両立したアコーディオンの実装例

See the Pen by tak-dcxi (@tak-dcxi) on CodePen.

排他的な機能が必要な場合はname属性を持たせ、排他的な機能が不要であればname属性を指定せずにそのままJSを適用してください。

任意のdetails要素を開いて表示しておきたい場合はマークアップでopen属性を指定してください。

マークアップ側の制約はdetails要素の子要素はsummary要素とmargin padding borderなどのスタイルが当たっていないdiv要素1つのみです。

マークアップ例
<section aria-labelledby="titleA">
<h2 id="titleA">グループA</h2>
<details name="groupA">
<summary>タイトル</summary>
<div>...</div>
</details>
<details name="groupA">
<summary>タイトル</summary>
<div>...</div>
</details>
<details name="groupA">
<summary>タイトル</summary>
<div>...</div>
</details>
</section>
<section aria-labelledby="titleB">
<h2 id="titleB">グループB</h2>
<!-- 最初に開いておきたい要素にはopen属性をつけておく -->
<details name="groupB" open>
<summary>タイトル</summary>
<div>...</div>
</details>
<details name="groupB">
<summary>タイトル</summary>
<div>...</div>
</details>
<details name="groupB">
<summary>タイトル</summary>
<div>...</div>
</details>
</section>
<section aria-labelledby="titleC">
<h2 id="titleC">排他的機能が不要な場合</h2>
<details>
<summary>タイトル</summary>
<div>...</div>
</details>
<details>
<summary>タイトル</summary>
<div>...</div>
</details>
<details>
<summary>タイトル</summary>
<div>...</div>
</details>
</section>

いつもの通りJSの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeDetailsAccordion

initializeDetailsAccordion. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

また、このJSを導入すればdetails要素のname属性がサポートされていない17.2未満のiOS SafariやFirefoxでも排他的アコーディオンを実現できますので、先述したポリフィルの導入は不要になります。

スタイリングに関してはsummary要素のデフォルトの三角形のアイコンはデザイン反映においては不要になるため非表示にしておくと良いでしょう。Safari以外はUAスタイルシートで指定されているdisplay:list-itemを上書きすることで非表示にできますが、Safariは::-webkit-details-marker疑似要素をdisplay:noneする必要があります。

デフォルトの三角形アイコンを非表示にする
.summary {
display: block flow; /* flexやgridでも可 */
&::-webkit-details-marker {
display: none; /* Safari用 */
}
}

他のスタイルは要件に依るため特筆することはありません。

アニメーションのポイント

Web Animations APIを使用する

今回の実装ではWeb Animations APIを使用してjQueryのslideToggle()的なアニメーションを実現しています。

アニメーションを実行する部分
const toggleAccordion = (
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
show: boolean,
): void => {
if (details.open === show) return
if (show) details.open = true
const { blockSize } = window.getComputedStyle(panel)
const keyframes = show
? [{ maxBlockSize: '0' }, { maxBlockSize: blockSize }]
: [{ maxBlockSize: blockSize }, { maxBlockSize: '0' }]
const animationOptions = {
duration: Math.max(0, options.duration || 0),
easing: options.easing,
}
const onAnimationEnd = () => {
requestAnimationFrame(() => {
if (!show) details.open = false
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}

slideToggle()的なアニメーションを行う際は予めパネル要素の高さの実寸値を取得しておく必要があります。

高さの取得にはscrollHeightなどを用いるのが主流だと思いますが、現在のCSSは原則的に論理プロパティで指定するのが定石であること、加えて当ブログのように縦書き対応も行っておきたいという理由からwindow.getComputedStyle()でアニメーション適用前のパネル要素のblock-sizeを取得するようにします。

const { blockSize } = window.getComputedStyle(panel)
const keyframes = show
? [{ maxBlockSize: '0' }, { maxBlockSize: blockSize }]
: [{ maxBlockSize: blockSize }, { maxBlockSize: '0' }]

アニメーションはblock-sizeではなく可変的でありつつも一定の制約を設けられるmax-block-sizeをアニメーションさせるのがベターでしょう。

次に、アニメーションのオプションはinitializeDetailsAccordion()の第二引数で受け取れるようにします。

export type AccordionOptions = {
duration?: number
easing?: string
}
const defaultOptions: AccordionOptions = {
duration: 300,
easing: 'ease-in-out',
}
const toggleAccordion = (
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
show: boolean,
): void => {
//...
const animationOptions: AccordionOptions = {
duration: Math.max(0, options.duration || 0),
easing: options.easing,
}
// ...
}
🤞Use
import initializeDetailsAccordion, { type AccordionOptions } from '@/scripts/initializeDetailsAccordion.ts'
document.addEventListener('DOMContentLoaded', () => {
const detailsElements = document.querySelectorAll('details') as NodeListOf<HTMLDetailsElement>
if (detailsElements.length === 0) return
const options: AccordionOptions = {
duration: 500,
easing: 'linear'
}
detailsElements.forEach((details) => {
initializeDetailsAccordion(details, options)
})
})

durationに負の値が差し込まれるとエラーになるのでMath.max()関数で最小値を0としておきます。

そしてrequestAnimationFrameを使用して、次の描画フレームでアニメーションを開始します。panel.animate(keyframes, animationOptions)でキーフレームとオプションに基づいてアニメーションを作成し、animation.addEventListener('finish', onAnimationEnd)を使用してアニメーションが終了したときにonAnimationEnd()関数が呼び出されるようにします。

const onAnimationEnd = () => {
requestAnimationFrame(() => {
if (!show) details.open = false
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})

先述したように閉じる際はアニメーションの終了を待ってからopen属性を取り外すようにします。

注意点として、いつものanimationの指定の癖でfillforwardsとかbothは指定しないようにしてください。アニメーション後もmax-block-sizeの値が固定値のままだとレスポンシブ時の改行の有無などで表示に不具合が出ますし、閉じた後に高さが0に固定されているとページ内検索時に中身がヒットした際に自動で開くというdetails要素のメリットを殺すことになります。

開閉のアニメーションを両立させる

先述したようにname属性を持たせたまま開閉のアニメーションを両立させるのは仕様上不可能です。

そのため、アニメーション開始時にname属性を取り除き、アニメーション終了時にname属性を復元することでやや強引に開閉のアニメーションを両立するようにします。

name属性をアニメーション中はremoveする
const toggleAccordion = (
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
show: boolean,
): void => {
// ...
if (detailsName) details.removeAttribute('name')
if (show) details.open = true
// ...
const onAnimationEnd = () => {
requestAnimationFrame(() => {
if (!show) details.open = false
if (detailsName) details.setAttribute('name', detailsName)
// ...
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}

最初は初期化時にname属性をdata-name属性に置き換えることも検討しましたが、ページ内検索時に排他的で無くなるためアニメーション中だけ取り外すほうがいいという判断をしました。details要素のname属性がサポートされていない環境では検索時の排他性は失われますが、details要素のページ内検索のサポートは現状name属性がサポートされているChrome系のブラウザのみなので考えないこととします。

アニメーション中だけoverflow:clipを適用する

slideToggle()的なアニメーションを実装する際はoverflowプロパティを使用してコンテンツのはみ出しを防ぐ必要がありますが、今回の実装ではアニメーション中のみにoverflow:clipを適用しています。

アニメーション開始時に適用し、終了したら取り除く
const toggleAccordion = (
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
show: boolean,
): void => {
// ...
panel.style.overflow = 'clip'
const onAnimationEnd = () => {
requestAnimationFrame(() => {
panel.style.overflow = ''
// ...
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}

実装例ではパネル要素に十分なpaddingが設けられているため問題ありませんが、paddingが無いor小さい場合にoverflow:clipが有効なままだと子要素のフォーカスリングが意図せずに切り取られてしまう可能性があります。

なるべくであればfocusableな要素を含む親要素に不必要なoverflowプロパティの指定は避けたほうが良いため、万が一のリスクを避けるためにもアニメーション終了時にはoverflowプロパティをリセットするようにしています。

また、あくまで要素のはみ出しを防ぐ目的のみであればoverflow:hiddenよりもoverflow:clipを優先したほうが良いでしょう。overflow:clipはプログラム的なスクロールを含むすべてのスクロールを禁止できるのに加えて、overflow:hiddenはスクロールコンテナを生成し、新しい整形コンテキストも作成する仕様が災いして予期せぬ不具合を引き起こす可能性があります。

マークアップ側の条件が増えてしまうため今回は使用しておりませんが、grid-template-rowsをアニメーションしてslideToggle()的なアニメーションを行う場合はoverflow:hiddenでなければいけないなど、場合によってはoverflow:clipではなくoverflow:hiddenを選択するケースも存在します。

また、動的なコンテンツが差し込まれる場合はcontain:contentを指定するほうがパフォーマンス面で有利になるメリットがありますが、アニメーション中のみに適用するとガタツキが生じる場合があるのでフォーカスリングの問題は意識しつつ恒久的に指定しておいたほうがいいでしょう。

なお、grid-template-rowsのアニメーションについては過去に投稿した記事「アコーディオンのスライドアニメーションはCSS2行で実装できる」にて解説しています。

視差効果(アニメーション)を減らす設定がされている場合にはdurationを0にする

過去記事で何度か説明しているため詳細は省きますが、視差効果(アニメーション)を減らす設定がされている場合にはアニメーションを行わないようにします。

window.matchMedia('(prefers-reduced-motion: reduce)').matchesで設定がされているかを確認し、設定がされている場合はアニメーションの間隔を0にします。

const isPrefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const animationOptions: AccordionOptions = {
duration: isPrefersReduced ? 0 : Math.max(0, options.duration || 0),
easing: options.easing,
}

アニメーション中は連打を防止する

アニメーションの連打防止策としてグローバル変数isAnimatingを使用します。アニメーションが開始するとisAnimatingtrueに設定され、アニメーションが終了するとfalseに戻ります。

isAnimatingフラグで連打防止を行う
let isAnimating: boolean = false
const toggleAccordion = (
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
show: boolean,
): void => {
if (details.open === show) return
isAnimating = true
// ...
const onAnimationEnd = () => {
requestAnimationFrame(() => {
panel.style.overflow = ''
// ...
isAnimating = false
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}

summary要素に登録したhandleClickイベント内でisAnimatingtrueの時は早期リターンするようにします。

handleClick関数
const handleClick = (
event: MouseEvent,
details: HTMLDetailsElement,
panel: HTMLElement,
options: AccordionOptions,
detailsName: string | null,
): void => {
event.preventDefault()
if (isAnimating) return
toggleAccordion(details, panel, options, detailsName, !details.open)
if (details.open) hideOtherAccordions(details, options, detailsName)
}

details要素のアニメーションに関する他の記事では、アニメーション中のdetails要素にカスタムデータ属性を持たせて連打防止フラグとする方法が紹介されています。しかし、複数のdetails要素がアニメーション中にname属性の復元タイミングがズレるとひとつのグループが複数のopen属性を持つ可能性があります。

そのため、今回のケースではアニメーション中は他のアコーディオンの動作を封じる方が不具合の防止になるため、このような施策を行うようにします。

オプションで印刷時に全てのアコーディオンを開くようにする

排他的アコーディオン自体が持つデメリットとして、全てのコンテンツを開いて印刷することができないというものがあります。

印刷の問題に関してはOpen UIで議論があったようですが、印刷時の強制的な展開は行わない結論になったようです。

`<details name>` による排他的アコーディオンの実現検索

`<details>` 要素に `name` 属性が追加され、排他的アコーディオンを実現することができるようになりました。

blog.w0s.jp

そのため、印刷する時は全てのアコーディオンを開いておいたほうが良さそうなコンテンツの場合はオプションで印刷時に全展開するようにします。

印刷時の設定を追加する
export type AccordionOptions = {
duration?: number
easing?: string
printAll?: boolean
}
const defaultOptions: AccordionOptions = {
duration: 300,
easing: 'ease-in-out',
printAll: false,
}
const initializeDetailsAccordion = (details: HTMLDetailsElement, options: AccordionOptions = {}): void => {
// ...
if (mergedOptions.printAll) {
window.addEventListener('beforeprint', () => handleBeforePrint(details, detailsName))
window.addEventListener('afterprint', () => handleAfterPrint(details, detailsName))
}
}
const openStatusAttribute = 'data-open-status'
const handleBeforePrint = (details: HTMLDetailsElement, detailsName: string | null): void => {
if (!details) return
details.setAttribute(openStatusAttribute, String(details.open))
if (detailsName) details.removeAttribute('name')
details.open = true
}
const handleAfterPrint = (details: HTMLDetailsElement, detailsName: string | null): void => {
if (!details) return
if (detailsName) details.setAttribute('name', detailsName)
details.open = details.getAttribute(openStatusAttribute) === 'true'
details.removeAttribute(openStatusAttribute)
}

beforeprintイベントが検知したら現在展開されているdetails要素をdata-open-status属性に保存した後にname属性を取り除いて全展開します。その後、afterprintイベントが検知したらname属性を戻した後にdata-open-status属性を参照して印刷前の展開状態を復元します。

ただし、アコーディオンメニューのように別に印刷しても嬉しくない(むしろ邪魔になり得る)要素の場合は、全展開しても余計な印刷が含まれるだけでなくコピー用紙の枚数やインクの量に負担を掛けることになるため、あくまでオプションで全展開がベストな場合以外にはfalseにしておいたほうが良さそうです。

なんでもかんでもdetails要素を使えばいいってわけではない

以前投稿した記事「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」で触れたように、ランドマークの下にトリガーとなる見出しと開閉コンテンツといった構成の場合はdetails要素を使わずにhidden="until-found"aria-expanded属性などで実装するほうが望ましいです。(理由はそちらの記事参照)

hidden="until-found"を使用したアコーディオンの実装例に関しても「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」にて紹介しています。そちらのコードも参考にしてください。

また、アコーディオンはユーザーに展開させる手間を与える機能であるため、本当にそのアコーディオンが必要か? は設計段階で考慮したほうがいいかもしれません。

参考リンク

本文上部へ戻る

折りたたみメニュー