スライダー(カルーセル)の実装をアクセシビリティ的な側面から考えた時、スライダーの機能やデザインによって考慮する点が色々とありそうだったので、いくつかのスライダーの実装を例に検討してみます。
要件
まずはARIA Authoring Practices Guide (APG)のCarousel (Slide Show or Image Rotator) Patternのページを参考に、考慮する内容を確認します。
参考ページで紹介されている種類のうち、今回は基本パターンとタブパターンの2つを試してみます。
構成要素
参考ページ内で紹介されている、スライダーの構成要素とその説明です。
- Slide: スライダー内にある単一のスライド要素。
- Rotation Control: 自動切り換えを開始/停止できるボタン。
- Next Slide Control: 次のスライドを表示するボタン。
- Previous Slide Control: 前のスライドを表示するボタン。
- Slide Picker Controls: 任意のスライドを表示するボタン。ドット。
スライダーのパターンについて
一般的にスライダーに必要な機能について確認します。
- Buttons for displaying the previous and next slides.
- Optionally, a control, or group of controls, for choosing a specific slide to display.
For example, slide picker controls can be marked up as tabs in a tablist with the slide represented by a tabpanel element.- If the carousel can automatically rotate, it also:
- Has a button for stopping and restarting rotation.
This is particularly important for supporting assistive technologies operating in a mode that does not move either keyboard focus or the mouse.- Stops rotating when keyboard focus enters the carousel.
It does not restart unless the user explicitly requests it to do so.- Stops rotating whenever the mouse is hovering over the carousel.
- 前後のスライドを表示するためのボタン。
- オプションで、特定のスライドを表示するためのコントロールグループ。
- 自動切り換えの場合
- スライドを停止・再開するためのボタン。
- フォーカスがカルーセルに入るとスライドを停止。
- マウスがカルーセルの上にホバーしている時は常にスライドを停止。
キーボード操作
- If the carousel has an auto-rotate feature, automatic slide rotation stops when any element in the carousel receives keyboard focus.
It does not resume unless the user activates the rotation control.- Tab and Shift + Tab: Move focus through the interactive elements of the carousel as specified by the page tab sequence — scripting for Tab is not necessary.
- Button elements implement the keyboard interaction defined in the button pattern.
Note: Activating the rotation control, next slide, and previous slide do not move focus, so users may easily repetitively activate them as many times as desired.- If present, the rotation control is the first element in the Tab sequence inside the carousel.
It is essential that it precede the rotating content so it can be easily located.- If tab elements are used for slide picker controls, they implement the keyboard interaction defined in the Tabs Pattern.
自動切り換え機能がある場合、カルーセル内の要素にフォーカスが当たった時に自動切り換えを停止する。
自動切り換えのコントロールがある場合、そのコントロールはカルーセル内の最初にフォーカスが当たるようにする。
スライドピッカー(ドット)のコントロールをタブUIとして設定している場合、タブUIのキーボード操作を実装する。
基本パターンのスライダー
実際にスライダーの機能別に、実装とアクセシビリティ対応を行ってみます。
フェードでの切り替え
まずは前後のボタンでフェードで切り替わるスライダーを、アクセシビリティの面を特に考慮しないで実装してみます。
<div class="slider js-slider"> <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_list"> <div class="slider_item js-slider-item is-show"> <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item js-slider-item"> <a href="#">Slide6</a> </div> </div> </div>
CSSはスライダーに必要な部分のみの抜粋になります。
ファイル全体はデモページをご確認ください。
.slider_list { position: relative; } .slider_item { position: relative; opacity: 0; transition: opacity 300ms; } .slider_item:not(:first-child) { position: absolute; top: 0; left: 0; } .slider_item.is-show { z-index: 5; opacity: 1; }
最後にJavaScriptです。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const slideSpeed = 300; window.addEventListener('DOMContentLoaded', function() { $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // カレントクラスの付け替え $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); // フェードのアニメーション終了後、スライド状態監視用のclass除去 setTimeout(function() { $slider.classList.remove('is-sliding'); }, slideSpeed); }
これで前後のボタンでフェードで切り替わるスライダーができました。
基本パターンのデモページ01
このスライダーに対して、アクセシビリティの考慮をしていきます。
WAI-ARIA
基本的なスライダーの場合に考慮する項目は以下の通りです。
- A carousel container element that encompasses all components of the carousel, including both carousel controls and slides, has either role region or role group.
The most appropriate role for the carousel container depends on the information architecture of the page.
See the
Landmark Regions Practice
to determine whether the carousel warrants being designated as a landmark region.- The carousel container has the aria-roledescription property set to
carousel
.- If the carousel has a visible label, its accessible label is provided by the property aria-labelledby on the carousel container set to the ID of the element containing the visible label.
Otherwise, an accessible label is provided by the property aria-label set on the carousel container.
Note that since thearia-roledescription
is set to “carousel”, the label does not contain the word “carousel”.
カルーセル全体を囲う要素に対して、role属性でregionかgroupの設定と、aria-roledescription=”carousel”の設定を行う。
また、カルーセルが可視ラベルを持つ場合はaria-labelledby属性の設定を行い、ない場合はaria-label属性の設定を行う。
- The rotation control, next slide control, and previous slide control are either native button elements (recommended) or implement the button pattern.
自動切り換えのコントロールと前後スライドのボタンは、button要素(推奨)かボタンパターンで実装する。
- Each slide container has role group with the property aria-roledescription set to
slide
.- Each slide has an accessible name:
- If a slide has a visible label, its accessible label is provided by the property aria-labelledby on the slide container set to the ID of the element containing the visible label.
- Otherwise, an accessible label is provided by the property aria-label set on the slide container.
- If unique names that identify the slide content are not available, a number and set size can serve as a meaningful alternative, e.g., “3 of 10”. Note: Normally, including set position and size information in an accessible name is not appropriate. An exception is helpful in this implementation because group elements do not support aria-setsize or aria-posinset. The tabbed carousel implementation pattern does not have this limitation.
- Note that since the
aria-roledescription
is set to “slide”, the label does not contain the word “slide.”
各スライド要素に、role=”group”とaria-roledescription=”slide”を設定する。
各スライド要素が可視ラベルを持つ場合はaria-labelledby属性の設定を行い、ない場合はaria-label属性の設定を行う。
ユニークなラベルが利用できない場合、「3 of 10」などを設定する。
- Optionally, an element wrapping the set of slide elements has aria-atomic set to
false
and aria-live set to:
off
: if the carousel is automatically rotating.polite
: if the carousel is NOT automatically rotating.
オプションとして、カルーセル全体を囲う要素にaria-atomic=”false”とaria-liveを設定する。
カルーセルが自動で切り換わるする場合はaria-live=”off”、そうでない場合はaria-live=”polite”を設定する。
対応後
上記の注意事項を元に、対応を追加してみます。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_list"> <div class="slider_item js-slider-item is-show" role="group" aria-roledescription="slide" aria-label="1 of 6" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item js-slider-item" role="group" aria-roledescription="slide" aria-label="6 of 6" > <a href="#">Slide6</a> </div> </div> </section>
前述の注意事項ではrole=”region”を付与するとなっていましたが、代わりにsection要素での実装(+aria-labelの付与)に変更しています。
CSS・JavaScriptの変更点はありません。
基本パターンのデモページ02
これでガイドラインの内容に沿って作成できましたが、今回のようにスライド内にリンクなどフォーカス対象の要素がある場合、Tabキーの移動で表示されていないスライド要素内にもフォーカスが移動してしまいます。
また、スクリーンリーダーを使って矢印キーでの操作を行った際、表示されていないスライド要素にも移動できてしまいます。
これらの対応のため、スライド内のフォーカス対象の要素にはtabindex属性で制御を、スライド要素にはaria-hidden属性で制御を追加します。
JavaScriptで下記変更を行います。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const slideSpeed = 300; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'false'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // 現在のカレントの設定変更 $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'true'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[nextIndex] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); // フェードのアニメーション終了後、スライド状態監視用のclass除去 setTimeout(function() { $slider.classList.remove('is-sliding'); }, slideSpeed); }
これで表示されていないスライド要素に移動しないようにできました。
基本パターンのデモページ03
自動切り換え機能の追加
前述のフェードでの切り替えのスライダーに、自動切り換えの機能を追加してみます。
JavaScriptを以下のように変更します。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const slideSpeed = 300; const slideAuto = 5000; let slideTimer; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); auto_slide(); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'false'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // 現在のカレントの設定変更 $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'true'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[nextIndex] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); // フェードのアニメーション終了後 setTimeout(function() { // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 自動スライドのタイマー設定 auto_slide(); }, slideSpeed); } // 自動スライドのタイマー設定 function auto_slide() { clearTimeout(slideTimer); slideTimer = setTimeout(next_slide, slideAuto); }
これで自動切り換えの処理が追加できましたが、この時点ではアクセシビリティの考慮は含まれていません。
基本パターンのデモページ04
WAI-ARIA
自動切り換えの機能で考慮する項目について確認してみます。
ここまでに出た自動切り換えの注意事項は以下の通りです。
- スライドを停止・再開するためのボタン。
- フォーカスがカルーセルに入るとスライドを停止。
- マウスがカルーセルの上にホバーしている時は常にスライドを停止。
自動切り換え機能がある場合、カルーセル内の要素にフォーカスが当たった時に自動切り換えを停止する。
自動切り換えのコントロールがある場合、そのコントロールはカルーセル内の最初にフォーカスが当たるようにする。
自動切り換えのコントロールと前後スライドのボタンは、button要素(推奨)かボタンパターンで実装する。
これらに加えて、WAI-ARIAの面から考慮する事項は以下になります。
- The rotation control has an accessible label provided by either its inner text or aria-label.
The label changes to match the action the button will perform, e.g., “Stop slide rotation” or “Start slide rotation”.
A label that changes when the button is activated clearly communicates both that slide content can change automatically and when it is doing so.
Note that since the label changes, the rotation control does not have any states, e.g.,aria-pressed
, specified.
自動切り換えのコントロールは、その内部テキストかaria-label属性によってラベルを持つ。
ラベルはボタンが実行するアクションと一致するようにアクティブ時に変化させ、スライドの内容が自動的に変化することと、それがいつ行われるかが伝わるようにする。
ラベルが変わるので、自動切り換えのコントロールにはaria-pressedなど設定しないように注意する。
対応後
上記の注意事項を元に、対応を追加してみます。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_auto js-slider-auto">Stop</button> <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_list"> <div class="slider_item js-slider-item is-show" role="group" aria-roledescription="slide" aria-label="1 of 6" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item js-slider-item" role="group" aria-roledescription="slide" aria-label="6 of 6" > <a href="#">Slide6</a> </div> </div> </section>
CSSの変更はありません。
JavaScriptは以下のように変更します。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const $slideAuto = document.querySelector('.js-slider-auto'); const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const slideSpeed = 300; const slideAuto = 5000; let slideTimer; let autoFlag = true; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); auto_slide(); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); $slideAuto.addEventListener('click', auto_control); $slider.addEventListener('mouseover', stop_slide); $slider.addEventListener('mouseleave', auto_slide); $slider.addEventListener('focusin', stop_slide); $slider.addEventListener('focusout', auto_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'false'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // 現在のカレントの設定変更 $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'true'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[nextIndex] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); // フェードのアニメーション終了後 setTimeout(function() { // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 自動スライドのタイマー設定 auto_slide(); }, slideSpeed); } // 自動スライドのタイマー設定 function auto_slide() { if(autoFlag) { clearTimeout(slideTimer); slideTimer = setTimeout(next_slide, slideAuto); } } // 自動スライド停止 function stop_slide() { clearTimeout(slideTimer); } // 自動スライドのコントロールクリック時の動作 function auto_control() { if(autoFlag) { autoFlag = false; $slideAuto.textContent = 'Start'; stop_slide(); } else { autoFlag = true; $slideAuto.textContent = 'Stop'; auto_slide(); } }
これで注意事項に沿った形での自動切り替えに変更できました。
基本パターンのデモページ05
横スライドでの切り替え
前述のフェードでの切り替えのスライダーをベースに、横スライドで切り替えるタイプのスライダーを実装してみます。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_wrapper"> <div class="slider_list js-slider-list"> <div class="slider_item slider_item--01 js-slider-item" role="group" aria-roledescription="slide" aria-label="1 of 6" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item slider_item--06 js-slider-item" role="group" aria-roledescription="slide" aria-label="6 of 6" > <a href="#">Slide6</a> </div> </div> </div> </section>
横スライドになるため、display: flex; とflex-shrink: 0; で横に並べるように変更します。
.slider_wrapper { overflow: hidden; padding-block: 3px; } .slider_list { display: flex; } .slider_item { flex-shrink: 0; position: relative; width: 100%; }
最後にJavaScriptです。
const $slider = document.querySelector('.js-slider'); const $slideList = document.querySelector('.js-slider-list'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideItem = 'js-slider-item'; const slideSpeed = 600; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // スライド処理開始 slider_slide(-1); } // 1つ先のスライドに移動する処理 function next_slide() { // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // スライド処理開始 slider_slide(1); } // スライダーのスライド処理 function slider_slide(direction) { // 現在のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'true'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); const slideItems = $slider.querySelectorAll('.'+slideItem); const slideWidth = $slideList.clientWidth; // 右方向に進む場合 if(direction === 1) { // スライドアニメーションの設定 $slideList.style.transition = 'transform '+slideSpeed+'ms'; $slideList.style.transform = 'translate3d(-'+slideWidth+'px, 0px, 0px)'; // スライドのアニメーション終了後 setTimeout(function() { // 先頭のスライドを末尾に移動 $slideList.appendChild(slideItems[0]); // アニメーションの設定を初期化 $slideList.style.transition = ''; $slideList.style.transform = ''; // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); }, slideSpeed); // 左方向に進む場合 } else if(direction === -1) { // 末尾のスライドを先頭に移動 $slideList.insertBefore(slideItems[slideItems.length-1], slideItems[0]); // スライドのアニメーション開始前の設定 $slideList.style.transform = 'translate3d(-'+slideWidth+'px, 0px, 0px)'; // アニメーション開始前の設定から1ms後にアニメーション処理実行 setTimeout(function() { // スライドアニメーションの設定 $slideList.style.transition = 'transform '+slideSpeed+'ms'; $slideList.style.transform = 'translate3d(0px, 0px, 0px)'; }, 1); // スライドのアニメーション終了後 setTimeout(function() { // アニメーションの設定を初期化 $slideList.style.transition = ''; $slideList.style.transform = ''; // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); }, slideSpeed); } }
これで横スライド版のスライダーができました。
基本パターンのデモページ06
基本的にはフェードから横スライドへの変更のための調整のみで、アクセシビリティの面での変更は特にありません。
自動切り換え機能追加
横スライドのスライダーに自動切り換え機能を追加してみます。
追加内容としてはフェードに自動切り替え機能を追加した時とほとんど同じです。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_auto js-slider-auto">Stop</button> <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_wrapper"> <div class="slider_list js-slider-list"> <div class="slider_item slider_item--01 js-slider-item" role="group" aria-roledescription="slide" aria-label="1 of 6" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item slider_item--06 js-slider-item" role="group" aria-roledescription="slide" aria-label="6 of 6" > <a href="#">Slide6</a> </div> </div> </div> </section>
CSSの変更はありません。
JavaScriptの変更は以下の通りです。
const $slider = document.querySelector('.js-slider'); const $slideList = document.querySelector('.js-slider-list'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const $slideAuto = document.querySelector('.js-slider-auto'); const slideItem = 'js-slider-item'; const slideSpeed = 600; const slideAuto = 5000; let slideTimer; let autoFlag = true; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); auto_slide(); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); $slideAuto.addEventListener('click', auto_control); $slider.addEventListener('mouseover', stop_slide); $slider.addEventListener('mouseleave', auto_slide); $slider.addEventListener('focusin', stop_slide); $slider.addEventListener('focusout', auto_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // スライド処理開始 slider_slide(-1); } // 1つ先のスライドに移動する処理 function next_slide() { // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // スライド処理開始 slider_slide(1); } // スライダーのスライド処理 function slider_slide(direction) { // 現在のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'true'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); const slideItems = $slider.querySelectorAll('.'+slideItem); const slideWidth = $slideList.clientWidth; // 右方向に進む場合 if(direction === 1) { // スライドアニメーションの設定 $slideList.style.transition = 'transform '+slideSpeed+'ms'; $slideList.style.transform = 'translate3d(-'+slideWidth+'px, 0px, 0px)'; // スライドのアニメーション終了後 setTimeout(function() { // 先頭のスライドを末尾に移動 $slideList.appendChild(slideItems[0]); // アニメーションの設定を初期化 $slideList.style.transition = ''; $slideList.style.transform = ''; // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 自動スライドのタイマー設定 auto_slide(); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); }, slideSpeed); // 左方向に進む場合 } else if(direction === -1) { // 末尾のスライドを先頭に移動 $slideList.insertBefore(slideItems[slideItems.length-1], slideItems[0]); // スライドのアニメーション開始前の設定 $slideList.style.transform = 'translate3d(-'+slideWidth+'px, 0px, 0px)'; // アニメーション開始前の設定から1ms後にアニメーション処理実行 setTimeout(function() { // スライドアニメーションの設定 $slideList.style.transition = 'transform '+slideSpeed+'ms'; $slideList.style.transform = 'translate3d(0px, 0px, 0px)'; }, 1); // スライドのアニメーション終了後 setTimeout(function() { // アニメーションの設定を初期化 $slideList.style.transition = ''; $slideList.style.transform = ''; // スライド状態監視用のclass除去 $slider.classList.remove('is-sliding'); // 自動スライドのタイマー設定 auto_slide(); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[0] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[0] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); }, slideSpeed); } } // 自動スライドのタイマー設定 function auto_slide() { if(autoFlag) { clearTimeout(slideTimer); slideTimer = setTimeout(next_slide, slideAuto); } } // 自動スライド停止 function stop_slide() { clearTimeout(slideTimer); } // 自動スライドのコントロールクリック時の動作 function auto_control() { if(autoFlag) { autoFlag = false; $slideAuto.textContent = 'Start'; stop_slide(); } else { autoFlag = true; $slideAuto.textContent = 'Stop'; auto_slide(); } }
これで横スライドのスライダーに自動切り換えを追加できました。
基本パターンのデモページ07
タブパターンのスライダー
ここまでは基本パターンのスライダーの実装を試してきましたが、次はタブパターンのスライダーを実装してみます。
タブパターンのスライダーは、ドットでのスライドの切り替え機能を含むスライダーになります。
前述の基本的なスライダー(フェード)をベースに、ドットの機能を追加してみます。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_dotes"> <button class="slider_dot js-slider-dot is-current">slide1</button> ~ 略 ~ <button class="slider_dot js-slider-dot">slide6</button> </div> <div class="slider_list"> <div class="slider_item js-slider-item is-show" role="group" aria-roledescription="slide" aria-label="1 of 6" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item js-slider-item" role="group" aria-roledescription="slide" aria-label="6 of 6" > <a href="#">Slide6</a> </div> </div> </section>
JavaScriptにドット部分の処理を追加します。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideDot = 'js-slider-dot'; const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const dotCurrentClass = 'is-current'; const slideSpeed = 300; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); const $slideDots = document.querySelectorAll('.'+slideDot); $slideDots.forEach(function(element) { element.addEventListener('click', dot_slide); }); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'false'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // 現在のカレントの設定変更 $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'true'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); $slider.querySelector('.'+slideDot+'.'+dotCurrentClass) .classList.remove(dotCurrentClass); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[nextIndex] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); $slider.querySelectorAll('.'+slideDot)[nextIndex] .classList.add(dotCurrentClass); // フェードのアニメーション終了後、スライド状態監視用のclass除去 setTimeout(function() { $slider.classList.remove('is-sliding'); }, slideSpeed); } // ドットをクリック時の処理 function dot_slide(e) { const target = e.currentTarget; const $slideDots = $slider.querySelectorAll('.'+slideDot); const nextIndex = Array.from($slideDots).findIndex(function(item) { return item === target; }); slider_slide(nextIndex); }
これでドットの機能を含んだスライダーを実装できました。
タブパターンのデモページ01
今までと同様に、このスライダーに対してアクセシビリティの考慮をしていきます。
WAI-ARIA
- Each slide container has role tabpanel in lieu of
group
, and it does not have thearia-roledescription
property.- It has slide picker controls implemented using the tabs pattern where:
- Each control is a
tab
element, so activating a tab displays the slide associated with that tab.- The accessible name of each
tab
indicates which slide it will display by including the name or number of the slide, e.g., “Slide 3”.
Slide names are preferable if each slide has a unique name.- The set of controls is grouped in a
tablist
element with an accessible name provided by the value of aria-label that identifies the purpose of the tabs, e.g., “Choose slide to display.”- The
tab
,tablist
, andtabpanel
implement the properties specified in the tabs pattern.
各スライドはrole=”group”の代わりにrole=”tabpanel”を設定して、aria-roledescription属性は設定しない。
タブパターンを使って実装されたスライドピッカーコントロールを持つ。
各コントロールはタブ要素で、タブをアクティブにするとタブに紐づいたスライドを表示する。
各タブのアクセシブル名は「slide 3」のように、スライドの名前や番号を含ませて、どのスライドを表示するかを示す。
スライドピッカーコントロールには、aria-labelとrole=”tablist”を設定する。
tab、tablist、tabpanelはタブパターンで指定されたプロパティを実装する。
対応後
上記の注意事項を元に、対応を追加してみます。
タブについては以前に記事を投稿していますので、詳しくはそちらをご確認ください。
<section class="slider js-slider" aria-roledescription="carousel" aria-label="おすすめ商品一覧" > <button class="slider_prev js-slider-prev">Previous</button> <button class="slider_next js-slider-next">Next</button> <div class="slider_dotes" role="tablist" aria-label="商品選択" > <button class="slider_dot js-slider-dot" id="dot1" role="tab" aria-controls="slide1" aria-selected="true" >slide1</button> ~ 略 ~ <button class="slider_dot js-slider-dot" id="dot6" role="tab" aria-controls="slide6" aria-selected="false" >slide6</button> </div> <div class="slider_list"> <div class="slider_item js-slider-item is-show" id="slide1" role="tabpanel" aria-labelledby="dot1" > <a href="#">Slide1</a> </div> ~ 略 ~ <div class="slider_item js-slider-item" id="slide6" role="tabpanel" aria-labelledby="dot6" > <a href="#">Slide6</a> </div> </div> </section>
ドットのカレントの設定を、classからaria-selectedを使った形に変更します。
.slider_list { position: relative; } .slider_item { position: relative; opacity: 0; transition: opacity 300ms; } .slider_item:not(:first-child) { position: absolute; top: 0; left: 0; } .slider_item.is-show { z-index: 5; opacity: 1; } .slider_dot[aria-selected="true"] { color: red; }
ドット周りをタブとして変更します。
const $slider = document.querySelector('.js-slider'); const $slidePrev = document.querySelector('.js-slider-prev'); const $slideNext = document.querySelector('.js-slider-next'); const slideDot = 'js-slider-dot'; const slideItem = 'js-slider-item'; const currentClass = 'is-show'; const slideSpeed = 300; // フォーカス可能な要素一覧 const focusableSelector = ` a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), summary, area[href] `; window.addEventListener('DOMContentLoaded', function() { slider_init(); const $slideDots = document.querySelectorAll('.'+slideDot); $slideDots.forEach(function(element) { if(element.getAttribute('aria-selected') == 'true') { element.setAttribute('tabindex', 0); } else { element.setAttribute('tabindex', -1); } element.addEventListener('keydown', dot_move); element.addEventListener('click', dot_slide); }); $slidePrev.addEventListener('click', prev_slide); $slideNext.addEventListener('click', next_slide); }); // スライダーの初期設定 function slider_init() { $slider.querySelectorAll('.'+slideItem).forEach(function(slide) { slide.setAttribute('aria-hidden', 'true'); slide.querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'false'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); } // 1つ前のスライドに移動する処理 function prev_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex > 0 ? currentIndex - 1 : $slideItems.length - 1; slider_slide(nextIndex); } // 1つ先のスライドに移動する処理 function next_slide() { const $slideItems = $slider.querySelectorAll('.'+slideItem); const currentIndex = Array.from($slideItems).findIndex(function(item) { return item.classList.contains(currentClass); }); // スライド中の場合は処理を行わない(連打防止) if($slider.classList.contains('is-sliding')) return; // スライド状態監視用のclass付与 $slider.classList.add('is-sliding'); // 次に表示するスライドを設定してスライド処理開始 const nextIndex = currentIndex < $slideItems.length - 1 ? currentIndex + 1 : 0; slider_slide(nextIndex); } // スライダーのスライド処理 function slider_slide(nextIndex) { // 現在のカレントの設定変更 $slider.querySelector('.'+slideItem+'.'+currentClass) .setAttribute('aria-hidden', 'true'); $slider.querySelector('.'+slideItem+'.'+currentClass) .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', -1); }); $slider.querySelector('.'+slideItem+'.'+currentClass) .classList.remove(currentClass); $slider.querySelector('.'+slideDot+'[aria-selected="true"]').setAttribute('tabindex', -1); $slider.querySelector('.'+slideDot+'[aria-selected="true"]').setAttribute('aria-selected', 'false'); // 次のカレントの設定変更 $slider.querySelectorAll('.'+slideItem)[nextIndex] .classList.add(currentClass); $slider.querySelectorAll('.'+slideItem)[nextIndex] .setAttribute('aria-hidden', 'false'); $slider.querySelectorAll('.'+slideItem)[nextIndex] .querySelectorAll(focusableSelector).forEach(function(element) { element.setAttribute('tabindex', 0); }); $slider.querySelectorAll('.'+slideDot)[nextIndex].setAttribute('tabindex', 0); $slider.querySelectorAll('.'+slideDot)[nextIndex].setAttribute('aria-selected', 'true'); // フェードのアニメーション終了後、スライド状態監視用のclass除去 setTimeout(function() { $slider.classList.remove('is-sliding'); }, slideSpeed); } // ドットをクリック時の処理 function dot_slide(e) { const target = e.currentTarget; const $slideDots = $slider.querySelectorAll('.'+slideDot); const nextIndex = Array.from($slideDots).findIndex(function(item) { return item === target; }); slider_slide(nextIndex); } // ドット間の移動 function dot_move(e) { const target = e.currentTarget; const tabBtnLength = document.querySelectorAll('[role="tab"]').length; let current = 0; let next = 0; document.querySelectorAll('[role="tab"]').forEach(function(element, index) { if(element === target) current = index; }); // キーに応じて移動先のタブボタンを決める const keycode = e.key; if(keycode === 'ArrowLeft') { next = current > 0 ? current - 1 : tabBtnLength - 1; } else if(keycode === 'ArrowRight') { next = current < tabBtnLength - 1 ? current + 1 : 0; } else if(keycode === 'Home') { next = 0; } else if(keycode === 'End') { next = tabBtnLength - 1; } else { return; } document.querySelectorAll('[role="tab"]')[next].focus(); slider_slide(next); e.stopPropagation(); e.preventDefault(); }
これでタブパターンのスライダーの実装ができました。
タブパターンのデモページ02
コメントが承認されるまで時間がかかります。