Skip to content

Commit 10de8d0

Browse files
committed
fix: open tooltip on click when anchor stops propagation
1 parent 3b275df commit 10de8d0

3 files changed

Lines changed: 75 additions & 32 deletions

File tree

src/components/Tooltip/event-delegation.ts

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,43 +10,55 @@
1010

1111
type Handler = (event: Event) => void
1212

13-
const handlersByType = new Map<string, Set<Handler>>()
14-
15-
function getOrCreateSet(eventType: string): Set<Handler> {
16-
let set = handlersByType.get(eventType)
17-
if (!set) {
18-
set = new Set()
19-
handlersByType.set(eventType, set)
20-
document.addEventListener(eventType, dispatch)
21-
}
22-
return set
13+
type DelegatedListener = {
14+
handlers: Set<Handler>
15+
dispatch: (event: Event) => void
16+
eventType: string
17+
capture: boolean
2318
}
2419

25-
function dispatch(event: Event): void {
26-
const handlers = handlersByType.get(event.type)
27-
if (handlers) {
28-
// Safe to iterate directly — mutations (add/remove) only happen in
29-
// setup/cleanup, not during dispatch. Set iteration is stable for
30-
// entries that existed when iteration began.
31-
handlers.forEach((handler) => {
32-
handler(event)
33-
})
20+
const handlersByType = new Map<string, DelegatedListener>()
21+
22+
function getListenerKey(eventType: string, capture: boolean): string {
23+
return `${eventType}:${capture ? 'capture' : 'bubble'}`
24+
}
25+
26+
function getOrCreateListener(eventType: string, capture: boolean): DelegatedListener {
27+
const key = getListenerKey(eventType, capture)
28+
let listener = handlersByType.get(key)
29+
if (!listener) {
30+
const handlers = new Set<Handler>()
31+
const dispatch = (event: Event): void => {
32+
handlers.forEach((handler) => {
33+
handler(event)
34+
})
35+
}
36+
listener = { handlers, dispatch, eventType, capture }
37+
handlersByType.set(key, listener)
38+
document.addEventListener(eventType, dispatch, { capture })
3439
}
40+
return listener
3541
}
3642

3743
/**
3844
* Register a handler for a document-level event type.
3945
* Returns an unsubscribe function.
4046
*/
41-
export function addDelegatedEventListener(eventType: string, handler: Handler): () => void {
42-
const set = getOrCreateSet(eventType)
43-
set.add(handler)
47+
export function addDelegatedEventListener(
48+
eventType: string,
49+
handler: Handler,
50+
options: AddEventListenerOptions = {},
51+
): () => void {
52+
const capture = Boolean(options.capture)
53+
const key = getListenerKey(eventType, capture)
54+
const listener = getOrCreateListener(eventType, capture)
55+
listener.handlers.add(handler)
4456

4557
return () => {
46-
set.delete(handler)
47-
if (set.size === 0) {
48-
handlersByType.delete(eventType)
49-
document.removeEventListener(eventType, dispatch)
58+
listener.handlers.delete(handler)
59+
if (listener.handlers.size === 0) {
60+
handlersByType.delete(key)
61+
document.removeEventListener(eventType, listener.dispatch, { capture })
5062
}
5163
}
5264
}
@@ -55,8 +67,10 @@ export function addDelegatedEventListener(eventType: string, handler: Handler):
5567
* Reset for testing purposes.
5668
*/
5769
export function resetEventDelegation(): void {
58-
handlersByType.forEach((_handlers, eventType) => {
59-
document.removeEventListener(eventType, dispatch)
70+
handlersByType.forEach((listener) => {
71+
document.removeEventListener(listener.eventType, listener.dispatch, {
72+
capture: listener.capture,
73+
})
6074
})
6175
handlersByType.clear()
6276
}

src/components/Tooltip/use-tooltip-events.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,12 @@ const useTooltipEvents = ({
293293
useEffect(() => {
294294
const cleanupFns: (() => void)[] = []
295295

296-
const addDelegatedListener = (eventType: string, listener: (event: Event) => void) => {
297-
cleanupFns.push(addDelegatedEventListener(eventType, listener))
296+
const addDelegatedListener = (
297+
eventType: string,
298+
listener: (event: Event) => void,
299+
options?: AddEventListenerOptions,
300+
) => {
301+
cleanupFns.push(addDelegatedEventListener(eventType, listener, options))
298302
}
299303

300304
const activeAnchorContainsTarget = (event?: Event): boolean =>
@@ -395,7 +399,9 @@ const useTooltipEvents = ({
395399
return
396400
}
397401
if (clickEvents.includes(event)) {
398-
addDelegatedListener(event, handleClickOpenTooltipAnchor as (event: Event) => void)
402+
addDelegatedListener(event, handleClickOpenTooltipAnchor as (event: Event) => void, {
403+
capture: true,
404+
})
399405
}
400406
})
401407

@@ -404,7 +410,9 @@ const useTooltipEvents = ({
404410
return
405411
}
406412
if (clickEvents.includes(event)) {
407-
addDelegatedListener(event, handleClickCloseTooltipAnchor as (event: Event) => void)
413+
addDelegatedListener(event, handleClickCloseTooltipAnchor as (event: Event) => void, {
414+
capture: true,
415+
})
408416
}
409417
})
410418

src/test/tooltip-interaction-behavior.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ describe('tooltip interaction behavior', () => {
6868
expect(tooltip).toHaveTextContent('Click Only Test')
6969
})
7070

71+
test('opens on click when the anchor stops propagation', async () => {
72+
await renderAndFlush(
73+
<>
74+
<button
75+
type="button"
76+
data-tooltip-id="stopped-click-test"
77+
onClick={(event) => event.stopPropagation()}
78+
>
79+
Click Me
80+
</button>
81+
<TooltipController id="stopped-click-test" content="Stopped Click Test" openOnClick />
82+
</>,
83+
)
84+
85+
fireEvent.click(screen.getByText('Click Me'))
86+
await flushMicrotasks()
87+
88+
const tooltip = await waitForTooltip('stopped-click-test')
89+
expect(tooltip).toHaveTextContent('Stopped Click Test')
90+
})
91+
7192
test('stops showing after scroll and resize global close events', async () => {
7293
await renderAndFlush(
7394
<>

0 commit comments

Comments
 (0)