From 53ba78a24a4e3b9ef99c791614837880d054b1ff Mon Sep 17 00:00:00 2001 From: Daniel Barion Date: Thu, 4 Jun 2026 11:40:26 -0300 Subject: [PATCH] fix: support anchor elements with dataset capability instead of only HTMLElement --- src/components/Tooltip/Tooltip.tsx | 8 ++-- src/components/Tooltip/TooltipTypes.d.ts | 10 ++--- src/components/Tooltip/anchor-registry.ts | 12 +++--- .../Tooltip/use-tooltip-anchors.tsx | 6 +-- src/components/Tooltip/use-tooltip-events.tsx | 42 +++++++++---------- .../TooltipController/TooltipController.tsx | 10 ++--- .../TooltipControllerTypes.d.ts | 4 +- .../shared-attribute-observer.ts | 11 ++--- src/test/tooltip-anchor-selection.spec.js | 16 +++++++ src/utils/resolve-data-tooltip-anchor.ts | 3 +- 10 files changed, 67 insertions(+), 55 deletions(-) diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 98e79500..149bd30e 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -147,11 +147,11 @@ const Tooltip = ({ useEffect(() => { if (!id) return - function getAriaDescribedBy(element: HTMLElement | null) { + function getAriaDescribedBy(element: Element | null) { return element?.getAttribute('aria-describedby')?.split(' ') || [] } - function removeAriaDescribedBy(element: HTMLElement | null) { + function removeAriaDescribedBy(element: Element | null) { const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id) if (newDescribedBy.length) { element?.setAttribute('aria-describedby', newDescribedBy.join(' ')) @@ -597,10 +597,10 @@ const Tooltip = ({ useImperativeHandle(forwardRef, () => ({ open: (options) => { - let imperativeAnchor: HTMLElement | null = null + let imperativeAnchor: Element | null = null if (options?.anchorSelect) { try { - imperativeAnchor = document.querySelector(options.anchorSelect) + imperativeAnchor = document.querySelector(options.anchorSelect) } catch { if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') { console.warn(`[react-tooltip] "${options.anchorSelect}" is not a valid CSS selector`) diff --git a/src/components/Tooltip/TooltipTypes.d.ts b/src/components/Tooltip/TooltipTypes.d.ts index 97490e9d..b476838f 100644 --- a/src/components/Tooltip/TooltipTypes.d.ts +++ b/src/components/Tooltip/TooltipTypes.d.ts @@ -69,7 +69,7 @@ export interface TooltipRefProps { /** * @readonly */ - activeAnchor: HTMLElement | null + activeAnchor: Element | null /** * @readonly */ @@ -129,10 +129,10 @@ export interface ITooltip { setIsOpen?: (value: boolean) => void afterShow?: () => void afterHide?: () => void - disableTooltip?: (anchorRef: HTMLElement | null) => boolean - previousActiveAnchor: HTMLElement | null - activeAnchor: HTMLElement | null - setActiveAnchor: (anchor: HTMLElement | null) => void + disableTooltip?: (anchorRef: Element | null) => boolean + previousActiveAnchor: Element | null + activeAnchor: Element | null + setActiveAnchor: (anchor: Element | null) => void border?: CSSProperties['border'] opacity?: CSSProperties['opacity'] arrowColor?: CSSProperties['backgroundColor'] diff --git a/src/components/Tooltip/anchor-registry.ts b/src/components/Tooltip/anchor-registry.ts index 2733fc56..ccab81e7 100644 --- a/src/components/Tooltip/anchor-registry.ts +++ b/src/components/Tooltip/anchor-registry.ts @@ -1,7 +1,7 @@ -type AnchorRegistrySubscriber = (anchors: HTMLElement[], error: Error | null) => void +type AnchorRegistrySubscriber = (anchors: Element[], error: Error | null) => void type AnchorRegistryEntry = { - anchors: HTMLElement[] + anchors: Element[] error: Error | null subscribers: Set /** @@ -25,7 +25,7 @@ function extractTooltipId(selector: string): string | null { return match ? match[2].replace(/\\(['"])/g, '$1') : null } -function areAnchorListsEqual(left: HTMLElement[], right: HTMLElement[]) { +function areAnchorListsEqual(left: Element[], right: Element[]) { if (left.length !== right.length) { return false } @@ -36,7 +36,7 @@ function areAnchorListsEqual(left: HTMLElement[], right: HTMLElement[]) { function readAnchorsForSelector(selector: string) { try { return { - anchors: Array.from(document.querySelectorAll(selector)), + anchors: Array.from(document.querySelectorAll(selector)), error: null, } } catch (error) { @@ -146,7 +146,7 @@ function collectAffectedTooltipIds(records: MutationRecord[]): Set | nul for (const record of records) { if (record.type === 'attributes') { - const target = record.target as HTMLElement + const target = record.target as Element const currentId = target.getAttribute?.('data-tooltip-id') if (currentId) ids.add(currentId) if (record.oldValue) ids.add(record.oldValue) @@ -158,7 +158,7 @@ function collectAffectedTooltipIds(records: MutationRecord[]): Set | nul for (let i = 0; i < nodes.length; i++) { const node = nodes[i] if (node.nodeType !== Node.ELEMENT_NODE) continue - const el = node as HTMLElement + const el = node as Element const id = el.getAttribute?.('data-tooltip-id') if (id) ids.add(id) // For large subtrees, bail out to full refresh to avoid double-scanning diff --git a/src/components/Tooltip/use-tooltip-anchors.tsx b/src/components/Tooltip/use-tooltip-anchors.tsx index 5535b8fa..c8e0c0fe 100644 --- a/src/components/Tooltip/use-tooltip-anchors.tsx +++ b/src/components/Tooltip/use-tooltip-anchors.tsx @@ -29,12 +29,12 @@ const useTooltipAnchors = ({ id?: string anchorSelect?: string imperativeAnchorSelect?: string - activeAnchor: HTMLElement | null - disableTooltip?: (anchorRef: HTMLElement | null) => boolean + activeAnchor: Element | null + disableTooltip?: (anchorRef: Element | null) => boolean onActiveAnchorRemoved: () => void trackAnchors: boolean }) => { - const [rawAnchorElements, setRawAnchorElements] = useState([]) + const [rawAnchorElements, setRawAnchorElements] = useState([]) const [selectorError, setSelectorError] = useState(null) const warnedSelectorRef = useRef(null) const selector = useMemo( diff --git a/src/components/Tooltip/use-tooltip-events.tsx b/src/components/Tooltip/use-tooltip-events.tsx index c1fd2d91..6ef6e833 100644 --- a/src/components/Tooltip/use-tooltip-events.tsx +++ b/src/components/Tooltip/use-tooltip-events.tsx @@ -44,14 +44,14 @@ const useTooltipEvents = ({ tooltipShowDelayTimerRef, updateTooltipPosition, }: { - activeAnchor: HTMLElement | null - anchorElements: HTMLElement[] + activeAnchor: Element | null + anchorElements: Element[] anchorSelector: string clickable: boolean closeEvents?: AnchorCloseEvents delayHide: number delayShow: number - disableTooltip?: (anchorRef: HTMLElement | null) => boolean + disableTooltip?: (anchorRef: Element | null) => boolean float: boolean globalCloseEvents?: GlobalCloseEvents handleHideTooltipDelayed: (delay?: number) => void @@ -64,7 +64,7 @@ const useTooltipEvents = ({ openEvents?: AnchorOpenEvents openOnClick: boolean rendered: boolean - setActiveAnchor: (anchor: HTMLElement | null) => void + setActiveAnchor: (anchor: Element | null) => void show: boolean tooltipHideDelayTimerRef: RefObject tooltipRef: RefObject @@ -73,13 +73,13 @@ const useTooltipEvents = ({ }) => { // Ref-stable debounced handlers — avoids recreating debounce instances on every effect run // eslint-disable-next-line @typescript-eslint/no-unused-vars - const debouncedShowRef = useRef(debounce((_anchor: HTMLElement | null) => {}, 50, true)) + const debouncedShowRef = useRef(debounce((_anchor: Element | null) => {}, 50, true)) const debouncedHideRef = useRef(debounce(() => {}, 50, true)) // Cache scroll parents — only recompute when the element actually changes const anchorScrollParentRef = useRef(null) const tooltipScrollParentRef = useRef(null) - const prevAnchorRef = useRef(null) + const prevAnchorRef = useRef(null) const prevTooltipRef = useRef(null) if (activeAnchor !== prevAnchorRef.current) { @@ -187,10 +187,8 @@ const useTooltipEvents = ({ updateTooltipPositionRef.current = updateTooltipPosition // --- Handler refs (updated every render, read via ref indirection in effects) --- - const resolveAnchorElementRef = useRef<(target: EventTarget | null) => HTMLElement | null>( - () => null, - ) - const handleShowTooltipRef = useRef<(anchor: HTMLElement | null) => void>(() => {}) + const resolveAnchorElementRef = useRef<(target: EventTarget | null) => Element | null>(() => null) + const handleShowTooltipRef = useRef<(anchor: Element | null) => void>(() => {}) const handleHideTooltipRef = useRef<() => void>(() => {}) const dataTooltipId = anchorSelector ? parseDataTooltipIdSelector(anchorSelector) : null @@ -215,8 +213,8 @@ const useTooltipEvents = ({ ? targetElement : targetElement.closest(anchorSelector)) ?? null - if (matchedAnchor && !disableTooltip?.(matchedAnchor as HTMLElement)) { - return matchedAnchor as HTMLElement + if (matchedAnchor && !disableTooltip?.(matchedAnchor)) { + return matchedAnchor } } catch { return null @@ -230,7 +228,7 @@ const useTooltipEvents = ({ ) } - handleShowTooltipRef.current = (anchor: HTMLElement | null) => { + handleShowTooltipRef.current = (anchor: Element | null) => { if (!anchor) { return } @@ -283,7 +281,7 @@ const useTooltipEvents = ({ // Update debounced callbacks to always delegate to latest handler refs const debouncedShow = debouncedShowRef.current const debouncedHide = debouncedHideRef.current - debouncedShow.setCallback((anchor: HTMLElement | null) => handleShowTooltipRef.current(anchor)) + debouncedShow.setCallback((anchor: Element | null) => handleShowTooltipRef.current(anchor)) debouncedHide.setCallback(() => handleHideTooltipRef.current()) // --- Effect 1: Delegated anchor events + tooltip hover --- @@ -302,9 +300,9 @@ const useTooltipEvents = ({ } const activeAnchorContainsTarget = (event?: Event): boolean => - Boolean(event?.target && activeAnchorRef.current?.contains(event.target as HTMLElement)) + Boolean(event?.target instanceof Node && activeAnchorRef.current?.contains(event.target)) - const debouncedHandleShowTooltip = (anchor: HTMLElement | null) => { + const debouncedHandleShowTooltip = (anchor: Element | null) => { debouncedHide.cancel() debouncedShow(anchor) } @@ -333,9 +331,9 @@ const useTooltipEvents = ({ if (!targetAnchor && !activeAnchorContainsTarget(event)) { return } - const relatedTarget = (event as MouseEvent).relatedTarget as HTMLElement | null + const relatedTarget = (event as MouseEvent).relatedTarget const containerAnchor = targetAnchor || activeAnchorRef.current - if (containerAnchor?.contains(relatedTarget)) { + if (relatedTarget instanceof Node && containerAnchor?.contains(relatedTarget)) { return } debouncedHandleHideTooltip() @@ -365,9 +363,9 @@ const useTooltipEvents = ({ if (!targetAnchor && !activeAnchorContainsTarget(event)) { return } - const relatedTarget = (event as FocusEvent).relatedTarget as HTMLElement | null + const relatedTarget = (event as FocusEvent).relatedTarget const containerAnchor = targetAnchor || activeAnchorRef.current - if (containerAnchor?.contains(relatedTarget)) { + if (relatedTarget instanceof Node && containerAnchor?.contains(relatedTarget)) { return } debouncedHandleHideTooltip() @@ -512,8 +510,8 @@ const useTooltipEvents = ({ if (!showRef.current) { return } - const target = (event as MouseEvent).target as HTMLElement - if (!target?.isConnected) { + const target = (event as MouseEvent).target + if (!(target instanceof Node) || !target.isConnected) { return } if (tooltipRef.current?.contains(target)) { diff --git a/src/components/TooltipController/TooltipController.tsx b/src/components/TooltipController/TooltipController.tsx index c00da727..1255a589 100644 --- a/src/components/TooltipController/TooltipController.tsx +++ b/src/components/TooltipController/TooltipController.tsx @@ -59,14 +59,14 @@ const TooltipController = React.forwardRef( }: ITooltipController, ref, ) => { - const [activeAnchor, setActiveAnchor] = useState(null) + const [activeAnchor, setActiveAnchor] = useState(null) const [anchorDataAttributes, setAnchorDataAttributes] = useState< Partial> >({}) - const previousActiveAnchorRef = useRef(null) + const previousActiveAnchorRef = useRef(null) const styleInjectionRef = useRef(disableStyleInjection) - const handleSetActiveAnchor = useCallback((anchor: HTMLElement | null) => { + const handleSetActiveAnchor = useCallback((anchor: Element | null) => { setActiveAnchor((prev) => { if (!anchor?.isSameNode(prev)) { previousActiveAnchorRef.current = prev @@ -76,7 +76,7 @@ const TooltipController = React.forwardRef( }, []) /* c8 ignore start */ - const getDataAttributesFromAnchorElement = (elementReference: HTMLElement) => { + const getDataAttributesFromAnchorElement = (elementReference: Element) => { const dataAttributes = elementReference?.getAttributeNames().reduce( (acc, name) => { if (name.startsWith('data-tooltip-')) { @@ -123,7 +123,7 @@ const TooltipController = React.forwardRef( return () => {} } - const updateAttributes = (element: HTMLElement) => { + const updateAttributes = (element: Element) => { const attrs = getDataAttributesFromAnchorElement(element) setAnchorDataAttributes((prev) => { const keys = Object.keys(attrs) as DataAttribute[] diff --git a/src/components/TooltipController/TooltipControllerTypes.d.ts b/src/components/TooltipController/TooltipControllerTypes.d.ts index 623b56ee..379dc0f3 100644 --- a/src/components/TooltipController/TooltipControllerTypes.d.ts +++ b/src/components/TooltipController/TooltipControllerTypes.d.ts @@ -17,7 +17,7 @@ export interface ITooltipController { classNameArrow?: string content?: ReactNode portalRoot?: Element | null - render?: (render: { content: ReactNode | null; activeAnchor: HTMLElement | null }) => ReactNode + render?: (render: { content: ReactNode | null; activeAnchor: Element | null }) => ReactNode place?: PlacesType offset?: number id?: string @@ -70,7 +70,7 @@ export interface ITooltipController { setIsOpen?: (value: boolean) => void afterShow?: () => void afterHide?: () => void - disableTooltip?: (anchorRef: HTMLElement | null) => boolean + disableTooltip?: (anchorRef: Element | null) => boolean role?: React.AriaRole } diff --git a/src/components/TooltipController/shared-attribute-observer.ts b/src/components/TooltipController/shared-attribute-observer.ts index c5dcf35b..e4592beb 100644 --- a/src/components/TooltipController/shared-attribute-observer.ts +++ b/src/components/TooltipController/shared-attribute-observer.ts @@ -4,9 +4,9 @@ * all active anchors and dispatches changes to registered callbacks. */ -type AttributeCallback = (element: HTMLElement) => void +type AttributeCallback = (element: Element) => void -const observedElements = new Map>() +const observedElements = new Map>() let sharedObserver: MutationObserver | null = null @@ -26,7 +26,7 @@ function getObserver(): MutationObserver { ) { continue } - const target = mutation.target as HTMLElement + const target = mutation.target as Element const callbacks = observedElements.get(target) if (callbacks) { callbacks.forEach((cb) => cb(target)) @@ -37,10 +37,7 @@ function getObserver(): MutationObserver { return sharedObserver } -export function observeAnchorAttributes( - element: HTMLElement, - callback: AttributeCallback, -): () => void { +export function observeAnchorAttributes(element: Element, callback: AttributeCallback): () => void { const observer = getObserver() let callbacks = observedElements.get(element) if (!callbacks) { diff --git a/src/test/tooltip-anchor-selection.spec.js b/src/test/tooltip-anchor-selection.spec.js index e1afac9f..1e8271ea 100644 --- a/src/test/tooltip-anchor-selection.spec.js +++ b/src/test/tooltip-anchor-selection.spec.js @@ -214,6 +214,22 @@ describe('tooltip anchor selection', () => { expect(document.getElementById('delegated-hover-test')).toBeInTheDocument() }) + test('opens for delegated hover on an svg anchor', async () => { + render( + <> + + + + + , + ) + + const anchor = screen.getByLabelText('SVG anchor') + + hoverAnchor(anchor, 100) + await waitForTooltip('svg-anchor-test') + }) + test('does not close on focus transitions inside the same anchor', async () => { render( <> diff --git a/src/utils/resolve-data-tooltip-anchor.ts b/src/utils/resolve-data-tooltip-anchor.ts index 85386375..afb0e802 100644 --- a/src/utils/resolve-data-tooltip-anchor.ts +++ b/src/utils/resolve-data-tooltip-anchor.ts @@ -2,7 +2,8 @@ function resolveDataTooltipAnchor(targetElement: Element, tooltipId: string) { let currentElement: Element | null = targetElement while (currentElement) { - if (currentElement instanceof HTMLElement && currentElement.dataset.tooltipId === tooltipId) { + const dataset = (currentElement as Element & { dataset?: DOMStringMap }).dataset + if (dataset?.tooltipId === tooltipId) { return currentElement } currentElement = currentElement.parentElement