웹 접근성을 준수하는 코드 작성하기는 현재 총 6편의 시리즈로 구성되어 있습니다.
- 웹 접근성을 준수하는 코드 작성하기 #1 - 서론
- 웹 접근성을 준수하는 코드 작성하기 #2 - Tab UI Script
- 웹 접근성을 준수하는 코드 작성하기 #번외편 - 대체 텍스트 적용기
- 웹 접근성을 준수하는 코드 작성하기 #3 - Accordion UI Script
- 웹 접근성을 준수하는 코드 작성하기 #4 - Popup Script
- 웹 접근성을 준수하는 코드 작성하기 #5 - Swiper.js 사용 시 웹 접근성 준수 하기 (현재글)
들어가며
오늘은 슬라이드를 사용하면서 웹 접근성을 지키기 위해 고려해야 할 것들을 살펴보려고 한다. 내가 주로 사용하는 라이브러리는 Swiper.js 여서 해당 라이브러리를 예시로 설명할 텐데, 다른 라이브러리도 약간씩 다르긴 하겠지만 API 문서를 살펴보고 비슷하게 지원하는 기능으로 매칭하여 구현하면 될 것 같다.
Slide 구현 시 웹 접근성을 준수하려면 고려해야 하는 것들
- 슬라이드의 이전, 다음 버튼이 있어 키보드로 이전, 다음 슬라이드의 접근이 가능해야 한다.
- 총 몇 장의 슬라이드 중 현재 활성화된 슬라이드가 몇 번째 슬라이드 인지 확인 할 수 있는 인디게이터가 있어야 한다.
- 자동 재생 슬라이드 일 경우 정지 버튼을 포함하고 있어야 한다.
- 키보드 tab, back tab으로 모든 슬라이드 접근이 가능하여야 한다.
- 무한 루프 형식의 슬라이드는 해당 슬라이드에서 포커스가 벗어날 수 없으면 웹 접근성에 위배된다. (무한 루프 옵션을 사용 시 키보드 탭 동작 또는 화살표 키 동작으로 해당 영역을 벗어날 수 있는지 확인 필요. 없다면 별도로 작업해주어야 함)
슬라이드의 이전, 다음 버튼이 있어 키보드로 이전, 다음 슬라이드의 접근이 가능해야 한다.
모든 슬라이드의 옵션에 있기 때문에 해당 옵션을 이용하면 쉽게 작업이 가능하다.
new Swiper('.swiper-container', {
navigation: {
nextEl: '.wrap-slide-box .swiper-button-next',
prevEl: '.wrap-slide-box .swiper-button-prev',
},
a11y: {
prevSlideMessage: '이전 슬라이드',
nextSlideMessage: '다음 슬라이드',
slideLabelMessage: '총 {{slidesLength}}장의 슬라이드 중 {{index}}번 슬라이드 입니다.',
},
});
- 여기서 a11y 옵션은 swiper 에서 제공하는 옵션으로 접근성 준수를 위해 활용하기 좋은 옵션이다. 해당 옵션에 관한 설명은 공식 API 문서를 참고하자.
총 몇 장의 슬라이드 중 현재 활성화된 슬라이드가 몇 번째 슬라이드 인지 확인 할 수 있는 인디게이터가 있어야 한다.
인디게이터 역시 모든 슬라이드의 옵션이 있을 것이다.
new Swiper('.swiper-container', {
navigation: {
nextEl: '.wrap-slide-box .swiper-button-next',
prevEl: '.wrap-slide-box .swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
},
a11y: {
prevSlideMessage: '이전 슬라이드',
nextSlideMessage: '다음 슬라이드',
slideLabelMessage: '총 {{slidesLength}}장의 슬라이드 중 {{index}}번 슬라이드 입니다.',
},
});
- swiper의 기본 옵션에 관한 것 설명은 희원 주임님이 작성한 글 - ’Swiper Library 스와이프 라이브러리를 사용해보자‘와 공식 API 문서를 함께 참고해보면 좋을 것 같다.
자동 재생 슬라이드 일 경우 정지 버튼을 포함하고 있어야 한다.
자동 재생 일시 정지 버튼의 경우 해당 버튼 옵션이 없을 지라도 자동 재생 정지 이벤트는 있기 때문에 버튼을 만들어서 해당 이벤트와 연결해주면 해결할 수 있다.
<div class="wrap-autoplay-control">
<button aria-pressed="false" aria-label="자동 재생 일시 정지"></button>
</div>
위와 같이 자동 재생 일시 정지 버튼을 마크업에 추가 후
new Swiper('.swiper-container', {
autoplay: {
delay: 1000,
},
navigation: {
nextEl: '.wrap-slide-box .swiper-button-next',
prevEl: '.wrap-slide-box .swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
},
a11y: {
prevSlideMessage: '이전 슬라이드',
nextSlideMessage: '다음 슬라이드',
slideLabelMessage: '총 {{slidesLength}}장의 슬라이드 중 {{index}}번 슬라이드 입니다.',
},
on: {
init: function () {
thisSlide = this;
autoPlayBtn = document.querySelector('.wrap-autoplay-control > button');
autoPlayBtn.addEventListener('click', (e) => {
autoPlayState = autoPlayBtn.getAttribute('aria-pressed');
if (autoPlayState === 'false') {
autoPlayBtn.setAttribute('aria-pressed', 'true');
thisSlide.autoplay.stop();
} else if (autoPlayState === 'true') {
autoPlayBtn.setAttribute('aria-pressed', 'false');
thisSlide.autoplay.start();
}
});
},
},
});
해당 버튼을 클릭했을 때 슬라이드의 autoplay 옵션을 정지/재생으로 토글 될 수 있게 작업해 준다. 이때 aria-pressed 속성을 추가해 버튼의 현재 상태를 알 수 있게 해주면 좋다.
동작 예시 : https://codepen.io/SeulbiLee_XE/pen/vYZzBPX
키보드 tab, back tab으로 모든 슬라이드 접근이 가능하여야 한다.
일반적으로 키보드 tab을 이용해 슬라이드에 접근 시, 별도의 작업 없이도 해당 슬라이드 접근은 가능하다. 다만, 키보드 tab으로 인해 강제로 화면 포커스가 넘어가기 때문에 다음 슬라이드로 넘어갈 때 슬라이드 동작이 고장 날 수 있다. 이런 부분까지 고려한다면 키보드 tab으로 슬라이드 접근 시 다음 또는 이전 슬라이드로 전환 시 키보드 이벤트를 막아주고, 슬라이드 전환 후 전환 된 슬라이드로 포커스 이동을 시켜주는 스크립트 작업이 필요하다.
<div class="wrap-slide-box">
<div class="swiper-button-next"></div>
<div class="swiper-container">
<div class="swiper-wrapper">
<div class="swiper-slide slide01" tabindex="-1">
<h1>slide 1</h1>
<ol>
<li><a href="javascript:;">링크 1-1</a></li>
<li><a href="javascript:;">링크 1-2</a></li>
<li><a href="javascript:;">링크 1-3</a></li>
<li><a href="javascript:;">링크 1-4</a></li>
</ol>
</div>
<div class="swiper-slide slide02" tabindex="-1">
<h1>slide 2</h1>
</div>
<div class="swiper-slide slide03" tabindex="-1">
<h1>slide 3</h1>
<ol>
<li><a href="javascript:;">링크 3-1</a></li>
<li><a href="javascript:;">링크 3-2</a></li>
<li><a href="javascript:;">링크 3-3</a></li>
<li><a href="javascript:;">링크 3-4</a></li>
</ol>
</div>
<div class="swiper-slide slide04" tabindex="-1">
<h1>slide 4</h1>
<ol>
<li><a href="javascript:;">링크 4-1</a></li>
<li><a href="javascript:;">링크 4-2</a></li>
<li><a href="javascript:;">링크 4-3</a></li>
<li><a href="javascript:;">링크 4-4</a></li>
</ol>
</div>
<div class="swiper-slide slide05" tabindex="-1">
<h1>slide 5</h1>
<ol>
<li><a href="javascript:;">링크 5-1</a></li>
<li><a href="javascript:;">링크 5-2</a></li>
<li><a href="javascript:;">링크 5-3</a></li>
<li><a href="javascript:;">링크 5-4</a></li>
</ol>
</div>
</div>
<div class="swiper-button-prev"></div>
<div class="swiper-pagination"></div>
</div>
</div>
슬라이드 마크업을 할 때, 이전 버튼과 다음 버튼의 위치를 많이 고민하게 되는데, 현재 코드에서는 가장 먼저 다음 버튼을 넣고 그다음 슬라이드, 마지막으로 이전 버튼을 넣었다. 사용자 경험(UX)을 생각했을 때, 제일 처음 슬라이드 접근 시에는 이전 버튼은 필요 없을 것 같았고, 슬라이드 내부로 들어가기 전 슬라이드의 빠른 탐색을 위해 다음 버튼은 먼저 있어야 할 것 같았다. 그리고 슬라이드를 모두 탐색 후 나왔을 때 다시 이전 슬라이드로 돌아가려면 back tab을 여러 번 하여 돌아갈 수밖에 없기 때문에 이전 버튼의 위치는 슬라이드 탐색 후 접근 할 수 있게 하였다.
let thisSlide, // Swiper Slide
focusOut, // 슬라이드 키보드 접근 확인
slideFocus = {}, // 슬라이드 내부 탭 포커스 가능한 요소 저장
swiperWrapper = document.querySelector('.swiper-wrapper'),
slideAll, // 전체 슬라이드 저장
slideLength, // 슬라이드 갯수
onClickNavigation, // 슬라이드 이전/다음 버튼으로 슬라이드 전환 확인
navigations = {}, // 슬라이드 이전 다음 버튼
prevEnter; // 이전 버튼 키보드 엔터로 접근 확인
const slideKeyDownEvt = (e, idx) => {
// back tab : 첫 번째 슬라이드 포커스 시
if (e.key == 'Tab' && e.shiftKey && thisSlide.activeIndex === 0) {
focusOut = false;
// back tab : 그 외 슬라이드 포커스 시
} else if (e.key == 'Tab' && e.shiftKey && e.target === slideFocus[idx][0]) {
e.preventDefault();
focusOut = true;
slideAll[thisSlide.activeIndex - 1].setAttribute('tabindex', '0');
thisSlide.slideTo(thisSlide.activeIndex - 1);
removeSlideTabindex();
} else if (
e.key == 'Tab' &&
!e.shiftKey &&
e.target === slideFocus[idx][slideFocus[idx].length - 1]
) {
if (idx >= slideLength) {
// tab : 마지막 슬라이드 내 마지막 요소 포커스 시
focusOut = false;
} else {
// tab : 그 외 슬라이드 내 마지막 요소 포커스 시
e.preventDefault();
if (slideAll[thisSlide.activeIndex + 1] <= slideLength)
slideAll[thisSlide.activeIndex + 1].setAttribute('tabindex', '0');
focusOut = true;
thisSlide.slideTo(thisSlide.activeIndex + 1);
removeSlideTabindex();
}
}
};
// 슬라이드 내부 클릭 요소 tabindex 값 삭제
const removeSlideTabindex = () => {
slideAll.forEach((element, i) => {
let focusTarget = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
focusTarget.forEach((el, idx) => {
if (el.closest('.swiper-slide') === slideAll[thisSlide.activeIndex])
el.removeAttribute('tabindex');
});
});
};
const slideFocusAct = (e, idx, next) => {
if (onClickNavigation) {
if (e.key == 'Enter' && !next) prevEnter = true;
else if (e.key == 'Tab') {
if (!e.shiftKey && next) {
e.preventDefault();
slideFocus[idx][0].setAttribute('tabindex', '0');
slideFocus[idx][0].focus();
removeSlideTabindex();
onClickNavigation = false;
} else if (prevEnter && !next) {
if (idx === 0) idx = 1;
slideFocus[idx - 1][0].setAttribute('tabindex', '0');
slideFocus[idx - 1][0].focus();
removeSlideTabindex();
onClickNavigation = false;
prevEnter = false;
}
}
}
};
new Swiper('.swiper-container', {
slidesPerView: 1,
speed: 700,
navigation: {
nextEl: '.wrap-slide-box .swiper-button-next',
prevEl: '.wrap-slide-box .swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
},
a11y: {
prevSlideMessage: '이전 슬라이드',
nextSlideMessage: '다음 슬라이드',
slideLabelMessage: '총 {{slidesLength}}장의 슬라이드 중 {{index}}번 슬라이드 입니다.',
},
on: {
init: function () {
thisSlide = this;
slideAll = document.querySelectorAll('.swiper-slide');
slideLength = slideAll.length - 1;
navigations['prev'] = document.querySelector('.swiper-button-prev');
navigations['next'] = document.querySelector('.swiper-button-next');
slideAll.forEach((element, idx) => {
slideAll[thisSlide.activeIndex].setAttribute('tabindex', '0');
let focusTarget = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
focusTarget.forEach((el, i) => {
if (el.closest('.swiper-slide') !== slideAll[thisSlide.activeIndex]) {
el.setAttribute('tabindex', '-1');
}
});
slideFocus[idx] = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
slideFocus[idx].unshift(element);
slideFocus[idx][0].addEventListener('keydown', (e) => slideKeyDownEvt(e, idx));
});
Object.values(navigations).forEach((navigation) => {
navigation.addEventListener('keydown', () => {
onClickNavigation = true;
});
});
navigations['next'].addEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, true),
);
navigations['prev'].addEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, false),
);
},
touchMove: function () {
return (onClickNavigation = false);
},
slideNextTransitionEnd: function () {
// 키보드 탭 버튼으로 인한 슬라이드 변경 시 동작
if (focusOut) {
slideFocus[this.realIndex][0].focus();
focusOut = false;
}
},
slidePrevTransitionStart: function () {
// 키보드 탭 버튼으로 인한 슬라이드 변경 시 동작
if (focusOut) {
slideFocus[this.realIndex][slideFocus[this.realIndex].length - 1].focus();
focusOut = false;
}
},
},
});
동작 예시 : https://jsfiddle.net/seulbiLee/wdxyc6n7/11
무한 루프 형식의 슬라이드는 해당 슬라이드에서 포커스가 벗어날 수 없으면 웹 접근성에 위배된다.
무한 루프 형식의 슬라이드는 키보드 포커스가 그 안에서 계속 맴돌고 빠져나오지 못하는 경우가 종종 있다. 무한 루프 슬라이드 일 경우 키보드 테스트 후 슬라이드 안에서 포커스가 벗어나지 못한다면 별도의 작업을 해주어야 한다. 나는 무한 루프를 위해 카피 된 슬라이드를 제외하고, 원본 슬라이드에만 포커스가 가도록 작업해보았다. Swiper.js에서는 카피 된 슬라이드의 경우 swiper-slide-duplicate 라는 클래스가 붙게 되어 있다. 그리고 활성화된 슬라이드의 index 값을 알려주는 activeIndex 값 외에 카피 된 슬라이드를 제외하고 원본 슬라이드의 index 값을 가져오는 realIndex 라는 값이 따로 있다. swiper-slide-duplicate 와 realIndex 값 두 가지를 이용하여 원본 슬라이드의 값을 찾아 키보드 포커스 이벤트를 제어해주었다.
let thisSlide, // Swiper Slide
focusOut, // 슬라이드 키보드 접근 확인
slideFocus = {}, // 슬라이드 내부 탭 포커스 가능한 요소 저장
swiperWrapper = document.querySelector('.swiper-wrapper'),
slideAll, // 전체 슬라이드 저장
realSlideAll, // loop 모드 일때 복사 된 슬라이드를 제외한 실제 슬라이드 저장
slideLength, // 슬라이드 갯수 - 1
onClickNavigation, // 슬라이드 이전/다음 버튼으로 슬라이드 전환 확인
navigations = {}, // 슬라이드 이전 다음 버튼
prevEnter; // 이전 버튼 키보드 엔터로 접근 확인
const slideKeyDownEvt = (e, idx) => {
// back tab : 첫 번째 슬라이드 포커스 시
if (e.key == 'Tab' && e.shiftKey && thisSlide.realIndex === 0) {
focusOut = false;
// back tab : 그 외 슬라이드 포커스 시
} else if (e.key == 'Tab' && e.shiftKey && e.target === slideFocus[idx][0]) {
e.preventDefault();
focusOut = true;
realSlideAll[thisSlide.realIndex - 1].setAttribute('tabindex', '0');
thisSlide.slideTo(thisSlide.activeIndex - 1);
removeSlideTabindex();
} else if (
e.key == 'Tab' &&
!e.shiftKey &&
e.target === slideFocus[idx][slideFocus[idx].length - 1]
) {
if (idx >= slideLength) {
// tab : 마지막 슬라이드 내 마지막 요소 포커스 시
focusOut = false;
} else {
// tab : 그 외 슬라이드 내 마지막 요소 포커스 시
e.preventDefault();
if (realSlideAll[thisSlide.realIndex + 1] <= slideLength)
realSlideAll[thisSlide.realIndex + 1].setAttribute('tabindex', '0');
focusOut = true;
thisSlide.slideTo(thisSlide.activeIndex + 1);
removeSlideTabindex();
}
}
};
// 슬라이드 내부 클릭 요소 tabindex 값 삭제
const removeSlideTabindex = () => {
slideAll.forEach((element, i) => {
let focusTarget = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
focusTarget.forEach((el, idx) => {
if (el.closest('.swiper-slide') === slideAll[thisSlide.activeIndex])
el.removeAttribute('tabindex');
});
});
};
const slideFocusAct = (e, idx, next) => {
if (onClickNavigation) {
if (e.key == 'Enter' && !next) prevEnter = true;
else if (e.key == 'Tab') {
if (idx === 0) {
idx = slideLength;
thisSlide.slideTo(slideLength + 1, 0);
} else if (idx === realSlideAll.length + 1) {
idx = 0;
thisSlide.slideTo(1, 0);
} else {
idx = idx - 1;
}
if ((!e.shiftKey && next) || (prevEnter && !next)) {
e.preventDefault();
slideFocus[idx][0].setAttribute('tabindex', '0');
slideFocus[idx][0].focus();
removeSlideTabindex();
onClickNavigation = false;
prevEnter = false;
}
}
}
};
new Swiper('.swiper-container', {
slidesPerView: 1,
speed: 300,
loop: true,
navigation: {
nextEl: '.wrap-slide-box .swiper-button-next',
prevEl: '.wrap-slide-box .swiper-button-prev',
},
pagination: {
el: '.swiper-pagination',
},
a11y: {
prevSlideMessage: '이전 슬라이드',
nextSlideMessage: '다음 슬라이드',
slideLabelMessage: '총 {{slidesLength}}장의 슬라이드 중 {{index}}번 슬라이드 입니다.',
},
on: {
init: function () {
thisSlide = this;
slideAll = document.querySelectorAll('.swiper-slide');
realSlideAll = document.querySelectorAll(
'.swiper-slide:not(.swiper-slide-duplicate)',
);
slideLength = realSlideAll.length - 1;
navigations['prev'] = document.querySelector('.swiper-button-prev');
navigations['next'] = document.querySelector('.swiper-button-next');
slideAll.forEach((element, i) => {
if (element.classList.contains('swiper-slide-duplicate')) {
element.setAttribute('aria-hidden', 'true');
}
slideAll[thisSlide.activeIndex].setAttribute('tabindex', '0');
let focusTarget = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
focusTarget.forEach((el, idx) => {
if (el.closest('.swiper-slide') !== slideAll[thisSlide.activeIndex]) {
el.setAttribute('tabindex', '-1');
}
});
});
realSlideAll.forEach((element, idx) => {
slideFocus[idx] = Array.prototype.slice.call(
element.querySelectorAll(
'a, button, input, [role="button"], textarea, select, [tabindex="0"]',
),
);
slideFocus[idx].unshift(element);
slideFocus[idx][0].removeEventListener('keydown', (e) => slideKeyDownEvt(e, idx));
slideFocus[idx][0].addEventListener('keydown', (e) => slideKeyDownEvt(e, idx));
});
Object.values(navigations).forEach((navigation) => {
navigation.addEventListener('keydown', () => {
onClickNavigation = true;
});
});
navigations['next'].removeEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, true),
);
navigations['next'].addEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, true),
);
navigations['prev'].removeEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, false),
);
navigations['prev'].addEventListener('keydown', (e) =>
slideFocusAct(e, thisSlide.activeIndex, false),
);
},
touchMove: function () {
return (onClickNavigation = false);
},
slideNextTransitionEnd: function () {
// 키보드 탭 버튼으로 인한 슬라이드 변경 시 동작
if (focusOut) {
slideFocus[this.realIndex][0].focus();
focusOut = false;
}
},
slidePrevTransitionStart: function () {
// 키보드 탭 버튼으로 인한 슬라이드 변경 시 동작
if (focusOut) {
slideFocus[this.realIndex][slideFocus[this.realIndex].length - 1].focus();
focusOut = false;
}
},
},
});
동작 예시 : https://jsfiddle.net/seulbiLee/3wpy9svk/7/
키보드 탭 이벤트 경우, 정말 많은 상황을 고려해야 한다. 그만큼 매우 까다롭고 어려운 작업이다. 현재 작성된 스크립트는 가로형 슬라이드에 최적화되어 작업 되었으므로, 세로형 슬라이드 작업 시에는 또 별도의 테스트 작업이 필요할 것이다. 며칠 동안 슬라이드 예시 코드만 짰는데도, 아직도 마음에 들지 않는 것 같다. 키보드 탭을 슬라이드 전환 시간조차 주지 않고 연타를 하게 되면 콘솔 에러가 나기도 하는데, 해당 부분은 좀 더 테스트와 연구가 필요할 것 같다. (혹시 아이디어 있으면 주세요 ㅠㅜ 같이 코드 만들어봐요)
(위 스크립트들은 ES6 문법이 포함되어 있기 때문에 IE에서는 동작하지 않는다. IE 동작을 위해선 closest, forEach, 화살표 함수, 백틱에 관한 polyfill 적용이 필요하다.)
마치며
꽤 긴 시간 웹 접근성을 준수하는 코드 작성이라는 주제를 가지고 글을 썼는데, 이번 편이 마지막이 될 것 같다. 더 필요한 UI가 생기면 그때 다시 작성하기로 하고 일단 이번 주제는 여기서 끝!