페이지네이션 컴포넌트, 포커스를 잃지 않게 하기
2편에서 이런 문제를 다뤘다.
19페이지에서 › 버튼에 포커스를 두고 Enter를 누른다. 20페이지에 도착하면 마지막 페이지이므로 › 버튼이 사라진다. 문제는 사라진 버튼이 방금 전까지 사용자가 누르고 있던 버튼이라는 데 있었다.
내 재현 환경에서 포커스된 요소를 숨기자 document.activeElement는 body가 됐다. 그래서 2편에서는 다음과 같은 정책을 만들었다.
포커스된 버튼이 사라질 때만 현재 페이지 버튼으로 포커스를 옮긴다.
당시에는 renderer 하나가 모든 버튼을 만들고 있었다. 어떤 버튼이 사라졌는지, 포커스를 어디로 옮겨야 하는지 한 함수 안에서 모두 알 수 있었다.
이 정책은 페이지네이션을 커스텀 엘리먼트로 바꾸면서 다시 깨졌다.
컴포넌트를 나눴다
2편의 페이지네이션은 함수로 생성했다.
const pagination = createPagination({
container,
totalItems: 195,
pageSize: 10,
onPageChange: (page) => fetchList(page),
});이를 다음과 같은 커스텀 엘리먼트로 바꿨다.
<my-pagination
current-page="3"
total-items="195"
page-size="10"
></my-pagination>처음에는 이 변화의 이점을 “HTML에서 렌더링할 수 있다”라고 생각했다. 하지만 렌더링 코드가 사라지는 것은 아니다. 여전히 JavaScript가 필요하다.
달라진 것은 초기화 방식이었다. 서버가 내려준 HTML에 <my-pagination>이 이미 있다면, 브라우저는 컴포넌트 정의가 등록된 뒤 기존 요소를 upgrade한다. 이미 문서에 연결된 요소라면 connectedCallback도 호출된다.
일반적인 HTML content attribute는 문자열이다. 따라서 임의의 JavaScript 함수 객체를 콜백 값으로 직접 전달할 수는 없다. 프로퍼티로 함수를 주입할 수는 있지만, 그러면 소비자가 다시 클라이언트 배선 코드를 작성해야 한다.
그래서 콜백을 받는 대신 이벤트를 내보내기로 했다.
pagination.addEventListener("page-navigate", (event) => {
// 페이지 상태 갱신
});컴포넌트에는 상태가 들어오고, 사용자 상호작용은 이벤트로 나간다. <input>이 값과 이벤트를 구분하는 것과 비슷한 방향이다.
디자인도 페이지마다 달랐기 때문에 컴포넌트를 다시 나눴다.
<my-pagination current-page="3">
<my-prev></my-prev>
<my-pages></my-pages>
<my-next></my-next>
</my-pagination>부모인 <my-pagination>이 상태를 가지고, 각 하위 컴포넌트가 자기 버튼을 그린다.
여기서 2편의 문제가 다시 나타났다.
숨기는 컴포넌트와 포커스를 받을 컴포넌트가 달라졌다
19페이지에서 ›를 누르면 <my-next>가 자기 버튼을 숨긴다. 그러나 포커스가 가야 할 현재 페이지 버튼은 <my-pages> 안에 있다.
<my-next> <my-pages>
다음 버튼을 숨김 현재 페이지 버튼을 가지고 있음
포커스 유실을 감지함 포커스 목적지를 알고 있음한 컴포넌트가 문제를 감지하지만 해결에 필요한 대상은 다른 컴포넌트가 소유한다.
상태는 이런 구조에서도 어렵지 않았다. 부모가 상태를 한 번 방송하면 각 하위 컴포넌트가 자기 몫을 갱신하면 된다. 포커스는 달랐다.
여러 컴포넌트가 상태를 독립적으로 반영할 수는 있지만, 한 번의 포커스 복구에서는 최종 목적지 하나를 선택해야 한다.
결국 포커스 복구를 세 부분으로 나눴다.
| 판단 | 소유자 |
|---|---|
| 포커스가 유실됐는가 | 버튼을 숨긴 하위 컴포넌트 |
| 포커스를 받을 수 있는가 | 각 후보 하위 컴포넌트 |
| 후보 중 누구를 선택할 것인가 | 전체 배치를 아는 host |
하위 컴포넌트는 자신이 포커스를 받을 수 있는지 판단하고, host는 유실 지점을 기준으로 가까운 후보부터 묻는다. 실제 .focus()는 대상의 내부 구조를 아는 하위 컴포넌트가 실행한다.
host가 직접 <my-pages> 내부 버튼을 찾을 수도 있었다. 하지만 그러면 host가 하위 컴포넌트의 내부 마크업에 의존한다. 페이지 버튼을 <button>에서 <a>로 바꾸는 것만으로 host까지 수정해야 한다.
그래서 host는 후보를 묻는 순서만 정하고, 대상 선택과 포커스 실행은 각 하위 컴포넌트에 남겼다.
포커스 가능 여부는 실행해 보기 전까지 예측이다
처음에는 후보 하위 컴포넌트가 hidden 속성을 확인해 포커스를 받을 수 있는지 답하도록 했다.
하지만 버튼은 light DOM에 있었다. 소비자 CSS가 다음처럼 내부 버튼을 숨길 수 있었다.
my-pages button {
display: none;
}이 경우 hidden은 false지만 focus()는 성공하지 않는다. 렌더링되지 않는 요소는 포커스 가능한 영역이 아니지만, focus()는 실패를 예외로 알려주지도 않는다.
따라서 “포커스를 받을 수 있다”는 답은 어디까지나 실행 전의 예측이었다. 실제 성공 여부는 focus()를 실행한 뒤 activeElement를 확인해야 했다.
const target = this.focusTarget();
if (!target) {
return false;
}
target.focus();
return document.activeElement === target;이 구현에서는 버튼이 light DOM에 있으므로 document.activeElement로 확인할 수 있다. 버튼을 Shadow DOM 내부로 옮긴다면 document.activeElement에는 내부 버튼이 아니라 shadow host가 나타날 수 있으므로, 해당 ShadowRoot.activeElement까지 확인해야 한다.
자격 판정과 실행을 완전히 분리할 수 없었던 이유는 둘 사이에 비동기 작업이 끼었기 때문이 아니다. CSS까지 반영된 실제 결과를 확인하려면 내부 포커스 대상을 알고 있는 컴포넌트가 직접 실행해 봐야 했기 때문이다.
처음과 비슷한 코드로 돌아왔지만 근거는 달라졌다.
그런데 복구 정책보다 먼저 확인할 것이 있었다
여기까지 구현한 뒤 다른 페이지네이션 라이브러리의 경계 처리를 조사했다.
구현 방식은 달랐다.
- 경계 컨트롤을 숨긴다.
- HTML
disabled를 붙인다. tabindex를 변경하거나 제거한다.- 링크의
href를 제거한다.
조사한 범위에서는 경계 상태로 전환하면서 포커스를 잃었을 때, 다른 대상으로 옮기는 명시적인 복구 정책을 확인하지 못했다.
처음에는 “다른 구현들도 이 문제를 놓쳤다”라고 생각했다. 하지만 비교하다 보니 내 설계에도 검토하지 않은 전제가 있다는 사실이 보였다.
나는 경계 버튼을 숨긴 뒤 포커스를 복구하고 있었다. 다른 구현들은 경계 컨트롤을 비활성화하거나 순차 포커스 조건을 변경한 뒤 별도로 복구하지 않았다.
다만 tabindex를 제거했다는 사실만으로 포커스 가능성이 사라진다고 단정할 수는 없다. 네이티브 <button>이나 href가 있는 <a>는 tabindex가 없어도 기본 탭 순서에 들어간다. 실제 결과를 알려면 어떤 HTML 요소에 어떤 속성 조합을 사용했는지까지 봐야 한다.
그럼에도 비교를 통해 질문은 바뀌었다.
경계 컨트롤을 없앨 것인가가 아니라, 경계에 도달했을 때 포커스 가능성을 없앨 것인가?
2편에서 hidden을 선택할 때 이 질문을 검토하지 않았다. 기존 정책으로부터 그대로 이어받았을 뿐이었다. 원래 설계 메모에도 이 결정은 “미검토”라고 남아 있었다.
포커스 조정의 소유권 문제는 컴포넌트를 나눈 사실 자체보다, 활성 컨트롤을 제거하는 상태 전이에서 시작된 것일 수 있었다.
경계 컨트롤을 남기기로 했다
최종적으로 경계 컨트롤을 숨기지 않기로 했다.
<button
type="button"
aria-disabled="true"
>
이전
</button>HTML disabled도 사용하지 않는다. 기본적으로 포커스 가능한 <button>을 탭 순서에 남기기 위해서다.
aria-disabled는 컨트롤이 인지 가능하지만 현재 조작할 수 없는 상태임을 접근성 API에 전달한다. 그러나 HTML disabled처럼 클릭과 포커스를 자동으로 차단하지는 않는다. 비활성 상태의 시각적 표현과 실제 동작 차단도 구현자가 별도로 처리해야 한다.
function handleActivate(event) {
const control = event.currentTarget;
if (control.getAttribute("aria-disabled") === "true") {
// 링크 등에 브라우저 기본 동작이 있다면 취소한다.
event.preventDefault();
// 비활성 상태에서는 page-navigate를 발생시키지 않는다.
return;
}
// 이 지점에서만 page-navigate를 발생시킨다.
}여기서 버튼을 비활성화하는 것은 preventDefault() 자체가 아니다. type="button"에는 취소할 기본 페이지 이동이 없기 때문이다. 비활성 상태에서 조기 반환해 컴포넌트의 페이지 이동 로직을 실행하지 않는 것이 실제 동작 차단이다. preventDefault()는 href가 있는 링크처럼 브라우저 기본 동작이 존재할 가능성까지 방어한다.
링크 모드에서는 문제가 하나 더 있었다. 경계 링크에 존재하지 않는 페이지의 href를 남기면 사용자가 새 탭이나 새 창으로 그 주소를 열 수 있다. 그렇다고 href만 제거하면 <a>는 링크 의미를 잃고 기본 탭 순서에서도 빠진다.
그래서 경계 링크는 다음과 같이 처리했다.
<a
role="link"
tabindex="0"
aria-disabled="true"
>
이전
</a>tabindex="0"은 순차 포커스 가능성을 유지하고, role="link"는 접근성 API에 링크라는 의미를 전달한다. role은 링크의 실제 탐색 동작이나 키보드 동작을 만들어주지 않는다. 이 상태는 비활성 링크이므로 탐색하지 않는 것이 맞다.
중요한 것은 전환 전후에 같은 <a> 노드를 유지하는 것이다. href를 제거하기 전에 tabindex="0"을 적용하거나, 활성 상태에서도 tabindex="0"을 유지해야 포커스 가능성이 중간에 끊기지 않는다. 다시 활성화할 때는 같은 노드에 유효한 href를 복원한다.
href를 제거한 <a>에 role="link"와 aria-disabled="true"를 적용하는 방식은 ARIA in HTML에도 비활성 링크를 표현하는 예시로 제시되어 있다.
이 방법만이 가능한 해답은 아니다. 버튼을 숨기기 전에 다른 대상으로 포커스를 옮기는 기존 방식도 동작한다. HTML disabled로 화면에는 남기되 탭 순서에서 제거할 수도 있다.
다만 비활성 상태를 전달하면서 동일한 경계 컨트롤을 탭 순서에 남기려면 aria-disabled와 수동 동작 차단의 조합이 가장 직접적이었다.
남겨야 했던 것은 모양이 아니라 DOM 노드였다
경계 컨트롤을 화면에 계속 그리는 것만으로는 충분하지 않다. 기존 버튼을 제거한 뒤 같은 모양의 버튼을 다시 만들면, 포커스를 가지고 있던 노드는 여전히 사라진다.
const focusedBeforeRender = document.activeElement;
render();
console.log(focusedBeforeRender === document.activeElement);따라서 이 설계가 기대는 조건은 다음과 같다.
포커스를 가진 DOM 노드를 유지한 채
aria-disabled,href같은 상태만 변경한다.
innerHTML 등으로 내부 마크업 전체를 교체한다면, 화면에 똑같은 버튼이 다시 나타나더라도 기존 포커스는 이미 유실된다.
또한 이 결론은 같은 문서 안에서 페이지 상태와 콘텐츠를 갱신하는 경우를 전제로 한다. 링크를 따라 새 문서를 로드한다면 기존 DOM 노드 자체가 사라지므로 포커스 유지 문제도 다른 방식으로 다뤄야 한다.
두 설계의 차이를 그려보면 다음과 같다.
flowchart LR
subgraph before["경계 컨트롤 제거"]
A["nextButton<br/>focused"] -->|"hidden 또는 노드 제거"| B["포커스 목적지 없음"]
B --> C["activeElement → body<br/>(재현 환경)"]
C --> D["host가 복구 대상 선택"]
D --> E["pages가 focus() 실행"]
end
subgraph after["동일 노드의 상태 변경"]
F["nextButton<br/>focused<br/>aria-disabled=false"]
F -->|"속성만 변경"| G["같은 nextButton<br/>focused<br/>aria-disabled=true"]
end
차이는 버튼이 화면에 보이는지가 아니라, 포커스를 가진 DOM 노드의 정체성이 상태 전이 전후에 유지되는지에 있다.
어렵게 만든 조정 계층이 사라졌다
동일한 경계 컨트롤이 포커스 가능한 상태로 남으니, 하위 컴포넌트의 경계를 넘는 포커스 유실도 발생하지 않았다.
그 결과 앞에서 만들었던 다음 구조를 제거할 수 있었다.
- 하위 컴포넌트가 host에 포커스 유실을 신고하는 프로토콜
- host가 문서 순서에 따라 후보를 고르는 로직
- 후보 하위 컴포넌트가 포커스 성공 여부를 반환하는 프로토콜
모든 포커스 코드가 사라진 것은 아니다. <my-pages> 내부에서 버튼 타입을 바꾸거나 페이지 버튼 목록을 교체할 때 생기는 유실은 해당 하위 컴포넌트가 직접 처리한다.
차이는 문제의 경계다.
자기 내부에서 발생하고 자기 내부에서 해결할 수 있는 유실은 하위 컴포넌트가 처리한다. 다른 하위 컴포넌트로 포커스를 넘겨야 할 때만 host의 조정이 필요하다. 동일한 경계 컨트롤을 유지한 뒤에는 후자의 상황이 없어졌다.
대신 Tab 정지점이 늘었다
이 선택에도 비용은 있다.
첫 페이지에서 이전 버튼은 아무 동작도 하지 않지만 여전히 Tab으로 도달할 수 있다. 마지막 페이지의 다음 버튼도 마찬가지다. hidden이나 HTML disabled를 사용했을 때보다 정지점이 하나씩 늘어난다.
비활성 컨트롤을 탭 순서에 남겨야 한다는 보편적인 규칙은 없다. ARIA Authoring Practices Guide의 키보드 지침도 발견 가능성이 중요한 비활성 컨트롤은 aria-disabled로 포커스 가능하게 유지할 수 있다고 설명하는 한편, 주변 컨트롤로 존재를 충분히 추론할 수 있다면 HTML disabled로 탭 순서에서 제거하는 관례도 제시한다.
페이지네이션의 이전·다음 버튼은 후자에 해당한다고 판단할 수도 있다. 첫 페이지에 다음 버튼이 있다면 이전 버튼이 비활성이라는 사실을 비교적 쉽게 추론할 수 있기 때문이다.
그럼에도 이 컴포넌트에서는 탭 정지점 최소화보다 포커스의 연속성을 우선하기로 했다.
불필요한 정지점과 포커스 유실은 서로 다른 사용자 집단의 문제가 아니다. 키보드 사용자는 비활성 정지점도 지나고, 포커스가 유실되면 현재 위치도 다시 찾아야 한다.
내가 선택한 기준은 빈도보다 한 번 발생했을 때의 비용이었다.
비활성 정지점은 항상 같은 위치에 있고 Tab 한 번으로 지나갈 수 있다. 반면 포커스 유실은 발생 시점을 예측하기 어렵고, 사용자가 현재 위치를 다시 파악해야 한다.
다만 이것은 접근성 표준이 정해준 답이 아니다. 이 컴포넌트가 선택한 UX 정책이다. 실제 사용성까지 확정하려면 키보드 사용자와 보조 기술을 이용한 검증이 더 필요하다.
포커스를 유지하는 것만으로 페이지 변경 전체가 전달되는 것도 아니다. 현재 페이지에는 aria-current="page"를 적용해야 하고, 같은 문서 안에서 결과 목록이 갱신된다면 사용자가 그 변화를 어떻게 인지할지도 별도로 검토해야 한다. 이 글에서 선택한 정책이 해결하는 범위는 경계 컨트롤을 조작한 직후 포커스가 유실되는 문제까지다.
문제를 푸는 것보다 문제를 만들지 않는 것
처음에는 컴포넌트를 나누면서 포커스가 소유자를 잃었다고 생각했다. 그래서 감지, 자격 판정, 후보 순위를 나누고 host에 조정 책임을 만들었다.
그 설계는 문제를 해결했다. 하지만 더 앞의 결정을 다시 보자 조정 계층 자체가 필요 없어졌다.
활성 컨트롤을 제거하지 않고 같은 DOM 노드의 상태만 바꾸니 포커스를 되찾아올 이유도 사라졌다.
설계 기록에서는 복잡한 문제를 해결한 코드가 눈에 잘 띈다. 반면 문제를 만들지 않기로 해서 삭제된 코드는 흔적이 적다. 이번에는 그 삭제가 최종 결과였다.
다음 편에서는 반대의 문제를 다룬다.
포커스된 요소가 사라지지 않고 DOM 노드도 그대로인데, 그 노드가 가리키는 페이지가 바뀌는 경우다. 포커스는 남아 있지만 사용자가 알고 있던 대상은 더 이상 그곳에 없다.