I am faced with a problem related to a number of identical event listeners. The main problem is that these events are executed in the order they were added: first in, first out. How can I change this order so that events are executed in reverse order: first in, last out? I know about capture property and it helps but not quite good.
Let’s say first of all i open message top bar:
const MessageTopBar = ({
onClose,
title,
mainIconSlot,
closeIconSlot,
description,
preventClose
}: MessageTopBarProps) => {
React.useEffect(() => {
const handleClose = (event: KeyboardEvent) => {
event.stopImmediatePropagation();
!preventClose && event.key === 'Escape' && onClose();
}
document.addEventListener('keydown', handleClose);
return () => {
document.removeEventListener('keydown', handleClose)
};
}, [])
return (
// jsx
);
};
It’s our first listener. Now i open details component:
const OutletDetails = ({
onClose,
name,
description,
type,
info,
shouldCloseOnClickOutside = true
}: {
description?: string;
info?: React.ReactNode;
name: string;
type: FeedTypes;
onClose: () => void;
shouldCloseOnClickOutside?: boolean;
}) => {
React.useEffect(() => {
if (!containerRef.current) return;
const handleClick = ({ target }: MouseEvent) => {
target instanceof Node && !containerRef.current?.contains(target) && onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
event.stopImmediatePropagation();
event.key === 'Escape' && onClose();
};
document.addEventListener('keydown', handleKeyDown);
shouldCloseOnClickOutside && document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
// jsx
);
};
It’s our second listener. Now open modal:
export const ModalProvider = ({ children }: { children: React.ReactNode }) => {
// another code here
const handleEscapeDown = (event: KeyboardEvent) => {
!isAsyncActionLoading && event.key === 'Escape' && closeModal();
};
const handleKeyDown = (event: KeyboardEvent) => {
event.stopImmediatePropagation();
const keyListeners = {
Tab: handleTabDown,
Escape: handleEscapeDown,
};
keyListeners[event.key as keyof typeof keyListeners]?.(event);
};
React.useEffect(() => {
if (!modals.length || !bodyRef.current) return;
bodyRef.current.focus();
document.body.style.paddingRight = window.innerWidth - document.body.offsetWidth + 'px';
document.body.classList.add('overflow-hidden');
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.body.classList.remove('overflow-hidden');
document.body.style.paddingRight = '0';
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isAsyncActionLoading, modals, handleKeyDown]);
return (
// jsx
);
};
This is our third event listener. Because we used useCapture
as true
, the modal will close first, the message top bar second just because this event was added earlier than the details. How can I change this order? If I use useCapture
as true
for every listener, it will close the message top bar first, even if the modal open.
So after a while i came up with global provider for DOM events:
export const DomEventsProvider = ({ children }: { children: React.ReactNode }) => {
const [listeners, setListeners] = React.useState(new Map());
React.useEffect(() => {
if (!listeners.size) return;
const entries = [...new Map([...listeners]).entries()];
const mappedListeners = entries.map(([type, listeners]) => {
const lastListener = [...listeners.values()].pop();
if (!lastListener) return { type, listener: () => {} };
document.addEventListener(type, lastListener, true);
return { type, listener: lastListener };
})
return () => {
mappedListeners.forEach(({ type, listener }) => {
document.removeEventListener(type, listener, true);
})
}
}, [listeners]);
const addEventListener = React.useCallback(<E extends keyof GlobalEventHandlersEventMap>(type: E, listener: (event: GlobalEventHandlersEventMap[E]) => void) => {
setListeners((prevState) => {
const listeners = new Map([...prevState]);
listeners.has(type) ? listeners.set(type, new Set([...listeners.get(type)!, listener])) : listeners.set(type, new Set([listener]));
return listeners;
});
return () => {
setListeners((prevState) => {
const listeners = new Map([...prevState]);
listeners.get(type)?.delete(listener);
!listeners.get(type)?.size && listeners.delete(type);
return listeners;
})
}
}, []);
const removeEventListener = React.useCallback(<E extends keyof GlobalEventHandlersEventMap>(type: E, listener: (event: GlobalEventHandlersEventMap[E]) => void) => {
setListeners((prevState) => {
const listeners = new Map([...prevState]);
listeners.get(type)?.delete(listener);
!listeners.get(type)?.size && listeners.delete(type);
return listeners;
})
}, []);
const value = React.useMemo(() => ({ addEventListener, removeEventListener }), []);
return (
<DomEventsContext.Provider value={value}>
{children}
</DomEventsContext.Provider>
)
};
I added clean up function inside addEventListener just for better DX. So now we can use like this:
const OutletDetails = ({
onClose,
name,
description,
type,
info,
shouldCloseOnClickOutside = true
}: {
description?: string;
info?: React.ReactNode;
name: string;
type: FeedTypes;
onClose: () => void;
shouldCloseOnClickOutside?: boolean;
}) => {
const { addEventListener } = useDomEvents();
React.useEffect(() => {
if (!containerRef.current) return;
const removeEventListener = addEventListener('keydown', (event) => {
event.stopImmediatePropagation();
event.key === 'Escape' && onClose();
});
return () => {
removeEventListener();
};
}, []);
return (
// jsx
);
};