본문 바로가기
빌드노트 검색

빌드노트

버튼 안 아이콘만 반응이 없으면 event.target에서 버튼을 다시 찾는다

목록에 위임한 클릭이 버튼 안 span·SVG에서 빠지면 target과 currentTarget을 구분한다. 실제 클릭으로 matches와 closest의 차이를 비교하고, 컨테이너 포함 관계와 중첩 영역의 소유권을 확인하는 코드를 제시한다.

목록에 클릭 리스너를 하나 붙였는데 버튼의 여백은 눌리고 안쪽 글자나 아이콘은 반응하지 않는다면, event.target.matches('button') 조건부터 확인한다. target은 누른 위치에 따라 버튼 안의 span, svg, path가 될 수 있다. 이 요소들은 버튼 안에 있어도 button 선택자와 일치하지 않는다. target.closest('button[data-action="save"]')로 동작 버튼을 찾고, 그 버튼이 현재 목록의 처리 대상인지 확인한다.

event.currentTarget으로 바꾸기만 해서는 해결되지 않는다. 목록에 리스너를 붙였다면 currentTarget은 목록이고, target은 그 리스너에 전달된 이벤트의 대상이다. 버튼을 찾을 출발점과 리스너가 설치된 곳을 구분해야 한다. DOM 표준의 target·currentTarget 정의 (새 탭에서 열림)에서 두 값의 역할을 확인할 수 있다.

같은 버튼을 네 군데 눌렀다

실제 서비스의 장애 기록이 아니라, 이 글을 위해 만든 정적 페이지의 관찰이다. Windows의 headless Chromium에서 목록에 저장 버튼 하나를 만들고 버튼 여백, 안쪽 글자 span, SVG의 빈 부분, SVG 안의 path를 브라우저 마우스 입력으로 각각 눌렀다. 저장 요청 대신 카운터만 증가시켰다. 다음은 리스너 안에서 즉시 기록한 값 중 핵심 열을 추린 결과다.

누른 곳       target             currentTarget   matches('button')  closest로 찾은 ID
버튼 여백     button#save        section#items   true               save
글자          span#save-label    section#items   false              save
SVG 빈 부분   svg#save-svg       section#items   false              save
SVG 도형      path#save-path     section#items   false              save

네 번 모두 목록의 리스너에 도달했지만, matches('button')을 통과한 것은 한 번이었다. 버튼을 찾아 범위까지 검사한 처리는 네 번 모두 같은 save 버튼을 골랐다. 아이콘이 클릭을 전달하지 않은 것이 아니라, 이 조건이 자손 요소에서 시작한 클릭을 버린 것이다.

matches의 정의 (새 탭에서 열림)는 호출한 요소 자체를 검사한다. closest의 정의 (새 탭에서 열림)는 자신부터 조상 방향으로 검사해 처음 일치하는 요소를 반환하고, 없으면 null을 반환한다. 그래서 버튼 여백을 눌렀을 때는 버튼 자신, 아이콘을 눌렀을 때는 그 아이콘을 감싼 버튼을 찾을 수 있다. SVG도 처리하려면 아래 코드처럼 Element를 검사한다. HTML 요소만 받는 검사로 좁히면 SVG 클릭을 먼저 버릴 수 있다.

closest는 목록 경계에서 멈추지 않는다

closest()에는 “이 목록 안에서만 찾기”라는 인자가 없다. 찾은 버튼에 container.contains(button)을 검사해 현재 컨테이너 안에 있는지 확인한다. contains의 정의 (새 탭에서 열림)는 자신 또는 자손인지 검사하는 것이다.

이를 구별하려고 버튼 안의 작은 span에 별도 리스너를 붙여 봤다. 그 영역의 자손을 누르자 closest()는 영역 바깥에 있는 조상 버튼 wrapper를 찾았다. 그러나 작은 영역에서 contains(wrapper)는 false였고, 범위를 검사한 처리는 동작하지 않았다. 일반 목록에서는 드문 모양이지만, closest()가 리스너의 설치 위치를 기준으로 탐색을 끝내지 않는다는 차이를 보여 준다.

목록 밖에 형제 버튼을 둔 조건도 따로 눌렀다. 이 클릭은 문서 리스너에는 기록됐지만 목록 리스너에는 도달하지 않았다. 이 결과를 contains()가 바깥 클릭을 막았다고 설명하면 안 된다. 그 클릭에는 목록의 검사 코드 자체가 실행되지 않았다.

중첩 목록은 다른 조건이다. 안쪽 목록의 버튼은 바깥 목록의 자손이기도 하므로 contains()만으로는 제외되지 않는다. 안쪽 저장 버튼을 한 번 눌렀을 때 두 목록의 리스너가 모두 실행됐고, 바깥쪽에서도 contains()는 true였다. 이 예제에서는 각 목록에 data-action-scope를 표시하고, 버튼에서 가장 가까운 처리 영역이 현재 컨테이너와 같을 때만 카운터를 올렸다. 안쪽 목록은 한 번 처리했고 바깥 목록은 건너뛰었다. data-action-scope는 이 예제에서 정한 표시이며 브라우저가 자동으로 이벤트를 차단하는 속성이 아니다.

내 목록에 옮길 때 확인할 코드

다음 전체 내용을 UTF-8의 click.html로 저장해 브라우저에서 열고 개발자 도구의 Console을 본다. 외부 라이브러리나 서버는 필요 없다. ‘저장’ 글자, ‘글자’ 부분, 회색 SVG 영역을 차례로 누른다. 기존 화면에 적용할 때는 ID와 동작 선택자를 맞추고 기존의 target.matches('button') 리스너를 교체한다.

<!doctype html>
<meta charset="utf-8">
<section id="items" data-action-scope>
  <button id="save" type="button" data-action="save">
    저장 <span id="label">글자</span>
    <svg id="icon" width="32" height="24" style="background:#ddd" aria-hidden="true"></svg>
  </button>
</section>
<script>
const items = document.querySelector('#items');
let handled = 0;
items.addEventListener('click', event => {
  const container = event.currentTarget;
  const target = event.target;
  if (!(target instanceof Element)) return;
  const button = target.closest('button[data-action="save"]');
  if (!button || !container.contains(button)) return;
  if (button.closest('[data-action-scope]') !== container) return;
  handled += 1;
  console.log(target.localName, container.id, button.id, handled);
});
</script>
button items save 1
span items save 2
svg items save 3

이 작은 예제에서는 처리 횟수가 세 번까지 올라간다. 실제 동작을 넣을 위치는 두 범위 검사 다음이다. 중첩 컴포넌트의 버튼을 각각 독립적으로 처리하려면 그 컴포넌트에도 같은 영역 표시와 자기 리스너를 둔다. 안쪽 버튼까지 바깥 목록이 처리하는 설계라면 소유권 검사 조건을 그 의도에 맞게 정한다.

자신의 화면에서도 아이콘을 누른 순간 목록 리스너 안에서 target.localName, currentTarget.id, 찾은 버튼 ID를 확인한다. closest() 결과가 null이면 실제 DOM의 버튼 조상과 선택자를 확인한다. 버튼은 찾았는데 처리를 건너뛰면 컨테이너 포함 관계와 가장 가까운 영역 표시를 확인한다. 리스너 로그부터 없다면 이 글의 선택 조건에 도달하지 않은 것이므로 클릭을 받는 요소와 이벤트 전달 경로를 먼저 조사한다.

직접 확인한 범위는 일반 DOM의 네이티브 클릭과 무해한 카운터다. Shadow DOM 경계, 프레임워크별 이벤트, 다른 브라우저 엔진, 모바일 터치는 실행하지 않았다. 폼 제출 동작도 이번 비교에 포함하지 않았다.

← 전체 글 보기