HTML dialog와 popover, 확인창과 도움말에 맞는 선택

도움말을 열었더니 입력란까지 잠겨서 설명을 보며 이름을 쓸 수 없다. 저장 확인창과 도움말 말풍선은 HTML dialog와 popover 중 각각 무엇으로 열어야 할까?

HTML dialog와 popover, 확인창과 도움말에 맞는 선택
세 줄 요약
  • 확인을 마칠 때까지 배경 조작을 멈추려면 showModal()로 dialog를 연다.
  • auto popover는 바깥 클릭과 Esc로 닫히며, manual은 연결한 버튼이나 스크립트로 닫는다.
  • dialog의 선택값과 실제 저장 동작은 앱에서 연결하며, Esc를 구별하려면 열 때 returnValue를 초기화한다.

확인을 끝내야 하는 창과 입력을 도와주는 창

문서 편집 화면에서 ‘변경 사항을 저장할까요?’라는 창이 열렸다면, 사용자는 그때의 수정 내용을 대상으로 저장 여부를 정한다. 확인 중에도 배경 문서를 편집할 수 있으면 처음 확인한 내용과 실제 저장할 내용이 달라질 수 있다. 반대로 이름 입력란 옆의 도움말은 설명을 읽으면서 입력란을 계속 쓸 수 있어야 한다. 두 UI는 화면 위에 뜬다는 모습이 같아도, 열려 있는 동안 허용해야 할 작업이 다르다.

dialog.showModal()은 첫 번째 상황을 위한 동작이다. 브라우저는 창 바깥 페이지를 비활성화하고 포커스를 창 안으로 옮긴다. popover는 두 번째처럼 주변 화면을 계속 쓰는 내용에 맞는다. 창의 크기나 가운데 배치 여부보다, 사용자가 지금 하던 작업을 잠시 멈춰야 하는지가 먼저다.

실제 web.dev의 갤러리 예제는 사진을 즐겨찾기에 넣으려는 사용자에게 로그인 모달을 열고, 그 안의 이메일 설명은 popover로 제공한다. 로그인은 별도로 마칠 작업이고 이메일 설명은 입력을 돕는 내용이다. 한 화면에서도 이 두 역할이 함께 필요할 수 있다.

개발자가 헷갈리는 지점은 비모달 dialog도 있다는 사실이다. Reddit의 한 질문도 dialog.show()와 popover="manual"이 모두 배경을 막지 않는데 왜 따로 존재하는지 묻는다. show로 연 dialog는 일반 페이지 계층에 대화상자로 표시되고, manual popover는 내용의 역할을 바꾸지 않은 채 top layer에 올라간다.

네 가지 여는 방식의 차이

구현 방식주변 페이지 조작표시 위치와 기본 닫기
dialog.showModal()막힌다.top layer에 표시된다. Esc 또는 연결한 닫기 버튼으로 닫는다.
dialog.show()가능하다.일반 페이지 계층에 표시된다. 폼이나 스크립트로 닫기를 연결한다.
popover="auto"가능하다.top layer에 표시된다. 바깥 클릭·Esc·호출 버튼으로 닫을 수 있다.
popover="manual"가능하다.top layer에 표시된다. 연결한 버튼이나 스크립트로 닫는다.

표는 별도의 닫기 정책을 추가하지 않은 경우다. 기본 모달 dialog는 Esc로 닫히지만 바깥 클릭만으로는 닫히지 않는다. closedby를 지원하는 브라우저에서 closedby="any"를 지정하면 바깥 클릭도 닫기 동작이 된다. 배경의 색을 바꾸는 스타일과 창을 닫는 입력 정책은 각각 설정한다.

top layer는 페이지 요소끼리 z-index로 순서를 정하는 계층보다 위에 있다. 부모 상자가 overflow: hidden인 경우에도 열린 popover가 그 상자에 잘리지 않게 표시할 수 있다. 하지만 높은 위치에 그려지는 기능과 배경 입력을 막는 기능은 별개다. manual popover를 화면 전체 크기로 꾸며도 모달의 포커스 동작이 자동으로 생기지는 않는다.

auto와 manual의 차이는 유지 시간에도 영향을 준다. auto는 다른 독립적인 auto popover가 열리면 기존 것을 닫는다. 중첩된 popover에는 예외가 있다. manual은 다른 popover를 열어도 자동으로 닫히지 않으므로 여러 보조 창을 함께 유지할 수 있다.

저장 선택을 받아 화면에 결과 표시하기

다음 예제는 저장과 취소를 구분해 선택 결과를 화면에 표시한다. HTML 다음에 JavaScript를 실행한다. 실제 서버 저장은 넣지 않았으며, 결과 문구도 파일이 저장됐다고 알리지 않는다. 앱에 붙일 때는 save 분기에서 저장 요청을 하고 그 요청의 성공 여부를 따로 처리한다.

<button id="open-save" type="button">저장 확인</button>
<p id="save-result" role="status"></p>
<dialog id="save-dialog" aria-labelledby="save-title">
  <h2 id="save-title">변경 사항을 저장할까요?</h2>
  <form method="dialog">
    <button type="submit" value="cancel" autofocus>취소</button>
    <button type="submit" value="save">저장 선택</button>
  </form>
</dialog>
const dialog = document.querySelector('#save-dialog');
const result = document.querySelector('#save-result');

document.querySelector('#open-save').addEventListener('click', () => {
  dialog.returnValue = '';
  dialog.showModal();
});

dialog.addEventListener('close', () => {
  result.textContent = dialog.returnValue === 'save'
    ? '저장을 선택했다.'
    : '저장을 선택하지 않고 닫았다.';
});

method="dialog"인 폼을 제출하면 브라우저는 창을 닫고 누른 제출 버튼의 value를 returnValue에 넣는다. 일반적인 서버 전송용 폼처럼 주소로 데이터를 보내는 동작은 하지 않는다. 따라서 ‘저장 선택’ 버튼이 하는 일은 선택값을 전달하는 것까지다.

창이 닫혔다는 사실은 close 이벤트로 받는다. 이 이벤트는 부모로 버블링하지 않으므로 예제처럼 dialog 자체에 리스너를 단다. 닫기는 취소 버튼, 저장 선택 버튼, Esc에서 모두 일어날 수 있고, 실제로 어떤 일을 할지는 선택값으로 나눈다.

열기 함수의 초기화도 이 구분을 위한 코드다. Esc는 새 returnValue를 설정하지 않는다. 앞서 저장을 선택한 창을 다시 열었다가 Esc로 닫으면 이전 값이 남을 수 있다. 매번 빈 문자열로 시작하면 그 종료를 새 저장 선택과 혼동하지 않는다.

이 차이는 삭제 확인처럼 결과가 큰 작업에도 적용된다. 닫기 이벤트가 발생했다는 이유만으로 삭제 요청을 보내면 취소도 실행으로 처리하게 된다. 앱에서는 확인을 뜻하는 값 하나만 실행 분기에 연결하고, 나머지 종료는 현재 문서나 항목을 그대로 두도록 작성한다.

창의 이름과 처음·마지막 포커스

예제의 aria-labelledby는 대화상자 이름을 화면의 제목과 연결한다. 제목을 바꾸면 보조 기술이 읽는 이름도 그 제목을 따른다. autofocus는 첫 조작 위치를 취소 버튼으로 정한다. 저장처럼 단순한 선택을 보여주는 예제지만, 돌이키기 어려운 삭제라면 취소부터 선택하게 하는 이유가 더 분명해진다.

WAI-ARIA의 모달 패턴은 처음 포커스를 무조건 첫 입력란에 두라고 하지 않는다. 긴 설명과 표를 먼저 읽어야 하는 창이라면 내부 제목 같은 정적 요소에 tabindex="-1"를 두고 그곳으로 포커스를 옮기는 방식을 제시한다. dialog 요소 자체에 tabindex를 붙이는 것과는 다르다. 짧은 확인창과 긴 동의서에 같은 초점 위치를 기계적으로 적용하지 않는 이유다.

내용 전체를 aria-describedby로 연결하는 것도 항상 읽기 좋은 결과를 만들지는 않는다. 여러 문단과 목록의 구조를 살려 탐색해야 한다면 긴 내용을 한 번에 읽게 연결하기보다 제목으로 창을 식별하고 내부를 차례로 읽게 하는 편이 적합하다. 이름은 어떤 창인지 알리고, 본문 구조는 그 안에서 무엇을 읽고 조작할지 안내한다.

네이티브 dialog의 포커스 처리 덕분에 모달이 열린 동안 Tab은 배경 페이지의 입력란으로 이동하지 않는다. 브라우저 자체 UI로 이동하는 것까지 막는다는 뜻은 아니다. 닫은 뒤에는 호출 버튼이 남아 있으면 그 버튼으로 돌아간다. 삭제로 호출 버튼 자체가 사라지는 앱이라면 다음 항목처럼 작업을 이어갈 위치를 앱이 정해야 한다.

입력하면서 읽는 도움말 만들기

이름 입력란에는 다음처럼 도움말을 연결할 수 있다. 호출 버튼의 popovertarget 값과 도움말의 id를 맞추면 버튼이 열기와 닫기를 토글한다. 도움말 안의 버튼은 popovertargetaction="hide"로 닫기만 수행한다. 두 버튼 모두 type을 button으로 지정했으므로 큰 입력 폼 안에 넣어도 제출 버튼으로 작동하지 않는다.

<label for="display-name">표시 이름</label>
<input id="display-name" name="displayName" type="text">
<button type="button" popovertarget="name-help">이름 입력 도움말</button>
<div id="name-help" popover="auto">
  <p>다른 참여자에게 표시할 이름을 입력한다.</p>
  <button type="button" popovertarget="name-help"
          popovertargetaction="hide">닫기</button>
</div>

auto에서는 바깥 입력란을 클릭하면 도움말이 닫히면서 입력을 계속할 수 있다. 설명이 짧고 한 번 확인하면 충분한 경우에 어울린다. 여러 입력 항목과 대조할 긴 설명이라면 같은 예제의 속성값을 popover="manual"로 바꾸면 된다. 이 경우 바깥을 클릭해도 설명이 남고, 사용자가 호출 버튼이나 닫기 버튼을 눌러 종료한다. manual에서는 Esc도 창을 닫지 않으므로, 사용자는 화면에 남은 닫기 버튼으로 종료할 수 있다.

popovertarget 연결은 키보드 탐색에도 쓰인다. 펼친 뒤 호출 버튼에서 Tab으로 이동하면 popover 안의 조작 요소를 순서대로 방문할 수 있고, auto popover를 Esc로 닫으면 호출 버튼으로 포커스가 돌아간다. 모달과 달리 주변 페이지까지 계속 이동할 수 있으므로 도움말을 보려고 입력 작업 전체를 중단할 필요가 없다.

배치와 크기는 이 동작 위에 별도로 정한다. 속성을 붙였다는 이유만으로 도움말이 입력란 바로 옆에 정렬되는 것은 아니다. 작은 화면에서 텍스트가 잘리는지, 설명이 입력하려는 칸을 가리는지는 실제 레이아웃에서 확인해야 한다. top layer는 부모 상자에 잘리는 문제를 덜어 주지만, 읽기 좋은 위치와 너비까지 선택해 주지는 않는다.

대화상자의 의미가 필요한 비모달 UI

dialog.show()는 배경을 계속 쓸 수 있는 대화상자가 필요할 때 선택할 수 있다. 예를 들어 편집 문서와 나란히 놓는 작은 찾기 창은 페이지 조작을 멈추지 않으면서 독립된 대화상자라는 이름과 역할이 필요할 수 있다. 다만 show로 연 dialog는 top layer에 올라가지 않으므로 다른 요소와 겹치는 순서는 일반 레이아웃에서 해결한다.

반대로 div에 popover를 붙였다고 그 내용이 자동으로 대화상자나 메뉴가 되지는 않는다. 설명 문단이면 문단으로, 링크 모음이면 링크로 읽힐 수 있게 실제 내용에 맞는 HTML을 작성한다. 시각적으로 메뉴처럼 꾸민 뒤 popover 속성 하나가 메뉴의 모든 키보드 동작을 제공한다고 기대하면 안 된다.

대화상자의 의미와 popover의 표시 동작이 함께 필요하면 <dialog popover>를 사용할 수 있다. 이를 popovertarget 버튼으로 열면 대화상자라는 의미를 유지하면서 비모달로 표시된다. popovertarget으로 열었을 때는 뒤쪽 입력란을 계속 쓸 수 있고, showModal로 열었을 때는 그 입력란을 조작할 수 없다.

위 예제를 붙인 뒤에는 저장 선택→다시 열기→Esc 순서로 동작시켜 결과 문구가 달라지는지 확인한다. 도움말은 열기→입력란 클릭 순서로 auto와 manual의 유지 여부를 비교한다. 이어 키보드만으로 열고 닫아 제목, 첫 포커스, 복귀 위치를 확인하면 창의 생김새만 봐서는 발견하기 어려운 구현 차이를 확인할 수 있다.