onPointerDown 이벤트란?

aken·2025년 9월 14일
0

shadcn의 dropdown menu로 multi select를 구현하는데, 선택한 값을 없애는 x버튼을 클릭하면 dropdown이 열리고 닫히는 버그가 발생했습니다.

const [open, setOpen] = useState(false);

<DropdownMenu
  open={open}
  onOpenChange={(option) => setOpen(option)}
>
  <DropdownMenuTrigger asChild>
    {selectedItems.map((item) => (
        <div
          key={item.value}
        >
          {item.label}
          <button
            type="button"
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              remove(item.value); // 아이템을 삭제하는 버튼
            }}
          >
            <XIcon size={16} />
          </button>
        </div>
    ))}
  </DropdownMenuTrigger>
  // ...
</DropdownMenu>

이 문제의 원인은 pointerdown 이벤트였는데요.

pointerdown 이벤트

mdn

The pointerdown event is fired when a pointer becomes active

pointer가 활성화될 때 발생하는 이벤트입니다.

  • 마우스: 버튼이 눌리지 않은 상태에서 하나 이상 눌린 경우 발생
  • 터치: 손가락이 화면에 닿을 때 발생
  • 펜: 펜이 화면에 닿을 때 발생

mousedown vs pointerdown

  1. 이벤트 대상 범위
    • mousedown: 마우스
    • pointerdown: 마우스, 터치, 펜
  2. trigger 조건
    • mousedown: 마우스로 누를 때마다 발생
    • pointerdown: 버튼이 하나도 안 눌린 상태에서 처음 눌렀을 때 발생 (하나 눌린 상태에서 다른 버튼 눌려도 발생 x)

문제 해결

dropdown이 열린 상태에서 XIcon을 클릭하면 dropdown이 닫히고,
dropdown이 닫힌 상태에서 XIcon을 클릭하면 dropdown이 열리는 문제의 원인을 고민해봤습니다.

const [open, setOpen] = useState(false);

<DropdownMenu
  open={open}
  onOpenChange={(option) => setOpen(option)}
>
  <DropdownMenuTrigger asChild>
    {selectedItems.map((item) => (
        <div
          key={item.value}
        >
          {item.label}
          <button
            type="button"
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              remove(item.value); // 아이템을 삭제하는 버튼
            }}
          >
            <XIcon size={16} />
          </button>
        </div>
    ))}
  </DropdownMenuTrigger>
  // ...
</DropdownMenu>

shadcn의 DropdownMenuTrigger 코드는 @radix-ui/react-dropdown-menu의 DropdownMenuTrigger 컴포넌트를 사용하고 있습니다.

import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';

function DropdownMenuTrigger({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
  return (
    <DropdownMenuPrimitive.Trigger
      data-slot="dropdown-menu-trigger"
      {...props}
    />
  );
}

@radix-ui/react-dropdown-menu의 코드를 뜯어보면, onPointerDown 이벤트가 걸려있는 걸 확인할 수 있습니다. 그 내부에는 onOpenToggle이 실행되는데, 이 함수는 dropdown을 열고 닫는 함수입니다.

const DropdownMenuTrigger = React.forwardRef<DropdownMenuTriggerElement, DropdownMenuTriggerProps>(
  (props: ScopedProps<DropdownMenuTriggerProps>, forwardedRef) => {
    const { __scopeDropdownMenu, disabled = false, ...triggerProps } = props;
    const context = useDropdownMenuContext(TRIGGER_NAME, __scopeDropdownMenu);
    const menuScope = useMenuScope(__scopeDropdownMenu);
    return (
      <MenuPrimitive.Anchor asChild {...menuScope}>
        <Primitive.button
          type="button"
          id={context.triggerId}
          aria-haspopup="menu"
          aria-expanded={context.open}
          aria-controls={context.open ? context.contentId : undefined}
          data-state={context.open ? 'open' : 'closed'}
          data-disabled={disabled ? '' : undefined}
          disabled={disabled}
          {...triggerProps}
          ref={composeRefs(forwardedRef, context.triggerRef)}
          onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
            // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
            // but not when the control key is pressed (avoiding MacOS right click)
            if (!disabled && event.button === 0 && event.ctrlKey === false) {
              context.onOpenToggle();
              // prevent trigger focusing when opening
              // this allows the content to be given focus without competition
              if (!context.open) event.preventDefault();
            }
          })}
          onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
            if (disabled) return;
            if (['Enter', ' '].includes(event.key)) context.onOpenToggle();
            if (event.key === 'ArrowDown') context.onOpenChange(true);
            // prevent keydown from scrolling window / first focused item to execute
            // that keydown (inadvertently closing the menu)
            if (['Enter', ' ', 'ArrowDown'].includes(event.key)) event.preventDefault();
          })}
        />
      </MenuPrimitive.Anchor>
    );
  }
);

const Trigger = DropdownMenuTrigger;

export {
  // ...
  Trigger,
  // ...
}

XIcon을 감싸고 있는 버튼을 XButton이라고 하면, XButton 클릭했을 때 아래와 같은 flow로 동작됩니다.

  1. XButton의 pointerdown 이벤트 발생
  2. pointerdown 이벤트 버블링으로 인해 @radix-ui/react-dropdown-menuDropdownMenuTrigger에 pointerdown 이벤트 발생
  3. Primitive.button의 onPointerDown 이벤트 핸들러를 실행하여, dropdown 열리고 닫히는 문제 발생

XButton의 onClick에서 이벤트 버블링을 막는 것이 아닌, onPointerDown에서 이벤트 버블링을 막아야합니다.

<DropdownMenu
  open={open}
  onOpenChange={(option) => setOpen(option)}
>
  <DropdownMenuTrigger asChild>
    {selectedItems.map((item) => (
        <div
          key={item.value}
        >
          {item.label}
          <button
            type="button"
            onPointerDown={(e) => {
              e.preventDefault();
              e.stopPropagation();
            }
            onClick={(e) => {
              remove(item.value); // 아이템을 삭제하는 버튼
            }}
          >
            <XIcon size={16} />
          </button>
        </div>
    ))}
  </DropdownMenuTrigger>
  // ...
</DropdownMenu>

그럼 pointerdown 이벤트가 버블링되지 않아, Primitive.button의 onPointerDown가 실행되지 않습니다.

이벤트 버블링은 왜 있는거지?

왜 자식에서 발생한 이벤트를 부모까지 전파해서 귀찮게 하지?라는 생각을 가질수도 있을 것 같아요.
하지만 mdn에서 오히려 이벤트 버블링을 유용하게 사용할 수 있다고 하는데요.

이벤트 버블링은 개발자가 이벤트를 단순하게 관리하게 도와줍니다.

여러 자식 요소에서 같은 이벤트 동작이 필요하다면, 각 자식마다 이벤트 핸들러를 붙일 필요 없이 부모 요소에 하나의 이벤트 핸들러를 등록하면 중복된 코드를 줄일 수 있습니다.

0개의 댓글