Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions src/components/Tooltip/event-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,55 @@

type Handler = (event: Event) => void

const handlersByType = new Map<string, Set<Handler>>()

function getOrCreateSet(eventType: string): Set<Handler> {
let set = handlersByType.get(eventType)
if (!set) {
set = new Set()
handlersByType.set(eventType, set)
document.addEventListener(eventType, dispatch)
}
return set
type DelegatedListener = {
handlers: Set<Handler>
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<string, DelegatedListener>()

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<Handler>()
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 })
}
}
}
Expand All @@ -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()
}
16 changes: 12 additions & 4 deletions src/components/Tooltip/use-tooltip-events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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,
})
}
})

Expand All @@ -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,
})
}
})

Expand Down
21 changes: 21 additions & 0 deletions src/test/tooltip-interaction-behavior.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<button
type="button"
data-tooltip-id="stopped-click-test"
onClick={(event) => event.stopPropagation()}
>
Click Me
</button>
<TooltipController id="stopped-click-test" content="Stopped Click Test" openOnClick />
</>,
)

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(
<>
Expand Down
Loading