Blog スタッフブログ

Webアクセシビリティを考慮したチェックボックス・ラジオボタン・セレクトボックス・タブ・アコーディオンの実装

Category | Blog
/ 1,359views

こんにちは、CTOの奥田です。

以前、「フォームのセレクトボックスとチェックボックス・ラジオボタンをCSSで装飾する」という記事を書きました。
こちらは5年前の記事になるのですが、当時は私もWebアクセシビリティについての知見が少なく、こちらのサンプルはWebアクセシビリティを考慮出来ていないものとなっていました。

あらためてWebアクセシビリティを勉強する中で得た知識から、過去の記事を清算する意味も込めて私なりの実装方法をご紹介できればとおもいます。
アクセシビリティの勉強にはW3Cのサイトはもちろん、Bootstrapのコンポーネントなどが非常に勉強になるので併せてご覧いただければとおもいます。

チェックボックス・ラジオボタン

以前のブログでご紹介したチェックボックスとラジオボタンはinput要素をdisplay:noneしてしまっているのでキーボードでの操作が出来なくなってしまっています。
こちらはvisually-hiddenと呼ばれる方法を使って視覚的に非表示にすることで、アクセシビリティを担保しつつ自由なデザインで実装することが出来ます。

.visually-hidden {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}

また、フォーカスした時にどこにフォーカスしているかがわかるようにoutlineをbox-shadowで表現しています。

.c-checkbox__input{
    &:focus + .c-checkbox__label::before{
        box-shadow:0 0 0 2px rgba($checkbox-bg,.3);
    }
}

.c-radio__input{
    &:focus + .c-radio__label::before{
        box-shadow:0 0 0 2px rgba($radio-bg,.3);
    }
}

disabledの時は擬似要素の表示を変更し、ラベルをopacity:.5;にすることで押せないということをよりわかりやすくしています。

.c-checkbox__input{
    &:disabled + .c-checkbox__label{
        opacity:.5;
    }
    &:disabled + .c-checkbox__label::before{
        background-color: #eee;
    }
    &:disabled + .c-checkbox__label::after{
        content:none;
    }
}
.c-radio__input{
    &:disabled + .c-radio__label{
        opacity:.5;
    }
    &:disabled + .c-radio__label::before{
        background-color: #eee;
    }
    &:disabled + .c-radio__label::after{
        content:none;
    }
}

チェックボックス

See the Pen BawyRZp by Mineo (@min30327) on CodePen.

ラジオボタン

See the Pen xxXbdox by Mineo (@min30327) on CodePen.

セレクトボックス

セレクトボックスはセレクト要素自体の背景にSVGでアイコンを配置することでwrapすることなくプルダウンアイコンを表示できます。
また、こちらもフォーカスした時にoutlineをbox-shadowで表現しています。
multipleの時はheight:autoにし、プルダウンアイコンを非表示にしています。

.c-select{
    &:focus{
        border-color:rgba($select-focus-color,.3);
        outline: none;
        box-shadow: 0 0 0 3px rgba($select-focus-color,.2);
    }
    &:disabled{
        background-color: #eee;
        cursor:not-allowed;
    }
    &[multiple]{
        height: auto;
        background-image: none;
        padding-right:15px;
    }
}

See the Pen qBPEjBO by Mineo (@min30327) on CodePen.

タブ

タブ切り替えは role=”tablist” で囲んだ要素内の role=”tab” が aria-selected=”true” か aria-selected=”false” で選択されているかどうかを指定し、aria-controls=”[tab_id]”に指定したIDの要素と関連付けます。
role=”tabpanel”にaria-controlsで指定したIDを付与し、aria-labelledby=”[trigger_id]”にタブボタンのIDを指定し関連付けます。

<div role="tablist">
    <button role="tab" aria-selected="true" aria-controls="tab01" id="tab-nav01">tab01</button>
    <button role="tab" aria-selected="false" aria-controls="tab02" id="tab-nav02">tab02</button>
</div>
<div role="tabpanel" id="tab01" aria-labelledby="tab-nav01">
    ...
</div>
<div role="tabpanel" id="tab02" aria-labelledby="tab-nav02">
    ...
</div>

スタイリングはデザインによって変わるとおもいますので割愛しますが、アクティブクラスを付与したコンテンツのみをdisplay:blockにするという単純な仕様にしています。
クラスの付与はJavaScriptで操作しています。
タブをボタン要素にした場合は :focus:not(:focus-visible) にoutline:noneを指定することでキーボードでの操作を有効化出来ます。

.c-tab__pane{
    display: none;
    &.c-tab__active{
        display: block;
    }
}
.c-tab__nav--item{
    &:focus:not(:focus-visible){
        outline: 0;
    }
}

今回の実装で書いたJavaScriptは以下です。
こちらは本記事用にコンポーネント化する際に書いたものであり、あくまで一例ですので現場で使う際はそれぞれの環境に沿った書き方で実装してください。

class Tab {
    constructor(){
        this.tab_wrapper = '.js-tab'
        this.tab_item = '.js-tab__item'
        this.tab_pane = '.js-tab__pane'
        this.active_class = 'c-tab__active'

        this.keys = {   
            "left": 37,
            "up": 38,
            "right": 39,
            "down": 40
        }
        this.init()
    }
    init(){
        this.tab = document.querySelectorAll(this.tab_wrapper);
        this.tab_navs = [];
    
        if(this.tab.length > 0){
            this.tab.forEach( (tab,i) => {
                const tab_items = tab.querySelectorAll( this.tab_item );
                this.tab_navs.push( tab_items )

                if(tab_items.length > 0){
                    tab_items.forEach( (tab_nav,index) => {
                        if(index == 0){
                            const pane_id = tab_nav.getAttribute('aria-controls');
                            this._open(tab_nav,pane_id)
                        }
                        this._addEvent(tab_nav,i,index,tab_items);
                    })
                }
            })
        }	
		
    }

    _addEvent (el,i,index,tab_items){
        
        el.addEventListener('click',(e) => {
            
            this._hide(i);

            const pane_id = el.getAttribute('aria-controls');
            this._open(el,pane_id)
        })
        el.addEventListener('keydown',(e)=>{
            const k = e.which || e.keyCode;
            let position = index
            if (k >= this.keys.left && k <= this.keys.down){
                if (k == this.keys.left || k == this.keys.up){
                    if (position > 0) {
                        e.preventDefault()
                        position--
                        tab_items[position].click()
                        tab_items[position].focus()
                    }
                }else if (k == this.keys.right || k == this.keys.down){
                    if (position < tab_items.length-1 ) {
                        e.preventDefault()
                        position++
                        tab_items[position].click()
                        tab_items[position].focus()
                    }
                }
            }
        })
    }
    _open(item,pane_id){
        const target_pane = document.querySelector( '#' + pane_id );
        if ( !target_pane ) return;

        item.classList.add(this.active_class);
        item.setAttribute("aria-selected",true)
        item.setAttribute("tabindex",0)
        target_pane.classList.add(this.active_class);
        target_pane.removeAttribute("hidden")
    }
    _hide (i){
        const tab = this.tab[i]
        const item_active = tab.querySelector(this.tab_item+'.'+this.active_class);
        const pane_active = tab.querySelector(this.tab_pane+'.'+this.active_class);
        if(item_active){
            item_active.classList.remove(this.active_class);
            item_active.setAttribute("aria-selected",false)
            item_active.setAttribute("tabindex","-1")
        }
        if(pane_active){
            pane_active.classList.remove(this.active_class);
            pane_active.setAttribute("hidden","hidden")
        }
    }
 }

document.addEventListener('DOMContentLoaded', ()=>{
  new Tab
})

See the Pen ExwaXrN by Mineo (@min30327) on CodePen.

アコーディオン

アコーディオンはトリガーに [aria-expanded] と aria-controls=”[開閉コンテンツのID]” を付与します。
開閉コンテンツには aria-hidden=”false” と aria-hidden=”true” で開閉状態を指定します。

<button type="button" aria-expanded="true" aria-controls="accordion-1">accordion01</button>
<div id="accordion-1" aria-hidden="false">
        ....
</div>
<button type="button" aria-expanded="true" aria-controls="accordion-2">accordion02</button>
<div id="accordion-2" aria-hidden="true">
        ....
</div>

今回の実装で書いたJavaScriptは以下です。
やっていることはタブとほとんど変わらないのですが、開閉時のアニメーションはGSAPを使用しています。
タブ同様に、こちらは本記事用にコンポーネント化する際に書いたものであり、あくまで一例ですので現場で使う際はそれぞれの環境に沿った書き方で実装してください。

class Accordion {
    constructor(){
        this.accordion_wrapper = "js-accordion"
        this.accordion__trigger = "js-accordion__trigger"
        this.accordion__content = "js-accordion__content"
        this.active_class = 'c-accordion__active'

        this.keys = {   
            "left": 37,
            "up": 38,
            "right": 39,
            "down": 40
        }
        this.init()
    }
    init (){
        this.accordions = document.querySelectorAll('.' + this.accordion_wrapper)
        this.triggers = []
        this.contents = []

        if(this.accordions.length > 0){

            this.accordions.forEach( (accordion, i ) => {
                const triggers = accordion.querySelectorAll('.' + this.accordion__trigger)
                const contents = accordion.querySelectorAll('.' + this.accordion__content)
                this.triggers.push(triggers)
                this.contents.push(contents)

                
                if(triggers.length > 0){
                    triggers.forEach( (trigger, index) => {
                      this._addEvent(trigger,i,index,triggers,accordion);
                    })
                }
                if(contents.length > 0){
                    contents.forEach( (content, index) => {
                        if(!content.classList.contains(this.active_class)){
                            content.style.height = 0;
                        }
                    })
                }
            })
                
        }          
    }
    _addEvent (el,i,index,triggers,accordion){
        el.addEventListener('click',(e) => {
            e.preventDefault();
            const id = el.getAttribute('aria-controls')
            const contents = accordion.querySelector('#' + id)
            if(contents){
                if(!contents.classList.contains(this.active_class)){
                    this._close(el,contents);
                    e.currentTarget.classList.add(this.active_class);
                    this._animation(contents,'open');
                }else{
                    this._close(el,contents);
                }
                
            }
        })
        
        el.addEventListener('keydown',(e)=>{
            const k = e.which || e.keyCode;
            let position = index
            if (k >= this.keys.left && k <= this.keys.down){
                if (k == this.keys.left || k == this.keys.up){
                    if (position > 0) {
                        e.preventDefault()
                        position--
                        triggers[position].focus()
                    }
                }else if (k == this.keys.right || k == this.keys.down){
                    if (position < triggers.length-1 ) {
                        e.preventDefault()
                        position++
                        triggers[position].focus()
                    }
                }
            }
        })
    }
    _close (trigger,contents){

        if(trigger){
            trigger.classList.remove(this.active_class);
        }
        
        if(contents){
            contents.classList.remove(this.active_class);
            this._animation(contents,'close');
        }
    }
    _animation (el,type){

        if(type=="open"){
            el.classList.add(this.active_class);
            el.style.height = 'auto';
            let height = el.clientHeight;
            el.setAttribute('data-height',height);
            el.setAttribute('aria-hidden',false);
            el.style.height = 0;
            
            gsap.to(el,{
                height:height,
                duration:.3,
                ease: "expo.out"
            })
        }else{
            
            el.classList.remove(this.active_class);
            el.setAttribute('aria-hidden',true);
            gsap.to(el,{
                height:0,
                duration:.3,
                ease: "expo.out"
            })
        }
    }
}

document.addEventListener('DOMContentLoaded', ()=>{
new Accordion
})

See the Pen BawydoE by Mineo (@min30327) on CodePen.

さいごに

Webアクセシビリティを考慮した実装をすることで、より多くの方にWebサイトをストレスなく閲覧していただけるよう配慮していきましょう。
以前のブログを参照された方もたくさんいらっしゃるとおもいますが、当時よりもアクセシビリティに対する考慮がより重要になってきていることを懸念し今回の記事を執筆いたしました。
今後も発信者として正しい情報発信ができるよう努めてまいりますのでどうぞよろしくお願いいたします。

Category | Blog
Author | Mineo Okuda / 1,359views

Company information

〒650-0024
神戸市中央区海岸通5 商船三井ビルディング4F

Contact us

WEBに関するお問い合わせは
078-977-8760 (10:00 - 18:00)