접근성CSS 및 레이아웃리액트

Popover API와 네이티브 <dialog>: 처음부터 직접 만드는 모달의 종말

Popover API와 네이티브 <dialog>: 처음부터 직접 만드는 모달의 종말

컴포넌트 접근성 글에서 저는 display: none으로 시각적으로는 사라졌지만, 누군가 잘못 숨겨서 접근성 트리에는 여전히 남아 있던 모달을 보여드렸습니다. 이는 <div>로 처음부터 만든 모달이 최소한 접근성이 있다고 인정받으려면 수동으로 해결해야 하는 다섯 가지 문제 중 하나에 불과했습니다. 포커스 트랩(Tab으로 사용자를 모달 밖으로 내보낼 수 없어야 함), Escape 키로 닫기, 콘텐츠 바깥 클릭으로 닫기, 모달을 연 요소로 포커스를 되돌려주기, 그리고 도중에 있는 컴포넌트가 overflow: hidden이나 자신만의 z-index를 얼마나 가지고 있든 상관없이 실제로 나머지 페이지 위에 렌더링되는 것까지입니다.

jsx

// 모달처럼 보이지만 위의 다섯 가지 문제 중 어느 것도 실제로 해결하지 못한다
const NaiveModal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal">{children}</div>
</div>
);
};

이 코드에는 포커스 트랩이 없습니다 – 모달이 시각적으로는 여전히 모든 것을 가리고 있어도, Tab은 키보드 사용자를 자유롭게 나머지 페이지로 되돌려 보냅니다. Escape로도 닫히지 않습니다. 닫힌 뒤 트리거로 포커스를 돌려주지도 않습니다. 그리고 DOM 트리의 어떤 부모도 overflow: hidden을 갖지 않고, 페이지의 다른 어떤 요소보다 낮은 z-index를 갖지 않는다는 가정에 의존합니다 – 큰 애플리케이션에서는 이 가정이 규칙적으로 거짓으로 드러납니다. Radix나 Headless UI 같은 라이브러리가 존재하는 이유는 바로 이것을 JavaScript에서 한 번에 제대로 해결하기 위해서입니다. 몇 년 전부터는 이 문제의 일부를 브라우저가 JS 코드 한 줄 없이 스스로 해결해 줍니다.

톱 레이어: z-index가 통하지 않는 레이어

<dialog>popover 속성이 모두 의존하는 핵심 메커니즘은 **톱 레이어(top layer)**입니다 – 문서의 나머지 부분 전체보다 물리적으로 위에 있으며, z-index, overflow, 스태킹 컨텍스트로부터 완전히 독립적인 브라우저 내부 렌더링 레이어입니다. 톱 레이어로 승격된 요소는 "아주 높은 z-index를 가진 것"이 아닙니다 – 그것은 더 이상 페이지의 일반적인 레이어 스택에 속하지 않으므로, overflow: hidden을 가진 어떤 부모도 그것을 잘라낼 수 없고, 터무니없는 z-index: 9999를 가진 어떤 인접 요소도 그것을 덮을 수 없습니다. 이는 브라우저가 전체 화면 모드의 <video> 같은 네이티브 요소를 위해 오래전부터 내부적으로 사용해 온 것과 같은 메커니즘입니다 – Popover API와 <dialog>는 그저 그것을 개발자에게 열어준 것뿐입니다.

jsx

const ConfirmDialog = ({ onConfirm }) => {
const dialogRef = useRef(null);
return (
<dialog ref={dialogRef} className="confirm-dialog">
<p>정말로 이 항목을 삭제하시겠습니까?</p>
<form method="dialog" className="confirm-dialog__actions">
<button type="submit" value="cancel">취소</button>
<button type="submit" value="confirm" onClick={onConfirm}>
삭제
</button>
</form>
</dialog>
);
};
// 컴포넌트 어딘가 다른 곳에서:
// dialogRef.current.showModal();

.showModal()을 호출하면(.show()가 아닙니다 – 흔한 실수인데, .show()는 배경도 포커스 트랩도 없는 비모달 다이얼로그를 엽니다) 네 가지가 동시에 공짜로 실행됩니다. 톱 레이어로의 승격, 나머지 페이지를 어둡게 하는 ::backdrop 의사 요소 렌더링, 포커스 트랩(Tab이 다이얼로그 내부에서만 순환합니다), 그리고 나머지 문서를 inert로 만드는 것입니다 – 다이얼로그 바깥의 요소들은 포커스를 받을 수 없게 되고, 어두워진 배경 아래에서 여전히 보이는데도 스크린 리더의 상호작용에서 사라집니다. Escape로 닫거나 <form method="dialog">로 닫으면(네이티브 메커니즘입니다 – 이런 폼 안의 submit 버튼을 클릭하면 preventDefault도 JS 핸들러도 없이 다이얼로그가 닫히고 dialog.returnValue가 클릭된 버튼의 value로 설정됩니다) 다이얼로그를 열었던 요소로 자동으로 포커스가 돌아갑니다. 이 네 가지 중 어느 것도 직접 작성할 필요가 없습니다.

Popover API: 모달성 없이 같은 것을

<dialog>는 나머지 페이지가 차단되어야 한다고 전제합니다. 이는 삭제 확인에는 맞지만, 드롭다운 메뉴나 툴팁, 토스트처럼 나머지 콘텐츠 위에 나타나야 하고 옆을 클릭하면 스스로 닫혀야 하지만 나머지 페이지와의 상호작용을 막아서는 안 되는 것들에는 너무 무겁습니다. 이를 위한 것이 popover 속성이며, JavaScript 코드 한 줄 없이 선언적으로 처리할 수 있습니다.

html

<button popovertarget="user-menu">계정</button>
<div id="user-menu" popover>
<a href="/profile">프로필</a>
<a href="/settings">설정</a>
<button popovertarget="user-menu" popovertargetaction="hide">로그아웃</button>
</div>

popovertarget/id 관계만으로도 브라우저가 나머지 모든 것을 처리하기에 충분합니다. 톱 레이어로의 승격, 라이트 디스미스(popover 바깥 어디를 클릭하거나 Escape를 누르면 documentclick을 수신하지 않아도 자동으로 닫힙니다), 그리고 트리거와 콘텐츠 사이의 올바른 ARIA 관계(브라우저가 이 속성 쌍을 근거로 aria-expandedaria-controls를 스스로 연결해 줍니다)까지 말이죠. 이는 예전에는 Floating UI 같은 라이브러리나, event.target.closest()로 추가 확인을 하며 요소 바깥 클릭을 수동으로 수신하는 코드가 필요했던 바로 그 일입니다.

모달 모드의 <dialog>와의 근본적인 차이는 이렇습니다. popover는 나머지 페이지를 inert로 만들지 않으며, 포커스 트랩도 없습니다. 이는 기능이 빠진 것이 아니라 명세의 의도적인 결정입니다 – 사용자 메뉴는 진짜 모달처럼 페이지의 다른 곳을 스크롤하거나 클릭할 가능성을 막아서는 안 됩니다. 이 두 도구를 잘못된 방향으로 헷갈리는 것 – 콘텐츠가 정말로 나머지 인터페이스를 막아야 하는 곳(예를 들어 되돌릴 수 없는 작업의 확인)에서 popover를 쓰는 것 – 은 키보드 사용자가 자신을 전혀 막지 않았던 "모달"에서 언제든 Tab으로 빠져나갈 수 있는 인터페이스를 만들어냅니다.

문서가 가끔 침묵하는 함정: <dialog>의 배경 클릭은 자동으로 닫히지 않는다

::backdrop가 공짜로 렌더링되긴 하지만, 그것을 클릭한다고 해서 추가 코드 없이 다이얼로그가 닫히지는 않습니다 – 이는 직접 작성해야 하는 몇 안 되는 것 중 하나입니다. 이를 가능하게 하는 메커니즘은 히트 테스팅에 관한 구체적인 사실에 기반합니다. 모달 모드의 <dialog> 요소 자체는 콘텐츠 박스가 시각적으로 아무리 작더라도 히트 테스팅에서 보이는 영역 전체를 채웁니다 – 그래서 어두워진 배경을 클릭해도 여전히 event.target === dialogElement가 되고, 내부 콘텐츠를 클릭하면 구체적인 자식 요소가 걸립니다.

jsx

const handleBackdropClick = (event) => {
// event.target이 <dialog> 자신인 것은 클릭이 배경에 맞았을 때뿐이며,
// 내부 콘텐츠의 어떤 요소에 맞았을 때가 아니다
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
// <dialog ref={dialogRef} onClick={handleBackdropClick}>

이 트릭은 따로 기억해 둘 가치가 있습니다. 직관적으로는 "배경이 스스로 렌더링되니 스스로 닫히기도 할 것 같다"고 생각하기 쉽지만 – 그렇지 않으며, 이것이 이 전체 세트 중 직접 작성한 JS 코드 한 줄이 필요한 유일한 요소입니다.

함정과 좋은 습관

  • .show().showModal()과 같지 않습니다. .show()는 비모달 다이얼로그를 엽니다 – ::backdrop도, 포커스 트랩도, 나머지 페이지의 inert도 없습니다. 진짜 모달을 위해서는 항상 .showModal()이 필요합니다.
  • <dialog>의 기본 스타일은 의도적으로 초기화해야 합니다. 접근성 글에서 다룬 다른 모든 네이티브 요소(<button>, <ul>)와 마찬가지입니다 – 브라우저는 기본 border, padding, 중앙 정렬을 부여하며, 실무에서는 거의 항상 자신만의 CSS로 이를 덮어쓰게 됩니다.
  • 모달성이 필요한 곳에서 popover는 <dialog>를 대체하지 않습니다. 포커스 트랩이 없고 inert가 없는 것은 우회해야 할 결함이 아니라 popover의 의도된 제약입니다 – 콘텐츠가 나머지 페이지를 반드시 막아야 한다면 항상 올바른 도구는 <dialog>.showModal()입니다.
  • 지원 현황: <dialog>는 오래전부터 안전하고, popover는 더 최근에 나왔습니다. <dialog>(showModal() 포함)는 2022년부터 탄탄하게 지원되어 왔습니다. popover 속성은 그보다 늦은 2024년에 베이스라인(Baseline, 널리 지원됨) 상태에 도달했습니다 – 구형 기기가 오래 남아 있는 프로젝트라면 JS 라이브러리를 폴백으로 완전히 포기하기 전에 지원해야 할 최소 Safari 버전을 확인해 볼 가치가 있습니다.

마무리

<div>로 직접 만든 모달이 나쁜 이유는 개발자가 노력을 덜 해서가 아니라, 이제 브라우저가 네이티브로 공짜로 제공하는 메커니즘 – z-index와 무관한 톱 레이어, 포커스 트랩, 나머지 페이지의 inert, ::backdrop, 트리거로의 포커스 반환 – 을 JavaScript로 재현하려 하기 때문입니다. <dialog>popover는 같은 것의 두 가지 변형이 아니라, 같은 경계의 반대편에 있는 두 도구입니다. 나머지 인터페이스를 의도적으로 막는 모달성과, 그것을 의도적으로 막지 않는 가볍고 일시적인 콘텐츠 말이죠. 이 둘 사이의 선택은 스타일의 문제가 아니라, 사용자가 그 순간 페이지의 다른 무언가를 할 수 있어야 하는가라는 질문에 대한 답입니다 – 그리고 이 질문은 메뉴가 스크롤을 막고 있다거나 계정 삭제 확인 모달이 전혀 Tab을 막지 않는다는 사실이 드러난 뒤가 아니라, 코드의 첫 줄을 쓰기 전에 스스로에게 던져볼 가치가 있습니다.