Skip to content

Commit 2dafd9f

Browse files
authored
Merge pull request #1280 from ReactTooltip/fix/anchor-elements-type
fix: support anchor elements with dataset capability instead of only HTMLElement
2 parents 4cc9054 + 53ba78a commit 2dafd9f

10 files changed

Lines changed: 67 additions & 55 deletions

src/components/Tooltip/Tooltip.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,11 @@ const Tooltip = ({
147147
useEffect(() => {
148148
if (!id) return
149149

150-
function getAriaDescribedBy(element: HTMLElement | null) {
150+
function getAriaDescribedBy(element: Element | null) {
151151
return element?.getAttribute('aria-describedby')?.split(' ') || []
152152
}
153153

154-
function removeAriaDescribedBy(element: HTMLElement | null) {
154+
function removeAriaDescribedBy(element: Element | null) {
155155
const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
156156
if (newDescribedBy.length) {
157157
element?.setAttribute('aria-describedby', newDescribedBy.join(' '))
@@ -597,10 +597,10 @@ const Tooltip = ({
597597

598598
useImperativeHandle(forwardRef, () => ({
599599
open: (options) => {
600-
let imperativeAnchor: HTMLElement | null = null
600+
let imperativeAnchor: Element | null = null
601601
if (options?.anchorSelect) {
602602
try {
603-
imperativeAnchor = document.querySelector<HTMLElement>(options.anchorSelect)
603+
imperativeAnchor = document.querySelector(options.anchorSelect)
604604
} catch {
605605
if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') {
606606
console.warn(`[react-tooltip] "${options.anchorSelect}" is not a valid CSS selector`)

src/components/Tooltip/TooltipTypes.d.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export interface TooltipRefProps {
6969
/**
7070
* @readonly
7171
*/
72-
activeAnchor: HTMLElement | null
72+
activeAnchor: Element | null
7373
/**
7474
* @readonly
7575
*/
@@ -129,10 +129,10 @@ export interface ITooltip {
129129
setIsOpen?: (value: boolean) => void
130130
afterShow?: () => void
131131
afterHide?: () => void
132-
disableTooltip?: (anchorRef: HTMLElement | null) => boolean
133-
previousActiveAnchor: HTMLElement | null
134-
activeAnchor: HTMLElement | null
135-
setActiveAnchor: (anchor: HTMLElement | null) => void
132+
disableTooltip?: (anchorRef: Element | null) => boolean
133+
previousActiveAnchor: Element | null
134+
activeAnchor: Element | null
135+
setActiveAnchor: (anchor: Element | null) => void
136136
border?: CSSProperties['border']
137137
opacity?: CSSProperties['opacity']
138138
arrowColor?: CSSProperties['backgroundColor']

src/components/Tooltip/anchor-registry.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
type AnchorRegistrySubscriber = (anchors: HTMLElement[], error: Error | null) => void
1+
type AnchorRegistrySubscriber = (anchors: Element[], error: Error | null) => void
22

33
type AnchorRegistryEntry = {
4-
anchors: HTMLElement[]
4+
anchors: Element[]
55
error: Error | null
66
subscribers: Set<AnchorRegistrySubscriber>
77
/**
@@ -25,7 +25,7 @@ function extractTooltipId(selector: string): string | null {
2525
return match ? match[2].replace(/\\(['"])/g, '$1') : null
2626
}
2727

28-
function areAnchorListsEqual(left: HTMLElement[], right: HTMLElement[]) {
28+
function areAnchorListsEqual(left: Element[], right: Element[]) {
2929
if (left.length !== right.length) {
3030
return false
3131
}
@@ -36,7 +36,7 @@ function areAnchorListsEqual(left: HTMLElement[], right: HTMLElement[]) {
3636
function readAnchorsForSelector(selector: string) {
3737
try {
3838
return {
39-
anchors: Array.from(document.querySelectorAll<HTMLElement>(selector)),
39+
anchors: Array.from(document.querySelectorAll(selector)),
4040
error: null,
4141
}
4242
} catch (error) {
@@ -146,7 +146,7 @@ function collectAffectedTooltipIds(records: MutationRecord[]): Set<string> | nul
146146

147147
for (const record of records) {
148148
if (record.type === 'attributes') {
149-
const target = record.target as HTMLElement
149+
const target = record.target as Element
150150
const currentId = target.getAttribute?.('data-tooltip-id')
151151
if (currentId) ids.add(currentId)
152152
if (record.oldValue) ids.add(record.oldValue)
@@ -158,7 +158,7 @@ function collectAffectedTooltipIds(records: MutationRecord[]): Set<string> | nul
158158
for (let i = 0; i < nodes.length; i++) {
159159
const node = nodes[i]
160160
if (node.nodeType !== Node.ELEMENT_NODE) continue
161-
const el = node as HTMLElement
161+
const el = node as Element
162162
const id = el.getAttribute?.('data-tooltip-id')
163163
if (id) ids.add(id)
164164
// For large subtrees, bail out to full refresh to avoid double-scanning

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ const useTooltipAnchors = ({
2929
id?: string
3030
anchorSelect?: string
3131
imperativeAnchorSelect?: string
32-
activeAnchor: HTMLElement | null
33-
disableTooltip?: (anchorRef: HTMLElement | null) => boolean
32+
activeAnchor: Element | null
33+
disableTooltip?: (anchorRef: Element | null) => boolean
3434
onActiveAnchorRemoved: () => void
3535
trackAnchors: boolean
3636
}) => {
37-
const [rawAnchorElements, setRawAnchorElements] = useState<HTMLElement[]>([])
37+
const [rawAnchorElements, setRawAnchorElements] = useState<Element[]>([])
3838
const [selectorError, setSelectorError] = useState<Error | null>(null)
3939
const warnedSelectorRef = useRef<string | null>(null)
4040
const selector = useMemo(

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

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ const useTooltipEvents = ({
4444
tooltipShowDelayTimerRef,
4545
updateTooltipPosition,
4646
}: {
47-
activeAnchor: HTMLElement | null
48-
anchorElements: HTMLElement[]
47+
activeAnchor: Element | null
48+
anchorElements: Element[]
4949
anchorSelector: string
5050
clickable: boolean
5151
closeEvents?: AnchorCloseEvents
5252
delayHide: number
5353
delayShow: number
54-
disableTooltip?: (anchorRef: HTMLElement | null) => boolean
54+
disableTooltip?: (anchorRef: Element | null) => boolean
5555
float: boolean
5656
globalCloseEvents?: GlobalCloseEvents
5757
handleHideTooltipDelayed: (delay?: number) => void
@@ -64,7 +64,7 @@ const useTooltipEvents = ({
6464
openEvents?: AnchorOpenEvents
6565
openOnClick: boolean
6666
rendered: boolean
67-
setActiveAnchor: (anchor: HTMLElement | null) => void
67+
setActiveAnchor: (anchor: Element | null) => void
6868
show: boolean
6969
tooltipHideDelayTimerRef: RefObject<NodeJS.Timeout | null>
7070
tooltipRef: RefObject<HTMLElement | null>
@@ -73,13 +73,13 @@ const useTooltipEvents = ({
7373
}) => {
7474
// Ref-stable debounced handlers — avoids recreating debounce instances on every effect run
7575
// eslint-disable-next-line @typescript-eslint/no-unused-vars
76-
const debouncedShowRef = useRef(debounce((_anchor: HTMLElement | null) => {}, 50, true))
76+
const debouncedShowRef = useRef(debounce((_anchor: Element | null) => {}, 50, true))
7777
const debouncedHideRef = useRef(debounce(() => {}, 50, true))
7878

7979
// Cache scroll parents — only recompute when the element actually changes
8080
const anchorScrollParentRef = useRef<Element | null>(null)
8181
const tooltipScrollParentRef = useRef<Element | null>(null)
82-
const prevAnchorRef = useRef<HTMLElement | null>(null)
82+
const prevAnchorRef = useRef<Element | null>(null)
8383
const prevTooltipRef = useRef<HTMLElement | null>(null)
8484

8585
if (activeAnchor !== prevAnchorRef.current) {
@@ -187,10 +187,8 @@ const useTooltipEvents = ({
187187
updateTooltipPositionRef.current = updateTooltipPosition
188188

189189
// --- Handler refs (updated every render, read via ref indirection in effects) ---
190-
const resolveAnchorElementRef = useRef<(target: EventTarget | null) => HTMLElement | null>(
191-
() => null,
192-
)
193-
const handleShowTooltipRef = useRef<(anchor: HTMLElement | null) => void>(() => {})
190+
const resolveAnchorElementRef = useRef<(target: EventTarget | null) => Element | null>(() => null)
191+
const handleShowTooltipRef = useRef<(anchor: Element | null) => void>(() => {})
194192
const handleHideTooltipRef = useRef<() => void>(() => {})
195193

196194
const dataTooltipId = anchorSelector ? parseDataTooltipIdSelector(anchorSelector) : null
@@ -215,8 +213,8 @@ const useTooltipEvents = ({
215213
? targetElement
216214
: targetElement.closest(anchorSelector)) ?? null
217215

218-
if (matchedAnchor && !disableTooltip?.(matchedAnchor as HTMLElement)) {
219-
return matchedAnchor as HTMLElement
216+
if (matchedAnchor && !disableTooltip?.(matchedAnchor)) {
217+
return matchedAnchor
220218
}
221219
} catch {
222220
return null
@@ -230,7 +228,7 @@ const useTooltipEvents = ({
230228
)
231229
}
232230

233-
handleShowTooltipRef.current = (anchor: HTMLElement | null) => {
231+
handleShowTooltipRef.current = (anchor: Element | null) => {
234232
if (!anchor) {
235233
return
236234
}
@@ -283,7 +281,7 @@ const useTooltipEvents = ({
283281
// Update debounced callbacks to always delegate to latest handler refs
284282
const debouncedShow = debouncedShowRef.current
285283
const debouncedHide = debouncedHideRef.current
286-
debouncedShow.setCallback((anchor: HTMLElement | null) => handleShowTooltipRef.current(anchor))
284+
debouncedShow.setCallback((anchor: Element | null) => handleShowTooltipRef.current(anchor))
287285
debouncedHide.setCallback(() => handleHideTooltipRef.current())
288286

289287
// --- Effect 1: Delegated anchor events + tooltip hover ---
@@ -302,9 +300,9 @@ const useTooltipEvents = ({
302300
}
303301

304302
const activeAnchorContainsTarget = (event?: Event): boolean =>
305-
Boolean(event?.target && activeAnchorRef.current?.contains(event.target as HTMLElement))
303+
Boolean(event?.target instanceof Node && activeAnchorRef.current?.contains(event.target))
306304

307-
const debouncedHandleShowTooltip = (anchor: HTMLElement | null) => {
305+
const debouncedHandleShowTooltip = (anchor: Element | null) => {
308306
debouncedHide.cancel()
309307
debouncedShow(anchor)
310308
}
@@ -333,9 +331,9 @@ const useTooltipEvents = ({
333331
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
334332
return
335333
}
336-
const relatedTarget = (event as MouseEvent).relatedTarget as HTMLElement | null
334+
const relatedTarget = (event as MouseEvent).relatedTarget
337335
const containerAnchor = targetAnchor || activeAnchorRef.current
338-
if (containerAnchor?.contains(relatedTarget)) {
336+
if (relatedTarget instanceof Node && containerAnchor?.contains(relatedTarget)) {
339337
return
340338
}
341339
debouncedHandleHideTooltip()
@@ -365,9 +363,9 @@ const useTooltipEvents = ({
365363
if (!targetAnchor && !activeAnchorContainsTarget(event)) {
366364
return
367365
}
368-
const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null
366+
const relatedTarget = (event as FocusEvent).relatedTarget
369367
const containerAnchor = targetAnchor || activeAnchorRef.current
370-
if (containerAnchor?.contains(relatedTarget)) {
368+
if (relatedTarget instanceof Node && containerAnchor?.contains(relatedTarget)) {
371369
return
372370
}
373371
debouncedHandleHideTooltip()
@@ -512,8 +510,8 @@ const useTooltipEvents = ({
512510
if (!showRef.current) {
513511
return
514512
}
515-
const target = (event as MouseEvent).target as HTMLElement
516-
if (!target?.isConnected) {
513+
const target = (event as MouseEvent).target
514+
if (!(target instanceof Node) || !target.isConnected) {
517515
return
518516
}
519517
if (tooltipRef.current?.contains(target)) {

src/components/TooltipController/TooltipController.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,14 @@ const TooltipController = React.forwardRef<TooltipRefProps, ITooltipController>(
5959
}: ITooltipController,
6060
ref,
6161
) => {
62-
const [activeAnchor, setActiveAnchor] = useState<HTMLElement | null>(null)
62+
const [activeAnchor, setActiveAnchor] = useState<Element | null>(null)
6363
const [anchorDataAttributes, setAnchorDataAttributes] = useState<
6464
Partial<Record<DataAttribute, string | null>>
6565
>({})
66-
const previousActiveAnchorRef = useRef<HTMLElement | null>(null)
66+
const previousActiveAnchorRef = useRef<Element | null>(null)
6767
const styleInjectionRef = useRef(disableStyleInjection)
6868

69-
const handleSetActiveAnchor = useCallback((anchor: HTMLElement | null) => {
69+
const handleSetActiveAnchor = useCallback((anchor: Element | null) => {
7070
setActiveAnchor((prev) => {
7171
if (!anchor?.isSameNode(prev)) {
7272
previousActiveAnchorRef.current = prev
@@ -76,7 +76,7 @@ const TooltipController = React.forwardRef<TooltipRefProps, ITooltipController>(
7676
}, [])
7777

7878
/* c8 ignore start */
79-
const getDataAttributesFromAnchorElement = (elementReference: HTMLElement) => {
79+
const getDataAttributesFromAnchorElement = (elementReference: Element) => {
8080
const dataAttributes = elementReference?.getAttributeNames().reduce(
8181
(acc, name) => {
8282
if (name.startsWith('data-tooltip-')) {
@@ -123,7 +123,7 @@ const TooltipController = React.forwardRef<TooltipRefProps, ITooltipController>(
123123
return () => {}
124124
}
125125

126-
const updateAttributes = (element: HTMLElement) => {
126+
const updateAttributes = (element: Element) => {
127127
const attrs = getDataAttributesFromAnchorElement(element)
128128
setAnchorDataAttributes((prev) => {
129129
const keys = Object.keys(attrs) as DataAttribute[]

src/components/TooltipController/TooltipControllerTypes.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface ITooltipController {
1717
classNameArrow?: string
1818
content?: ReactNode
1919
portalRoot?: Element | null
20-
render?: (render: { content: ReactNode | null; activeAnchor: HTMLElement | null }) => ReactNode
20+
render?: (render: { content: ReactNode | null; activeAnchor: Element | null }) => ReactNode
2121
place?: PlacesType
2222
offset?: number
2323
id?: string
@@ -70,7 +70,7 @@ export interface ITooltipController {
7070
setIsOpen?: (value: boolean) => void
7171
afterShow?: () => void
7272
afterHide?: () => void
73-
disableTooltip?: (anchorRef: HTMLElement | null) => boolean
73+
disableTooltip?: (anchorRef: Element | null) => boolean
7474
role?: React.AriaRole
7575
}
7676

src/components/TooltipController/shared-attribute-observer.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
* all active anchors and dispatches changes to registered callbacks.
55
*/
66

7-
type AttributeCallback = (element: HTMLElement) => void
7+
type AttributeCallback = (element: Element) => void
88

9-
const observedElements = new Map<HTMLElement, Set<AttributeCallback>>()
9+
const observedElements = new Map<Element, Set<AttributeCallback>>()
1010

1111
let sharedObserver: MutationObserver | null = null
1212

@@ -26,7 +26,7 @@ function getObserver(): MutationObserver {
2626
) {
2727
continue
2828
}
29-
const target = mutation.target as HTMLElement
29+
const target = mutation.target as Element
3030
const callbacks = observedElements.get(target)
3131
if (callbacks) {
3232
callbacks.forEach((cb) => cb(target))
@@ -37,10 +37,7 @@ function getObserver(): MutationObserver {
3737
return sharedObserver
3838
}
3939

40-
export function observeAnchorAttributes(
41-
element: HTMLElement,
42-
callback: AttributeCallback,
43-
): () => void {
40+
export function observeAnchorAttributes(element: Element, callback: AttributeCallback): () => void {
4441
const observer = getObserver()
4542
let callbacks = observedElements.get(element)
4643
if (!callbacks) {

src/test/tooltip-anchor-selection.spec.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ describe('tooltip anchor selection', () => {
214214
expect(document.getElementById('delegated-hover-test')).toBeInTheDocument()
215215
})
216216

217+
test('opens for delegated hover on an svg anchor', async () => {
218+
render(
219+
<>
220+
<svg data-tooltip-id="svg-anchor-test" aria-label="SVG anchor" width="16" height="16">
221+
<circle cx="8" cy="8" r="8" />
222+
</svg>
223+
<TooltipController id="svg-anchor-test" content="SVG Anchor Test" />
224+
</>,
225+
)
226+
227+
const anchor = screen.getByLabelText('SVG anchor')
228+
229+
hoverAnchor(anchor, 100)
230+
await waitForTooltip('svg-anchor-test')
231+
})
232+
217233
test('does not close on focus transitions inside the same anchor', async () => {
218234
render(
219235
<>

src/utils/resolve-data-tooltip-anchor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ function resolveDataTooltipAnchor(targetElement: Element, tooltipId: string) {
22
let currentElement: Element | null = targetElement
33

44
while (currentElement) {
5-
if (currentElement instanceof HTMLElement && currentElement.dataset.tooltipId === tooltipId) {
5+
const dataset = (currentElement as Element & { dataset?: DOMStringMap }).dataset
6+
if (dataset?.tooltipId === tooltipId) {
67
return currentElement
78
}
89
currentElement = currentElement.parentElement

0 commit comments

Comments
 (0)