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

빌드노트

pointer-events: none 버튼이 키보드로는 눌리는 이유

pointer-events: none은 포인터의 대상 선택을 바꾸지만 버튼의 Tab 초점과 Enter 활성화는 막지 않는다. 같은 네이티브 버튼의 정상·CSS 적용·disabled 조건을 실제 좌표 클릭과 키 입력으로 비교하고, 사용 불가 상태를 구현할 때 바꿀 지점을 설명한다.

버튼에 pointer-events: none을 붙였더니 마우스로는 동작하지 않는데, Tab으로 이동한 뒤 Enter를 누르면 저장 코드가 실행될 수 있다. 이 CSS는 포인터 좌표에서 이벤트를 받을 대상을 고르는 히트 테스트에 관여한다. 버튼을 비활성 컨트롤로 바꾸지는 않는다. 네이티브 <button>을 사용 불가 상태로 만들려면 해당 CSS 선언을 제거하고 disabled를 적용한다.

여기서 click이라는 이벤트 이름도 오해하기 쉽다. 버튼의 click 리스너는 마우스 전용 리스너가 아니다. HTML 표준의 활성화 동작 (새 탭에서 열림)은 키보드처럼 클릭 이외의 방식으로 요소를 활성화할 때도 click 이벤트를 발생시키도록 정한다. 따라서 포인터 입력의 대상을 제외했다고 이 리스너의 모든 실행 경로가 막히지는 않는다.

같은 저장 버튼에 세 조건을 적용했다

이 글을 위해 만든 페이지에서 save라는 버튼 하나에 정상, pointer-events: none, disabled 조건을 각각 적용했다. 버튼 앞뒤에는 Tab 이동을 확인할 before와 after 버튼을 뒀다. 세 버튼 모두 폼 제출이 없는 type="button"이다.

Windows의 headless Chromium 147.0.7727.15에서 각 입력 비교마다 새 브라우저 컨텍스트를 열었다. 저장 버튼 중앙에 좌표 클릭을 보내는 실험과, 새 문서의 본문에서 Tab → Tab → Enter → Tab을 보내는 실험을 나눴다. Playwright 1.59.0의 마우스·키보드 입력을 사용했으며 강제 클릭이나 JavaScript의 click() 호출은 쓰지 않았다. 아래는 실제 이벤트와 활성 요소 기록을 요약한 결과다.

  • 정상: 중앙을 클릭하면 save에 초점과 click이 생겼다. Tab 초점은 before → save → after였고, save에서 Enter를 누르면 click이 생겼다.
  • pointer-events: none: 중앙을 클릭해도 save에 초점과 click이 생기지 않았다. 그러나 Tab은 정상 조건과 같은 순서로 이동했고, Enter로 save의 click이 생겼다.
  • disabled: 중앙 클릭으로 save에 초점이나 click이 생기지 않았다. Tab은 before → after로 이동해 저장 버튼을 건너뛰었다. 이때 두 번째 Tab 뒤의 Enter는 after를 활성화했으며, save의 click은 없었다.

pointer-events: none 조건에서 포인터 입력 자체가 사라진 것도 아니었다. 저장 버튼을 둘러싼 배경 요소 surface가 pointerdown과 click의 대상이 됐다. 기록의 event.target이 save가 아닌 surface였으므로 저장 버튼에서 발생한 클릭이 부모로 버블링된 경우와 구별할 수 있었다. 이 페이지에는 겹친 링크나 다른 동작 버튼을 두지 않았다.

CSS UI 명세의 pointer-events 절 (새 탭에서 열림)은 none일 때 요소의 상자를 히트 테스트에서 제외하지만 순차 초점 탐색은 막지 않는다고 설명한다. MDN의 pointer-events 설명 (새 탭에서 열림)도 Tab 초점이 유지됨을 명시한다. 이 실험에서 포인터와 키보드 결과가 달랐던 이유와 맞는다.

내 버튼에서도 초점과 click을 따로 본다

문제를 작게 확인하려면 다음 내용을 새 HTML 파일에 저장하고 브라우저에서 연다. 아래 코드는 저장 동작 대신 초점을 받은 요소와 클릭 이벤트의 대상을 Console에 출력한다.

<!doctype html>
<meta charset="utf-8">
<title>버튼 입력 비교</title>
<button id="before" type="button">이전</button>
<div id="surface">
  <button id="save" type="button" style="pointer-events: none">저장</button>
</div>
<button id="after" type="button">다음</button>
<script>
for (const type of ["focusin", "click"]) {
  document.addEventListener(type, event => {
    console.log(type, event.target.id || event.target.tagName);
  });
}
</script>

개발자 도구의 Console을 연 뒤 이전 버튼을 클릭해 페이지 안에 초점을 둔다. 이어 Tab → Enter → Tab을 누른다. 이전 버튼을 클릭할 때의 출력은 빼면 다음 세 줄이 남는다. 이 짧은 예제도 별도로 같은 브라우저에서 실행해 확인했다.

focusin save
click save
focusin after

저장 버튼을 마우스로 클릭해도 click save는 나오지 않지만, 키보드로는 나온다. 자기 화면에서 이 차이가 없다면 개발자 도구가 키 입력을 받고 있지는 않은지 먼저 확인한다. 실제 요소가 <button>인지, 다른 disabled 조건이나 키 이벤트의 preventDefault(), 초점을 옮기는 코드가 있는지도 살펴본다. pointer-events: none만으로 위 세 줄이 사라질 것이라고 기대해서는 안 된다.

사용 금지라면 버튼의 상태를 바꾼다

위 예제의 저장 버튼 한 줄을 다음으로 바꾼다. 포인터용 CSS를 제거하고 버튼 자체를 비활성화하는 수정이다.

<button id="save" type="button" disabled>저장</button>

파일을 다시 연 뒤 저장 버튼을 클릭하고, 이전 버튼에서 Tab으로 이동해 본다. 이제 저장 버튼에는 focusin save와 click save가 없고, 초점은 다음 버튼으로 간다. 다음 버튼에서 Enter를 누르면 click after가 나오는 것은 정상이다. 이벤트가 하나라도 생겼는지가 아니라, 비활성화하려던 저장 버튼이 대상이었는지를 본다.

HTML 표준의 disabled 규칙 (새 탭에서 열림)은 비활성 컨트롤에서 사용자 상호작용으로 큐에 들어온 click의 전달을 막도록 정한다. 초점 가능 영역의 조건 (새 탭에서 열림)에서도 비활성 요소를 제외한다. disabled가 모든 종류의 이벤트를 없앤다는 뜻은 아니다. 이번 Chromium 기록에서는 비활성 버튼에 pointerdown과 pointerup은 남았지만 초점과 click은 생기지 않았다.

상태를 JavaScript로 바꾼다면 실제 버튼의 .disabled에 true나 false를 대입한다. HTML의 disabled="false"는 속성이 존재하므로 여전히 비활성 상태다. 또한 이 동작은 disabled를 지원하는 컨트롤의 동작이다. 링크나 role="button"인 div에 속성만 붙여 같은 결과를 기대할 수는 없다.

직접 확인한 범위는 네이티브 버튼, 데스크톱 Chromium 한 버전, 마우스 좌표와 Tab·Enter 입력이다. 실제 하드웨어 입력, Firefox·Safari, 터치와 스크린리더, 커스텀 컨트롤은 검증하지 않았다. 버튼의 비활성 상태를 사용자 입력 경로에서 확인한 것이며, 별도 코드가 함수를 직접 호출하는 경로까지 차단했다고 해석하지 않는다.

← 전체 글 보기