대부분의 서비스에서 form을 다루는 요구사항이 주어진다. 정말 기본적인 로그인, 회원가입, 회원 정보 수정이 그 예시가 될 수 있겠고 이외에도 포스트 등록, 포스트 수정, 포털 사이트의 검색, 커머스 서비스에서는 주문서 작성 등등 다양하게 사용이 된다.
최근 기업과제를 진행하며 직무나 관심 카테고리 선택이 가능한 회원가입 기능을 구현했다. 시맨틱한 마크업을 사용하고자 각각 radio input과 checkbox input을 커스텀해서 사용했다.
기능을 구현하고나니, 어렵지않은 요구사항이기도 해서 내가 봤을 때는 동작은 아주 잘 되었다!
하지만 이후 접근성(accessbility)을 고려하지 못한 부분에 대해 피드백을 받았다. 이론적으로는 알고있는 부분이라하더라도, 많고 다양한 요구사항들을 나의 관점에서 개발을 하다보면 자칫 놓치기 쉬운 부분이라고 생각이 들더라. 그렇다하더라도 접근성을 보장하는 것은 프론트엔드 개발자의 책임이기 때문에 개선이 필요하다.
뿐만 아니라 웹 접근성 보장은 법률에 명시된 의무사항이며, 관련 법에 따라 모든 웹 사이트가 웹 접근성을 준수해야한다고 알려져있다.
본 포스팅에서는 radio와 checkbox input을 활용한 기능의 접근성을 어떻게 향상했는지와 시행착오를 위주로 얘기해보려한다.
사실 radio, checkbox와 같은 마크업을 사용하면 기본적으로 웹 접근성을 고려한 기능이 내장되어있다. 그렇기 때문에 탭이나 방향키를 눌러보면 항목 사이의 이동 등과 같이 키보드 만을 이용한 동작이 가능하다.
아래 코드는 카테고리 선택을 위한 컴포넌트의 일부인데, UI/UX 향상을 위해 label, input과 같은 시맨틱한 마크업에다가 디자인을 커스텀한 것을 볼 수 있다.
export default function Category(props: CategoryProps) {
// ...
return (
<StyledCheckBox>
카테고리
<div className="group">
{CATEGORY.map((item, index) => (
<label key={index}>
{item}
<input
type="checkbox"
name="categories"
onChange={() => {
const newValue = [...value].filter(v => v !== item)
if (newValue.length === value.length) {
newValue.push(item)
}
const $warning = warningRef.current as HTMLDivElement
$warning.innerHTML = ''
validation(newValue)
}}
onInvalid={event => {
event.preventDefault()
validation(value)
}}
checked={value.includes(item)}
/>
</label>
))}
<input
type="radio"
name="category"
required
onInvalid={() => {
validation(value)
}}
onChange={() => {}}
checked={value.length !== 0}
/>
</div>
<div ref={warningRef} id="warning"></div>
</StyledCheckBox>
)
}
const StyledCheckBox = styled(StyledField)`
font-size: 1.5rem;
gap: 5px;
#group {
display: flex;
gap: 10px;
}
label {
font-size: 1rem;
padding: 5px 15px;
border: 2px solid #b9b9b9;
border-radius: 40px;
&:has(:checked) {
border-color: ${palette.main};
}
}
input {
appearance: none;
margin: 0;
}
`
하지만 이 코드 위에서 동작하는 체크박스는 키보드 상호작용에 대해 아무런 반응이 없었다. 해당 요소에 포커스가 되면 포커스된 요소를 화면에서 인식할 수 없다. 동일하게 커스텀했던 radio 버튼도 마찬가지였다.
원래 focus된 input은 테두리가 표시되게 되는게 기본 html 동작이다. 하지만 나는 알약 모양의 버튼을 만들고자했어서, 기본 체크박스를 없애고 label을 추가해주었다.
이때 input을 시각적으로 없앴기 때문에 focus 표시가 사용자에게 시각적으로 보여지지 않게되는 것이다.
:has()
, :focus
의 조합으로 해결접근성을 위한 몇가지 패턴들이 소개되어있는 w3c 공식문서에 radio, checkbox와 관련한 것도 자세하게 잘 설명이 되어있었고 이에 따라 해결을 해주었다.
우선 요소가 활성화된다는 것을 쉽게 인식하도록 하기 위해, 위에 포인터를 가져가게 되면 테두리를 추가하고 커서를 포인터로 설정했다.
아래 코드는 label 요소에 선언해준 스타일이다.
font-size: 1rem;
padding: 5px 15px;
border: 1px solid #b9b9b9;
border-radius: 40px;
input {
appearance: none;
margin: 0;
}
cursor: pointer;
&:hover {
outline: 3px solid ${palette.grey300};
}
&:has(:checked) {
border-color: ${palette.main};
}
focus가 발생하면 요소에 테두리를 주어 강조표시를 해주어야한다.
근데 기본적으로 label은 포커스 가능한 요소가 아니기 때문에, 바로 pseudo-class :focus
요소에 스타일을 주면 동작하지 않을 것이다.
가장 처음에는 label 자체를 포커스 가능한 요소로 전환하는 방법을 생각해보기도 했다. 이를 위해서는 tabindex 속성을 사용한다. label 요소의 속성에 tabIndex="0"
을 전달해주고, 눈에 보이지 않는 input에는 포커스 기능을 제거하기 위해 tabIndex="-1"
로 설정하는 방법을 생각했다.
하지만 몇 가지 문제가 있다. input의 tab 관련 브라우저 기본 동작이 무효해짐에 따라서 구현해야할 부분이 많아진다.
예를 들어 input 요소를 클릭(탭으로 focus되어있는 상황에서 space)하면 change이벤트가 발생되야하는데, 해당 동작이 무시된다. 또한 라디오 버튼의 경우 요소 간에 방향키를 통해 이동할 수 있는데 이 또한 무시된다.
이와 같이 접근성을 위한 브라우저 기본 동작을 제거하여, 잠재적인 접근성 이슈가 있을 수 있으면서 코드를 복잡하게 만들 수 있어 해당 방법에는 어려움이 있을 거라 생각했다.
:has()
를 활용했다. 파라미터에는 상대적인 selector목록이 전달될 수 있다. 이와 하나라도 일치하는 요소가 있다면 해당 스타일이 적용된다.
따라서 나는 &:has(:focus)
에 대한 스타일을 지정해주었고, 눈에 띄지만 다른 상태의 테두리 색상과 구별해주기위해 아래처럼 스타일을 주었다.
font-size: 1rem;
padding: 5px 15px;
border: 1px solid #b9b9b9;
border-radius: 40px;
input {
appearance: none;
margin: 0;
}
//
cursor: pointer;
&:hover {
outline: 3px solid ${palette.grey300};
}
&:has(:focus) {
outline: 2px solid black;
outline-offset: 2px;
}
&:has(:checked) {
border-color: ${palette.main};
}
이를 구현하기위해 :focus-within
가 대안이될 수 있다. 차이는 has의 경우 구체적인 선택자들을 제공할 수 있다는 것이다.
다행히 css에서 선언적으로 사용할 수 있는 pseudo class가 제공되어, 스타일 코드만 작성해주면됐다.
그 결과 브라우저에 focus가 발생했을 때 현재 초점이 있는 요소가 무엇인지에 대한 정보를 제공할 수 있게 되어 접근성을 향상했다.
지금까지한 작업은 마우스, 펜과 같은 포인팅 장치를 사용할 수 없는 사용자를 위한 작업이었다.
키보드를 이용하는 유저는 현재 포커스된 요소의 정보가 필요하지만, 포인팅 장치를 이용하는 유저에게는 마우스 아래에 있는 요소가 곧 타깃이기 때문에, 포커스 표시가 불필요할 것이다.
하지만 :focus
를 활용해 스타일링한 상황에서는 포인팅 장치로부터 클릭이 발생하면, focus되어 요소에 테두리 표시가 된다.
이를 해결하기 위해 click 이벤트 핸들러에서 포인팅 장치를 구분하여 분기처리하도록 했다.
참고로 click 이벤트가 발생하는 시점은 아래와 같다.
click 이벤트는 보통 pointer event 객체를 인자로 받는데, 이 객체는 pointerType이라는 속성을 갖고있어 포인팅 장치의 종류를 확인할 수 있다. mouse, touch, pen 그리고 감지할 수 없다면 빈문자열로 세팅된다.
키보드로 입력하는 경우, 다른 장치와 달리 빈 문자열로 전달되는 것을 확인하고 아래와 같이 click 이벤트 핸들러 로직에 분기로직을 작성해주었다.
포인팅 장치로부터의 입력이면 명시적으로 blur API를 활용해 포커스가 사라지도록 한다.
// ...
function Category(props: CategoryProps) {
const { field, setField } = props
const { value } = field
// ...
const device = useRef<'' | 'mouse' | 'touch'>('')
const handleClick: React.MouseEventHandler<HTMLLabelElement> = event => {
if (!(event.nativeEvent instanceof PointerEvent)) {
return
}
const { pointerType } = event.nativeEvent
if (pointerType === 'mouse' || pointerType === 'touch') {
device.current = pointerType
}
if (event.target === event.currentTarget) {
// 포인팅 장치로부터 발생한 click 이벤트가 label, input 두 요소에서 발생함에 따라
// 이를 제한하기 위한 로직
return
}
if (device.current === 'mouse' || device.current === 'touch') {
const $radio = event.target as HTMLInputElement
$radio.blur()
}
const newValue = [...value].filter(v => v !== item)
if (newValue.length === value.length) {
newValue.push(item)
}
validation(newValue)
device.current = ''
}
return (
<StyledCategory>
<fieldset>
<legend>카테고리</legend>
{CATEGORY.map((item, index) => (
<Chip
key={index}
onClick={handleClick}
>
{item}
<input
type="checkbox"
name="categories"
onInvalid={event => {
event.preventDefault()
validation(value)
}}
checked={value.includes(item)}
readOnly
/>
</Chip>
))}
<input
type="radio"
name="categories-validator"
required
onInvalid={event => {
event.preventDefault()
validation(value)
}}
checked={value.length !== 0}
readOnly
hidden
/>
</fieldset>
<div ref={warningRef} id="warning"></div>
</StyledCategory>
)
}
// ...
이렇게 해서 어렵지 않게 문제를 해결할 수 있었다. 그러나 또 다른 문제를 맞닦드릴 수 있었다. 🥲
크롬 브라우저에서는 예상한대로 기능이 잘 동작하지만, 사파리 브라우저에서는 동일한 기능이 동작하지 않는 문제가 발생했다.
click event에 전달된 event에서 pointer type을 읽지 못하기 때문이였다.
MDN 공식문서에 따르면 click event 사용 시 파이어폭스, 사파리 브라우저에서의 경우 전달되는 이벤트가 MouseEvent 타입이다. PointerEvent 여야지 pointer type을 읽어와 작성한 코드가 정상적으로 동작이되는데, 이처럼 브라우저 호환성 이슈가 있었다. (공식문서를 끝까지 읽어보자…)
브라우저 호환성 문제에 유의하며, 모든 브라우저에서 동작하는게 보장되는 로직을 생각해야했다.
변경이 발생할 때 포인팅 장치 입력이 있었는지만 구분을 하면되니, 이를 항상 모든 브라우저에서 사용가능한 pointerdown, pointerup 이벤트를 활용했다. 이 이벤트들은 포인팅 장치로부터 발생한 이벤트 만을 처리한다.
일반적으로 정상적인 클릭 동작은 눌렀다 떼는 동작이다. 이를 판별하기위해 pointerDown, clickByPointer 두 변수를 두고 이벤트 핸들러 간에 공유 및 조작할 수 있도록 한다.
pointerup 이벤트가 발생하면, pointerDown가 true로 설정되었는지를 확인하고 clickByPointer(포인터 장치로 부터 클릭이벤트가 발생했는지 여부)를 true로 설정한다.
항목간에 변경이 발생하면 이 clickByPointer 값에 따라 원하는 로직을 수행한다.
아래는 지금까지의 동작이 작성된 코드의 일부이다.
pointerDown, clickByPointer 변수는 컴포넌트 바깥에 선언하여 관리된다. 또는 컴포넌트 내부에서 useRef로 선언할 수도 있다. 해당 변수들이 다른 상태로 인한 리렌더링으로 영향을 받지않게 유의하는게 중요한 것 같다.
let pointerDown = false
let clickByPointer = false
function Category(props: CategoryProps) {
//...
const handlePointerDown: React.PointerEventHandler<HTMLDivElement> = event => {
const $checkbox = (event.target as HTMLElement).closest('label')
if (!$checkbox) {
return
}
pointerDown = true
}
const handlePointerUp: React.PointerEventHandler<HTMLDivElement> = event => {
const $checkbox = (event.target as HTMLElement).closest('label')
if (!$checkbox || !pointerDown) {
pointerDown = false
return
}
clickByPointer = true
}
const handleChange: React.ChangeEventHandler<HTMLDivElement> = event => {
const $checkbox = event.target as HTMLInputElement
validation(toggle($checkbox.value))
pointerDown = false
if (!clickByPointer) {
return
}
clickByPointer = false
$checkbox.blur()
}
// ...
return (
<StyledCategory onPointerDown={handlePointerDown} onPointerUp={handlePointerUp} onChange={handleChange}>
// ...
</StyledCategory>
)
}
:focus-visible
접근성 이슈, 브라우저 호환성 문제들을 거치며 비로소 문제를 해결할 수 있었다. 하지만 문뜩 브라우저에 단에서 input 요소의 기본 스타일링이 되었는지가 궁금하게되었다. tab을 누르면 테두리가 표시 될테고, 포인팅 장치로 입력하면 어떻게되지? 직접 시행착오를 거치면서 스타일링이 :focus
를 활용해 구현되어있지는 않을 것이라 생각했다.
소스를 보며 다른 :focus-visible
라는 pseudo class를 사용하는 것을 확인할 수 있었다.
사실 내가 구현하고자했던 로직을 이 class로 해결할 수 있었던 것이다. 이 클래스는 :focus
와는 달리 사용자에게 현재 포커스가 어디위치해있는지 표시해야될 상황에서 적용된다.
따라서 포인팅 장치의 입력으로부터 발생한 포커스 상황에서는 그렇지 않은 상황으로 간주하고, 키보드 장치와 같은 입력으로인한 포커스 상황에서는 해당 class가 적용이 되어 이때 테두리를 표시할 수 있다.
공식문서에서도 입력 방식에 따라 다른 초점 타겟 표시를 해주기위해 유용하다고 설명이 되어있다. 또한 극소수의 브라우저 버전을 제외하고 모든 브라우저에서 지원하고 있기도하다.
다만 브라우저마다 조금씩 ui를 다르게 주는 부분이 있다. 만약 제공하는 서비스에서 모든 브라우저에서 동일하게 UI를 보여주는게 중요하다면 브라우저의 기능에 의존하지않고 직접 구현을 해야하거나 커스텀이 필요할 것이고 그렇지 않고 약간의 UI차이를 수용가능하다면 브라우저의 기능을 활용하면 되겠다.
UI/UX를 고려한 기능을 직접 구현할 수도 있고 브라우저에서 지원하는 pseudo class, pseudo element 등을 활용할 수도 있다. 직접 구현한 방식은 지원되지않는 기능들도 제공할 수 있는 이점이 있고 다양한 요구사항을 구현할 수 있다. 반면 브라우저에서 지원하는 기능들을 활용하면 빠르게 요구사항을 구현할 수 있다.
이번 경험을 통해 주어진 요구사항을 구현한다는 것은 내가 바라보는 화면에서 정상적으로 동작이 되어야하는 것은 물론이고 접근성, 브라우저 호환성 등 고려해야할 것들이 많다는 것을 느꼈다.
Patterns - W3C
"focus-visible" | Can I use... Support tables for HTML5, CSS3, etc