はじめに
みなさんアクセシビリティを意識して開発できていますか?
必要なところにrole属性
を記述したり、tabキー
でフォーカスができるようにしたりなど、意識しないといけないことも多いです。
そのため、アクセシビリティを完璧にやろうとするのは一苦労です。
ただ、コンポーネントごとに区切って、アクセシビリティを理解しておけば、実装するタイミングに思い出しやすく、アクセシビリティも意識しやすいと思います。
そのため、この記事では「メニューボタン」に焦点を当てて、アクセシビリティを意識したメニューボタンの実装方法とメニューボタンで意識した方がいいアクセシビリティを解説しようと思います。
アクセシビリティを意識したメニューボタンの仕様
⚪︎ メニューボタンとは?
メニューボタンは、メニューを開くボタンです。
ボタンをアクティブにすると、メニューが表示されます。
また、押すタイプのボタンの中に下向きの三角形がヒントとしてデザインされていることが一般的です。
⚪︎ キーボードインタラクション
- ボタンにフォーカスがある場合
-
Enterキー
- メニューを開いて、最初のメニューアイテムにフォーカスを移動する
-
Spaceキー
- メニューを開いて、最初のメニューアイテムにフォーカスを移動する
-
↓キー
(任意)- メニューを開いて、最初のメニューアイテムにフォーカスを移動する
-
↑キー
(任意)- メニューを開いて、最後のメニューアイテムにフォーカスを移動する
-
- メニューが開いた後に必要なキーボードインタラクションは、こちら↓
⚪︎ WAI-ARIA の役割、状態、プロパティ
- メニューを開く要素には
role="button"
を設定する -
role="button"
を持つ要素は、aria-haspopup
にmenu
ortrue
を設定する - メニューが表示されているとき、
role="button"
を持つ要素にaria-expanded="true"
を設定する - メニューが隠れているとき、
role="button"
を持つ要素にaria-expanded
を設定しないか、aria-expanded="false"
を設定する - ボタンをアクティブにすることで表示される
menuitem
を含む要素にはrole="menu"
を設定する -
role="button"
を持つ要素は、role="menu"
を持つ要素のIDをaria-controls
に設定する - メニュー要素に必要な WAI-ARIA の役割、状態、プロパティはこちら↓
アクセシビリティを意識したメニューボタンの完成形
See the Pen Memu button Accessibility by でぐぅー | Qiita (@sp_degu) on CodePen.
アクセシビリティを意識したメニューボタンの作り方
1. HTMLを実装する
<div class="menu-button-links">
<button type="button" id="menubutton" aria-haspopup="true" aria-controls="menu2">
デバイス
<span class="material-symbols-outlined">expand_more</span>
</button>
<ul id="menu2" role="menu" aria-labelledby="menubutton">
<li role="none">
<a role="menuitem">
<span class="material-symbols-outlined">computer</span>
パソコン
</a>
</li>
<li role="none">
<a role="menuitem">
<span class="material-symbols-outlined">tablet_android</span>
タブレット
</a>
</li>
<li role="none">
<a role="menuitem">
<span class="material-symbols-outlined">smartphone</span>
モバイル
</a>
</li>
</ul>
</div>
2. CSSを実装する
body {
background-color: #212529;
color: #fff;
display: grid;
height: calc(100vh - 40px);
margin: 0;
padding: 20px 0;
place-items: center;
width: 100vw;
align-items: start;
}
.menu-button-links {
backdrop-filter: blur(50px);
background-color: rgb(128 128 128 / .3);
background-blend-mode: luminosity;
border-radius: 24px;
padding: 12px;
position: relative;
&::before {
background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
border: 1.4px solid transparent;
border-radius: 24px;
content: "";
inset: 0;
position: absolute;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
-webkit-mask-composite: destination-out;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
mask-composite: exclude;
z-index: -1;
}
}
.menu-button-links button {
align-items: center;
background: none;
border: none;
color: #ffffff;
display: flex;
font-size: 17px;
font-weight: 600;
gap: 8px;
padding: 8px 16px;
text-decoration: none;
white-space: nowrap;
&:hover, &:focus, &.focused {
border-radius: 8px;
background: radial-gradient(34.12% 136.61% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(50% 164.29% at 50% 100%, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.00) 60.33%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.06);
background-blend-mode: color-dodge, normal, color-dodge, lighten;
}
}
.menu-button-links [role="menu"] {
background-blend-mode: luminosity;
background-color: rgb(128 128 128 / .3);
backdrop-filter: blur(50px);
border-radius: 24px;
display: none;
list-style: none;
position: absolute;
padding: 12px;
&::before {
background: linear-gradient(135deg, rgb(255 255 255 / .4) 0, rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0) 60%, rgb(255 255 255 / .1) 100%);
border: 1.4px solid transparent;
border-radius: 24px;
content: "";
inset: 0;
position: absolute;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
-webkit-mask-composite: destination-out;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0) border-box;
mask-composite: exclude;
z-index: -1;
}
}
.menu-button-links [role="menuitem"] {
align-items: center;
background: none;
border: none;
color: #ffffff;
display: flex;
font-size: 17px;
gap: 8px;
padding: 8px 16px;
text-decoration: none;
white-space: nowrap;
&:hover, &:focus, &.focused {
border-radius: 8px;
background: radial-gradient(34.12% 136.61% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(50% 164.29% at 50% 100%, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.00) 60.33%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.06);
background-blend-mode: color-dodge, normal, color-dodge, lighten;
}
}
3. JavaScriptを実装する
class MenuButtonLinks {
constructor(domNode) {
this.domNode = domNode;
this.buttonNode = domNode.querySelector('button');
this.menuNode = domNode.querySelector('[role="menu"]');
this.menuitemNodes = [];
this.firstMenuitem = false;
this.lastMenuitem = false;
this.firstChars = [];
this.buttonNode.addEventListener(
'keydown',
this.onButtonKeydown.bind(this)
);
this.buttonNode.addEventListener('click', this.onButtonClick.bind(this));
var nodes = domNode.querySelectorAll('[role="menuitem"]');
for (var i = 0; i < nodes.length; i++) {
var menuitem = nodes[i];
this.menuitemNodes.push(menuitem);
menuitem.tabIndex = -1;
this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase());
menuitem.addEventListener('keydown', this.onMenuitemKeydown.bind(this));
menuitem.addEventListener(
'mouseover',
this.onMenuitemMouseover.bind(this)
);
if (!this.firstMenuitem) {
this.firstMenuitem = menuitem;
}
this.lastMenuitem = menuitem;
}
domNode.addEventListener('focusin', this.onFocusin.bind(this));
domNode.addEventListener('focusout', this.onFocusout.bind(this));
window.addEventListener(
'mousedown',
this.onBackgroundMousedown.bind(this),
true
);
}
setFocusToMenuitem(newMenuitem) {
this.menuitemNodes.forEach(function (item) {
if (item === newMenuitem) {
item.tabIndex = 0;
newMenuitem.focus();
} else {
item.tabIndex = -1;
}
});
}
setFocusToFirstMenuitem() {
this.setFocusToMenuitem(this.firstMenuitem);
}
setFocusToLastMenuitem() {
this.setFocusToMenuitem(this.lastMenuitem);
}
setFocusToPreviousMenuitem(currentMenuitem) {
var newMenuitem, index;
if (currentMenuitem === this.firstMenuitem) {
newMenuitem = this.lastMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index - 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusToNextMenuitem(currentMenuitem) {
var newMenuitem, index;
if (currentMenuitem === this.lastMenuitem) {
newMenuitem = this.firstMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index + 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusByFirstCharacter(currentMenuitem, char) {
var start, index;
if (char.length > 1) {
return;
}
char = char.toLowerCase();
start = this.menuitemNodes.indexOf(currentMenuitem) + 1;
if (start >= this.menuitemNodes.length) {
start = 0;
}
index = this.firstChars.indexOf(char, start);
if (index === -1) {
index = this.firstChars.indexOf(char, 0);
}
if (index > -1) {
this.setFocusToMenuitem(this.menuitemNodes[index]);
}
}
getIndexFirstChars(startIndex, char) {
for (var i = startIndex; i < this.firstChars.length; i++) {
if (char === this.firstChars[i]) {
return i;
}
}
return -1;
}
openPopup() {
this.menuNode.style.display = 'block';
this.menuNode.style.left = '0';
this.menuNode.style.top = '58px';
this.buttonNode.setAttribute('aria-expanded', 'true');
}
closePopup() {
if (this.isOpen()) {
this.buttonNode.removeAttribute('aria-expanded');
this.menuNode.style.display = 'none';
}
}
isOpen() {
return this.buttonNode.getAttribute('aria-expanded') === 'true';
}
onFocusin() {
this.domNode.classList.add('focus');
}
onFocusout() {
this.domNode.classList.remove('focus');
}
onButtonKeydown(event) {
var key = event.key,
flag = false;
switch (key) {
case ' ':
case 'Enter':
case 'ArrowDown':
case 'Down':
this.openPopup();
this.setFocusToFirstMenuitem();
flag = true;
break;
case 'Esc':
case 'Escape':
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case 'Up':
case 'ArrowUp':
this.openPopup();
this.setFocusToLastMenuitem();
flag = true;
break;
default:
break;
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onButtonClick(event) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
} else {
this.openPopup();
this.setFocusToFirstMenuitem();
}
event.stopPropagation();
event.preventDefault();
}
onMenuitemKeydown(event) {
var tgt = event.currentTarget,
key = event.key,
flag = false;
function isPrintableCharacter(str) {
return str.length === 1 && str.match(/\S/);
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (event.shiftKey) {
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
if (event.key === 'Tab') {
this.buttonNode.focus();
this.closePopup();
flag = true;
}
} else {
switch (key) {
case ' ':
window.location.href = tgt.href;
break;
case 'Esc':
case 'Escape':
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case 'Up':
case 'ArrowUp':
this.setFocusToPreviousMenuitem(tgt);
flag = true;
break;
case 'ArrowDown':
case 'Down':
this.setFocusToNextMenuitem(tgt);
flag = true;
break;
case 'Home':
case 'PageUp':
this.setFocusToFirstMenuitem();
flag = true;
break;
case 'End':
case 'PageDown':
this.setFocusToLastMenuitem();
flag = true;
break;
case 'Tab':
this.closePopup();
break;
default:
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
break;
}
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onMenuitemMouseover(event) {
var tgt = event.currentTarget;
tgt.focus();
}
onBackgroundMousedown(event) {
if (!this.domNode.contains(event.target)) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
}
}
}
}
window.addEventListener('load', function () {
var menuButtons = document.querySelectorAll('.menu-button-links');
for (let i = 0; i < menuButtons.length; i++) {
new MenuButtonLinks(menuButtons[i]);
}
});
まとめ
この記事では、「メニューボタン」に焦点を当てて、アクセシビリティを意識したメニューボタンの実装方法とメニューボタンで意識した方がいいアクセシビリティを解説しました。
ぜひこの記事をストックして、メニューボタンを実装する時にアクセシビリティについて思い出してもらえると嬉しいです。
Advent Calendar 2023では、他のコンポーネントにも焦点を当てて、アクセシビリティについても解説しているので、ぜひ購読していてください。
最後まで読んでくださってありがとうございます!
普段はデザインやフロントエンドを中心にQiitaに記事を投稿しているので、ぜひQiitaのフォローとX(Twitter)のフォローをお願いします。