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 이벤트였는데요.
The pointerdown event is fired when a pointer becomes active
pointer가 활성화될 때 발생하는 이벤트입니다.
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로 동작됩니다.
@radix-ui/react-dropdown-menu
의 DropdownMenuTrigger
에 pointerdown 이벤트 발생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에서 오히려 이벤트 버블링을 유용하게 사용할 수 있다고 하는데요.
이벤트 버블링은 개발자가 이벤트를 단순하게 관리하게 도와줍니다.
여러 자식 요소에서 같은 이벤트 동작이 필요하다면, 각 자식마다 이벤트 핸들러를 붙일 필요 없이 부모 요소에 하나의 이벤트 핸들러를 등록하면 중복된 코드를 줄일 수 있습니다.