← 글 목록

notes

event bubble(Js x 없이)

JavaScript 이벤트 버블링을 라이브러리 없이 구현하는 흐름·주의점 메모.

  • 버블을 중간에서 끊는 건 JS의 stopPropagation()뿐이고, HTML·CSS로는 이벤트 대상을 바꾸거나 없애는 것만 된다.
  • pointer-events: none은 클릭을 아래 요소로 통과시킬 뿐이라 부모로의 버블은 그대로 일어난다.
  • 탭 순서는 tabindex, 클릭과 포커스를 통째로 막을 땐 inert, Enter 제출은 form 기본 동작을 쓴다.

제목은 이벤트 버블인데, 정확히는 stopPropagation 같은 JS 없이 HTML과 CSS만으로 이벤트를 어디까지 다룰 수 있나 찾아본 메모다. 찾아보니 버블 자체를 끊는 속성은 없었다. 대신 이벤트가 애초에 안 생기게 하거나, 이벤트를 받는 대상을 바꾸는 방법은 꽤 있다.

이벤트가 지나가는 길

클릭하면 이벤트는 document에서 내려와서(캡처) 실제로 클릭된 요소에 닿고(타깃) 다시 위로 올라간다(버블). 부모에 onclick이 걸려 있으면 자식만 눌러도 부모 핸들러가 도는 게 이 버블 때문이다.

이걸 중간에서 끊는 건 JS의 e.stopPropagation()뿐이다. 그래서 아래 방법들은 전부 "끊는" 게 아니라 "애초에 안 가게" 하는 쪽이다.

포커스 순서: tabindex

tabindex="-1"을 주면 Tab 키로는 그 요소에 안 간다. 그런데 JS에서 el.focus()로는 포커스를 줄 수 있다. 모달을 열 때 제목에 포커스를 옮기거나, 화면에만 있는 장식용 버튼을 탭 순서에서 뺄 때 썼다.


<button>Button 1</button>
<button tabindex="-1">Button 2 (Not focusable with Tab)</button>
<button>Button 3</button>

pointer-events: none 은 버블을 막지 않는다

처음엔 자식에 pointer-events: none을 주면 부모까지 안 올라갈 줄 알았다. 테스트해 보니 부모 onclick은 그대로 실행됐다.

#child {
    pointer-events: none; /* Child element will not respond to mouse events */
}

none을 주면 그 요소는 마우스 히트 테스트에서 빠진다. 클릭이 그 자리에 있는 다음 요소(보통 부모)로 통과하고, 그 부모가 타깃이 된다. 거기서부터 버블은 평소처럼 올라간다. 즉 막은 게 아니라 타깃을 바꾼 것이다.

또 하나, 마우스만 막힌다. 버튼이면 Tab으로 포커스해서 Enter를 누르면 click이 그대로 발생한다. 그래서 버튼을 막을 때는 pointer-events 대신 disabled를 쓴다.

직접 확인해 본 코드
<div id="parent" onclick="console.log('parent')" style="padding:20px;background:#eee">
  <button id="child" style="pointer-events:none">눌러보기</button>
</div>
<!-- 버튼을 클릭하면 child는 반응 없고 console에 parent 가 찍힌다 -->


키보드로 동작시키기: accesskey, form

accesskey는 요소에 단축키를 단다. keydown 이벤트를 직접 받는 건 아니고, 브라우저가 단축키를 받으면 그 요소를 누른 것처럼 처리해 준다.

<button type="submit" accesskey="s">Submit</button>

브라우저 자체 단축키랑 겹치기 쉽고 사용자가 알 방법도 없어서, 사내 관리 화면처럼 쓰는 사람이 정해져 있을 때만 썼다.

Enter 키는 form이 알아서 처리한다. 입력칸에 포커스가 있을 때 Enter를 누르면 form 안의 첫 번째 submit 버튼을 누른 것처럼 제출된다. keydown에서 e.key === 'Enter'를 잡는 코드를 짤 필요가 없다. 검색창 같은 걸 div로 만들면 이걸 다 직접 구현해야 해서, 난 입력이 들어가면 일단 form으로 감싼다.

<form action="/submit" method="post">
  <input type="text" name="first_name" placeholder="First Name">
  <input type="text" name="last_name" placeholder="Last Name">
  <button type="submit">Submit</button>
</form>

submit 버튼이 없어도 텍스트 입력이 딱 하나뿐인 form은 Enter로 제출된다. 입력이 두 개 이상인데 버튼이 없으면 안 되니, 버튼을 숨기고 싶어도 지우지는 않는다.

양수 tabindex를 안 쓰는 이유
<input type="text" name="username" tabindex="1">
<input type="password" name="password" tabindex="2">
<button type="submit" tabindex="3">Submit</button>

이러면 이 세 개가 페이지의 다른 모든 요소보다 먼저 포커스를 가져간다. 헤더 메뉴를 건너뛰고 갑자기 폼으로 점프하는 식이라 키보드 사용자 입장에서는 헷갈린다. 순서는 HTML 마크업 순서로 맞추는 게 맞다.

클릭과 포커스를 전부 막을 때: inert

모달이 떠 있을 때 뒷 배경을 통째로 죽이고 싶다면 inert가 제일 깔끔하다. 클릭, Tab 포커스, 텍스트 선택이 전부 막히고 스크린 리더에서도 안 보인다. 요즘 브라우저는 다 지원한다.

<main inert>
  <!-- 모달이 열려 있는 동안 여기 있는 건 아무것도 못 누른다 -->
</main>
<dialog open>...</dialog>

<dialog>를 showModal()로 열면 브라우저가 바깥을 알아서 inert 처럼 만들어 주긴 하는데, 이건 JS 한 줄이 필요하다. 정리하면 버블을 중간에서 끊는 건 JS로만 되고, HTML·CSS는 이벤트가 생기는 대상을 고르거나 아예 없애는 쪽으로 쓴다.