diff --git a/src/components/Tooltip/event-delegation.ts b/src/components/Tooltip/event-delegation.ts index 904721ec..70ff237a 100644 --- a/src/components/Tooltip/event-delegation.ts +++ b/src/components/Tooltip/event-delegation.ts @@ -10,43 +10,55 @@ type Handler = (event: Event) => void -const handlersByType = new Map>() - -function getOrCreateSet(eventType: string): Set { - let set = handlersByType.get(eventType) - if (!set) { - set = new Set() - handlersByType.set(eventType, set) - document.addEventListener(eventType, dispatch) - } - return set +type DelegatedListener = { + handlers: Set + dispatch: (event: Event) => void + eventType: string + capture: boolean } -function dispatch(event: Event): void { - const handlers = handlersByType.get(event.type) - if (handlers) { - // Safe to iterate directly — mutations (add/remove) only happen in - // setup/cleanup, not during dispatch. Set iteration is stable for - // entries that existed when iteration began. - handlers.forEach((handler) => { - handler(event) - }) +const handlersByType = new Map() + +function getListenerKey(eventType: string, capture: boolean): string { + return `${eventType}:${capture ? 'capture' : 'bubble'}` +} + +function getOrCreateListener(eventType: string, capture: boolean): DelegatedListener { + const key = getListenerKey(eventType, capture) + let listener = handlersByType.get(key) + if (!listener) { + const handlers = new Set() + const dispatch = (event: Event): void => { + handlers.forEach((handler) => { + handler(event) + }) + } + listener = { handlers, dispatch, eventType, capture } + handlersByType.set(key, listener) + document.addEventListener(eventType, dispatch, { capture }) } + return listener } /** * Register a handler for a document-level event type. * Returns an unsubscribe function. */ -export function addDelegatedEventListener(eventType: string, handler: Handler): () => void { - const set = getOrCreateSet(eventType) - set.add(handler) +export function addDelegatedEventListener( + eventType: string, + handler: Handler, + options: AddEventListenerOptions = {}, +): () => void { + const capture = Boolean(options.capture) + const key = getListenerKey(eventType, capture) + const listener = getOrCreateListener(eventType, capture) + listener.handlers.add(handler) return () => { - set.delete(handler) - if (set.size === 0) { - handlersByType.delete(eventType) - document.removeEventListener(eventType, dispatch) + listener.handlers.delete(handler) + if (listener.handlers.size === 0) { + handlersByType.delete(key) + document.removeEventListener(eventType, listener.dispatch, { capture }) } } } @@ -55,8 +67,10 @@ export function addDelegatedEventListener(eventType: string, handler: Handler): * Reset for testing purposes. */ export function resetEventDelegation(): void { - handlersByType.forEach((_handlers, eventType) => { - document.removeEventListener(eventType, dispatch) + handlersByType.forEach((listener) => { + document.removeEventListener(listener.eventType, listener.dispatch, { + capture: listener.capture, + }) }) handlersByType.clear() } diff --git a/src/components/Tooltip/use-tooltip-events.tsx b/src/components/Tooltip/use-tooltip-events.tsx index 297df142..d223d29f 100644 --- a/src/components/Tooltip/use-tooltip-events.tsx +++ b/src/components/Tooltip/use-tooltip-events.tsx @@ -293,8 +293,12 @@ const useTooltipEvents = ({ useEffect(() => { const cleanupFns: (() => void)[] = [] - const addDelegatedListener = (eventType: string, listener: (event: Event) => void) => { - cleanupFns.push(addDelegatedEventListener(eventType, listener)) + const addDelegatedListener = ( + eventType: string, + listener: (event: Event) => void, + options?: AddEventListenerOptions, + ) => { + cleanupFns.push(addDelegatedEventListener(eventType, listener, options)) } const activeAnchorContainsTarget = (event?: Event): boolean => @@ -395,7 +399,9 @@ const useTooltipEvents = ({ return } if (clickEvents.includes(event)) { - addDelegatedListener(event, handleClickOpenTooltipAnchor as (event: Event) => void) + addDelegatedListener(event, handleClickOpenTooltipAnchor as (event: Event) => void, { + capture: true, + }) } }) @@ -404,7 +410,9 @@ const useTooltipEvents = ({ return } if (clickEvents.includes(event)) { - addDelegatedListener(event, handleClickCloseTooltipAnchor as (event: Event) => void) + addDelegatedListener(event, handleClickCloseTooltipAnchor as (event: Event) => void, { + capture: true, + }) } }) diff --git a/src/test/tooltip-interaction-behavior.spec.js b/src/test/tooltip-interaction-behavior.spec.js index bbf82ff9..9928544a 100644 --- a/src/test/tooltip-interaction-behavior.spec.js +++ b/src/test/tooltip-interaction-behavior.spec.js @@ -68,6 +68,27 @@ describe('tooltip interaction behavior', () => { expect(tooltip).toHaveTextContent('Click Only Test') }) + test('opens on click when the anchor stops propagation', async () => { + await renderAndFlush( + <> + + + , + ) + + fireEvent.click(screen.getByText('Click Me')) + await flushMicrotasks() + + const tooltip = await waitForTooltip('stopped-click-test') + expect(tooltip).toHaveTextContent('Stopped Click Test') + }) + test('stops showing after scroll and resize global close events', async () => { await renderAndFlush( <>