diff --git a/docs/docs/examples/imperative-mode.mdx b/docs/docs/examples/imperative-mode.mdx new file mode 100644 index 00000000..b8fe3553 --- /dev/null +++ b/docs/docs/examples/imperative-mode.mdx @@ -0,0 +1,207 @@ +--- +sidebar_position: 1 +--- + +# Imperative mode (ref) + +Using the ReactTooltip imperative mode to control the tooltip programmatically. + +import { useRef } from 'react'; +import { Tooltip } from 'react-tooltip' + +export const TooltipAnchor = ({ children, id, ...rest }) => { + return ( + + {children} + + ) +} + +### Basic usage + +A ref object created with `React.useRef()` can be passed to the `ref` tooltip prop. +It allows you to expose internal state variables (read-only), and to also control the tooltip programmatically. + +#### API + +```ts +interface TooltipImperativeOpenOptions { + anchorSelect?: string + position?: IPosition + place?: PlacesType + /** + * In practice, `ChildrenType` -> `React.ReactNode` + */ + content?: ChildrenType + /** + * Delay (in ms) before opening the tooltip. + */ + delay?: number +} + +interface TooltipImperativeCloseOptions { + /** + * Delay (in ms) before closing the tooltip. + */ + delay?: number +} + +interface TooltipRefProps { + open: (options?: TooltipImperativeOpenOptions) => void + close: (options?: TooltipImperativeCloseOptions) => void + /** + * @readonly + */ + activeAnchor: HTMLElement | null + /** + * @readonly + */ + place: PlacesType + /** + * @readonly + */ + isOpen: boolean +} +``` + +#### Methods + +:::info + +The imperative methods can be applied alongside regular tooltip usage. For example, you could use just `close()` to close a regular tooltip after an HTTP request is finished. + +If you intend to use the tooltip exclusively with these methods, setting the `imperativeModeOnly` prop to disable default behavior is recommended. Otherwise, you might face undesired behavior. + +::: + +- `open()` opens the tooltip programmatically. All arguments are optional + - `anchorSelect` overrides the current selector. Ideally, it should match only one element (e.g. `#my-element`) + - `position` overrides the `position` tooltip prop + - `place` overrides the `place` tooltip prop + - `content` overrides the tooltip content, whether it was set through `content`, `render`, or any other way + - `delay` indicates how long (in ms) before the tooltip actually opens +- `close()` closes the tooltip programmatically + - `delay` indicates how long (in ms) before the tooltip actually closes + +#### Internal state + +:::note + +These are read-only. Updating their values has no effect on the tooltip. + +::: + +- `activeAnchor` is a reference to the current anchor element +- `place` is the current tooltip placement relative to the anchor element. Can differ from the `place` tooltip prop if the tooltip is close to the edges of its container +- `isOpen` indicates whether the tooltip is currently being shown or not + +```jsx +import { useRef } from 'react'; +import { Tooltip, TooltipRefProps } from 'react-tooltip'; + +const tooltipRef1 = useRef(null) +const tooltipRef2 = useRef(null) + + + ◕‿‿◕ + + + + + +``` + +:::caution + +Notice the tooltip still closes when unhovering the anchor element. This might be undesired if you're using the imperative methods exclusively. + +If that's the case, use the `imperativeModeOnly` tooltip prop to disable default tooltip behavior. + +::: + +export const ImperativeModeExample = () => { + const tooltipRef1 = useRef(null) + const tooltipRef2 = useRef(null) + return ( + <> + + ◕‿‿◕ + +
+ + +
+ + + + ) +} + +
+ +
diff --git a/docs/docs/options.mdx b/docs/docs/options.mdx index 84415a68..de25b904 100644 --- a/docs/docs/options.mdx +++ b/docs/docs/options.mdx @@ -89,6 +89,7 @@ import { Tooltip } from 'react-tooltip'; | name | type | required | default | values | description | | ----------------------- | -------------------------------------- | -------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ref` | Tooltip reference | no | | `React.useRef` | Reference object which exposes internal state, and some methods for manually controlling the tooltip. See [the examples](./examples/imperative-mode.mdx). | | `className` | `string` | no | | | Class name to customize tooltip element. You can also use the default class `react-tooltip` which is set internally | | `classNameArrow` | `string` | no | | | Class name to customize tooltip arrow element. You can also use the default class `react-tooltip-arrow` which is set internally | | `content` | `string` | no | | | Content to be displayed in tooltip (`html` prop is priorized over `content`) | @@ -117,6 +118,7 @@ import { Tooltip } from 'react-tooltip'; | `openEvents` | `Record` | no | `mouseenter` `focus` | `mouseenter` `focus` `click` `dblclick` `mousedown` | Events to be listened on the anchor elements to open the tooltip | | `closeEvents` | `Record` | no | `mouseleave` `blur` | `mouseleave` `blur` `click` `dblclick` `mouseup` | Events to be listened on the anchor elements to close the tooltip | | `globalCloseEvents` | `Record` | no | | `escape` `scroll` `resize` `clickOutsideAnchor` | Global events to be listened to close the tooltip (`escape` closes on pressing `ESC`, `clickOutsideAnchor` is useful with click events on `openEvents`) | +| `imperativeModeOnly` | `boolean` | no | `false` | `true` `false` | When enabled, default tooltip behavior is disabled. Check [the examples](./examples/imperative-mode.mdx) for more details | | `style` | `CSSProperties` | no | | a CSS style object | Add inline styles directly to the tooltip | | `position` | `{ x: number; y: number }` | no | | any `number` value for both `x` and `y` | Override the tooltip position on the DOM | | `isOpen` | `boolean` | no | | `true` `false` | The tooltip can be controlled or uncontrolled, this attribute can be used to handle show and hide tooltip outside tooltip (can be used **without** `setIsOpen`) | diff --git a/src/App.tsx b/src/App.tsx index c65155ac..18a48af9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ import { TooltipController as Tooltip } from 'components/TooltipController' -import { IPosition } from 'components/Tooltip/TooltipTypes.d' -import React, { useState } from 'react' +import { IPosition, TooltipRefProps } from 'components/Tooltip/TooltipTypes.d' +import React, { useEffect, useRef, useState } from 'react' import { inline, offset } from '@floating-ui/dom' import styles from './styles.module.css' @@ -11,6 +11,7 @@ function App() { const [isDarkOpen, setIsDarkOpen] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const [toggle, setToggle] = useState(false) + const tooltipRef = useRef(null) const handlePositionClick: React.MouseEventHandler = (event) => { const x = event.clientX @@ -23,6 +24,19 @@ function App() { setAnchorId(target.id) } + useEffect(() => { + const handleQ = (event: KeyboardEvent) => { + if (event.key === 'q') { + // q + tooltipRef.current?.close() + } + } + window.addEventListener('keydown', handleQ) + return () => { + window.removeEventListener('keydown', handleQ) + } + }) + return (
diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 32297bbf..eda9e2d9 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react' +import React, { useEffect, useState, useRef, useCallback, useImperativeHandle } from 'react' import { autoUpdate } from '@floating-ui/dom' import classNames from 'classnames' import debounce from 'utils/debounce' @@ -15,10 +15,12 @@ import type { IPosition, ITooltip, PlacesType, + TooltipImperativeOpenOptions, } from './TooltipTypes' const Tooltip = ({ // props + forwardRef, id, className, classNameArrow, @@ -44,6 +46,7 @@ const Tooltip = ({ openEvents, closeEvents, globalCloseEvents, + imperativeModeOnly, style: externalStyles, position, afterShow, @@ -68,6 +71,9 @@ const Tooltip = ({ const [inlineArrowStyles, setInlineArrowStyles] = useState({}) const [show, setShow] = useState(false) const [rendered, setRendered] = useState(false) + const [imperativeOptions, setImperativeOptions] = useState( + null, + ) const wasShowing = useRef(false) const lastFloatPosition = useRef(null) /** @@ -106,6 +112,8 @@ const Tooltip = ({ mouseleave: true, blur: true, click: false, + dblclick: false, + mouseup: false, } if (!closeEvents && shouldOpenOnClick) { Object.assign(actualCloseEvents, { @@ -122,6 +130,29 @@ const Tooltip = ({ clickOutsideAnchor: hasClickEvent || false, } + if (imperativeModeOnly) { + Object.assign(actualOpenEvents, { + mouseenter: false, + focus: false, + click: false, + dblclick: false, + mousedown: false, + }) + Object.assign(actualCloseEvents, { + mouseleave: false, + blur: false, + click: false, + dblclick: false, + mouseup: false, + }) + Object.assign(actualGlobalCloseEvents, { + escape: false, + scroll: false, + resize: false, + clickOutsideAnchor: false, + }) + } + /** * useLayoutEffect runs before useEffect, * but should be used carefully because of caveats @@ -183,18 +214,20 @@ const Tooltip = ({ if (show) { afterShow?.() } else { - afterHide?.() + /** + * see `onTransitionEnd` on tooltip wrapper + */ } }, [show]) - const handleShowTooltipDelayed = () => { + const handleShowTooltipDelayed = (delay = delayShow) => { if (tooltipShowDelayTimerRef.current) { clearTimeout(tooltipShowDelayTimerRef.current) } tooltipShowDelayTimerRef.current = setTimeout(() => { handleShow(true) - }, delayShow) + }, delay) } const handleHideTooltipDelayed = (delay = delayHide) => { @@ -268,7 +301,7 @@ const Tooltip = ({ }, } as Element computeTooltipPosition({ - place, + place: imperativeOptions?.place ?? place, offset, elementReference: virtualElement, tooltipReference: tooltipRef.current, @@ -301,12 +334,16 @@ const Tooltip = ({ } const handleClickOutsideAnchors = (event: MouseEvent) => { - const anchorById = document.querySelector(`[id='${anchorId}']`) - const anchors = [anchorById, ...anchorsBySelect] - if (anchors.some((anchor) => anchor?.contains(event.target as HTMLElement))) { + if (!show) { return } - if (tooltipRef.current?.contains(event.target as HTMLElement)) { + const target = event.target as HTMLElement + if (tooltipRef.current?.contains(target)) { + return + } + const anchorById = document.querySelector(`[id='${anchorId}']`) + const anchors = [anchorById, ...anchorsBySelect] + if (anchors.some((anchor) => anchor?.contains(target))) { return } handleShow(false) @@ -320,9 +357,10 @@ const Tooltip = ({ const debouncedHandleShowTooltip = debounce(handleShowTooltip, 50, true) const debouncedHandleHideTooltip = debounce(handleHideTooltip, 50, true) const updateTooltipPosition = useCallback(() => { - if (position) { + const actualPosition = imperativeOptions?.position ?? position + if (actualPosition) { // if `position` is set, override regular and `float` positioning - handleTooltipPosition(position) + handleTooltipPosition(actualPosition) return } @@ -346,7 +384,7 @@ const Tooltip = ({ } computeTooltipPosition({ - place, + place: imperativeOptions?.place ?? place, offset, elementReference: activeAnchor, tooltipReference: tooltipRef.current, @@ -373,9 +411,11 @@ const Tooltip = ({ content, externalStyles, place, + imperativeOptions?.place, offset, positionStrategy, position, + imperativeOptions?.position, float, ]) @@ -550,7 +590,7 @@ const Tooltip = ({ ]) useEffect(() => { - let selector = anchorSelect ?? '' + let selector = imperativeOptions?.anchorSelect ?? anchorSelect ?? '' if (!selector && id) { selector = `[data-tooltip-id='${id}']` } @@ -650,7 +690,7 @@ const Tooltip = ({ return () => { documentObserver.disconnect() } - }, [id, anchorSelect, activeAnchor]) + }, [id, anchorSelect, imperativeOptions?.anchorSelect, activeAnchor]) useEffect(() => { updateTooltipPosition() @@ -694,7 +734,7 @@ const Tooltip = ({ }, []) useEffect(() => { - let selector = anchorSelect + let selector = imperativeOptions?.anchorSelect ?? anchorSelect if (!selector && id) { selector = `[data-tooltip-id='${id}']` } @@ -708,11 +748,44 @@ const Tooltip = ({ // warning was already issued in the controller setAnchorsBySelect([]) } - }, [id, anchorSelect]) + }, [id, anchorSelect, imperativeOptions?.anchorSelect]) + const actualContent = imperativeOptions?.content ?? content const canShow = show && Object.keys(inlineStyles).length > 0 - return rendered && !hidden && content ? ( + useImperativeHandle(forwardRef, () => ({ + open: (options) => { + if (options?.anchorSelect) { + try { + document.querySelector(options.anchorSelect) + } catch { + if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(`[react-tooltip] "${options.anchorSelect}" is not a valid CSS selector`) + } + return + } + } + setImperativeOptions(options ?? null) + if (options?.delay) { + handleShowTooltipDelayed(options.delay) + } else { + handleShow(true) + } + }, + close: (options) => { + if (options?.delay) { + handleHideTooltipDelayed(options.delay) + } else { + handleShow(false) + } + }, + activeAnchor, + place: actualPlacement, + isOpen: Boolean(rendered && !hidden && actualContent && canShow), + })) + + return rendered && !hidden && actualContent ? ( - {content} + {actualContent} void + close: (options?: TooltipImperativeCloseOptions) => void + /** + * @readonly + */ + activeAnchor: HTMLElement | null + /** + * @readonly + */ + place: PlacesType + /** + * @readonly + */ + isOpen: boolean +} + export type AnchorOpenEvents = { mouseenter?: boolean focus?: boolean @@ -71,6 +106,7 @@ export type GlobalCloseEvents = { } export interface ITooltip { + forwardRef?: React.ForwardedRef className?: string classNameArrow?: string content?: ChildrenType @@ -105,6 +141,7 @@ export interface ITooltip { openEvents?: AnchorOpenEvents closeEvents?: AnchorCloseEvents globalCloseEvents?: GlobalCloseEvents + imperativeModeOnly?: boolean style?: CSSProperties position?: IPosition isOpen?: boolean diff --git a/src/components/TooltipController/TooltipController.tsx b/src/components/TooltipController/TooltipController.tsx index b5613170..cdc65cff 100644 --- a/src/components/TooltipController/TooltipController.tsx +++ b/src/components/TooltipController/TooltipController.tsx @@ -9,348 +9,357 @@ import type { DataAttribute, ITooltip, ChildrenType, + TooltipRefProps, } from 'components/Tooltip/TooltipTypes' import { useTooltip } from 'components/TooltipProvider' import { TooltipContent } from 'components/TooltipContent' import cssSupports from 'utils/css-supports' import type { ITooltipController } from './TooltipControllerTypes' -const TooltipController = ({ - id, - anchorId, - anchorSelect, - content, - html, - render, - className, - classNameArrow, - variant = 'dark', - place = 'top', - offset = 10, - wrapper = 'div', - children = null, - events = ['hover'], - openOnClick = false, - positionStrategy = 'absolute', - middlewares, - delayShow = 0, - delayHide = 0, - float = false, - hidden = false, - noArrow = false, - clickable = false, - closeOnEsc = false, - closeOnScroll = false, - closeOnResize = false, - openEvents, - closeEvents, - globalCloseEvents, - style, - position, - isOpen, - disableStyleInjection = false, - border, - opacity, - arrowColor, - setIsOpen, - afterShow, - afterHide, -}: ITooltipController) => { - const [tooltipContent, setTooltipContent] = useState(content) - const [tooltipHtml, setTooltipHtml] = useState(html) - const [tooltipPlace, setTooltipPlace] = useState(place) - const [tooltipVariant, setTooltipVariant] = useState(variant) - const [tooltipOffset, setTooltipOffset] = useState(offset) - const [tooltipDelayShow, setTooltipDelayShow] = useState(delayShow) - const [tooltipDelayHide, setTooltipDelayHide] = useState(delayHide) - const [tooltipFloat, setTooltipFloat] = useState(float) - const [tooltipHidden, setTooltipHidden] = useState(hidden) - const [tooltipWrapper, setTooltipWrapper] = useState(wrapper) - const [tooltipEvents, setTooltipEvents] = useState(events) - const [tooltipPositionStrategy, setTooltipPositionStrategy] = useState(positionStrategy) - const [activeAnchor, setActiveAnchor] = useState(null) - const styleInjectionRef = useRef(disableStyleInjection) - /** - * @todo Remove this in a future version (provider/wrapper method is deprecated) - */ - const { anchorRefs, activeAnchor: providerActiveAnchor } = useTooltip(id) +const TooltipController = React.forwardRef( + ( + { + id, + anchorId, + anchorSelect, + content, + html, + render, + className, + classNameArrow, + variant = 'dark', + place = 'top', + offset = 10, + wrapper = 'div', + children = null, + events = ['hover'], + openOnClick = false, + positionStrategy = 'absolute', + middlewares, + delayShow = 0, + delayHide = 0, + float = false, + hidden = false, + noArrow = false, + clickable = false, + closeOnEsc = false, + closeOnScroll = false, + closeOnResize = false, + openEvents, + closeEvents, + globalCloseEvents, + imperativeModeOnly = false, + style, + position, + isOpen, + disableStyleInjection = false, + border, + opacity, + arrowColor, + setIsOpen, + afterShow, + afterHide, + }: ITooltipController, + ref, + ) => { + const [tooltipContent, setTooltipContent] = useState(content) + const [tooltipHtml, setTooltipHtml] = useState(html) + const [tooltipPlace, setTooltipPlace] = useState(place) + const [tooltipVariant, setTooltipVariant] = useState(variant) + const [tooltipOffset, setTooltipOffset] = useState(offset) + const [tooltipDelayShow, setTooltipDelayShow] = useState(delayShow) + const [tooltipDelayHide, setTooltipDelayHide] = useState(delayHide) + const [tooltipFloat, setTooltipFloat] = useState(float) + const [tooltipHidden, setTooltipHidden] = useState(hidden) + const [tooltipWrapper, setTooltipWrapper] = useState(wrapper) + const [tooltipEvents, setTooltipEvents] = useState(events) + const [tooltipPositionStrategy, setTooltipPositionStrategy] = useState(positionStrategy) + const [activeAnchor, setActiveAnchor] = useState(null) + const styleInjectionRef = useRef(disableStyleInjection) + /** + * @todo Remove this in a future version (provider/wrapper method is deprecated) + */ + const { anchorRefs, activeAnchor: providerActiveAnchor } = useTooltip(id) - const getDataAttributesFromAnchorElement = (elementReference: HTMLElement) => { - const dataAttributes = elementReference?.getAttributeNames().reduce((acc, name) => { - if (name.startsWith('data-tooltip-')) { - const parsedAttribute = name.replace(/^data-tooltip-/, '') as DataAttribute - acc[parsedAttribute] = elementReference?.getAttribute(name) ?? null - } - return acc - }, {} as Record) + const getDataAttributesFromAnchorElement = (elementReference: HTMLElement) => { + const dataAttributes = elementReference?.getAttributeNames().reduce((acc, name) => { + if (name.startsWith('data-tooltip-')) { + const parsedAttribute = name.replace(/^data-tooltip-/, '') as DataAttribute + acc[parsedAttribute] = elementReference?.getAttribute(name) ?? null + } + return acc + }, {} as Record) - return dataAttributes - } + return dataAttributes + } - const applyAllDataAttributesFromAnchorElement = ( - dataAttributes: Record, - ) => { - const handleDataAttributes: Record void> = { - place: (value) => { - setTooltipPlace((value as PlacesType) ?? place) - }, - content: (value) => { - setTooltipContent(value ?? content) - }, - html: (value) => { - setTooltipHtml(value ?? html) - }, - variant: (value) => { - setTooltipVariant((value as VariantType) ?? variant) - }, - offset: (value) => { - setTooltipOffset(value === null ? offset : Number(value)) - }, - wrapper: (value) => { - setTooltipWrapper((value as WrapperType) ?? wrapper) - }, - events: (value) => { - const parsed = value?.split(' ') as EventsType[] - setTooltipEvents(parsed ?? events) - }, - 'position-strategy': (value) => { - setTooltipPositionStrategy((value as PositionStrategy) ?? positionStrategy) - }, - 'delay-show': (value) => { - setTooltipDelayShow(value === null ? delayShow : Number(value)) - }, - 'delay-hide': (value) => { - setTooltipDelayHide(value === null ? delayHide : Number(value)) - }, - float: (value) => { - setTooltipFloat(value === null ? float : value === 'true') - }, - hidden: (value) => { - setTooltipHidden(value === null ? hidden : value === 'true') - }, + const applyAllDataAttributesFromAnchorElement = ( + dataAttributes: Record, + ) => { + const handleDataAttributes: Record void> = { + place: (value) => { + setTooltipPlace((value as PlacesType) ?? place) + }, + content: (value) => { + setTooltipContent(value ?? content) + }, + html: (value) => { + setTooltipHtml(value ?? html) + }, + variant: (value) => { + setTooltipVariant((value as VariantType) ?? variant) + }, + offset: (value) => { + setTooltipOffset(value === null ? offset : Number(value)) + }, + wrapper: (value) => { + setTooltipWrapper((value as WrapperType) ?? wrapper) + }, + events: (value) => { + const parsed = value?.split(' ') as EventsType[] + setTooltipEvents(parsed ?? events) + }, + 'position-strategy': (value) => { + setTooltipPositionStrategy((value as PositionStrategy) ?? positionStrategy) + }, + 'delay-show': (value) => { + setTooltipDelayShow(value === null ? delayShow : Number(value)) + }, + 'delay-hide': (value) => { + setTooltipDelayHide(value === null ? delayHide : Number(value)) + }, + float: (value) => { + setTooltipFloat(value === null ? float : value === 'true') + }, + hidden: (value) => { + setTooltipHidden(value === null ? hidden : value === 'true') + }, + } + // reset unset data attributes to default values + // without this, data attributes from the last active anchor will still be used + Object.values(handleDataAttributes).forEach((handler) => handler(null)) + Object.entries(dataAttributes).forEach(([key, value]) => { + handleDataAttributes[key as DataAttribute]?.(value) + }) } - // reset unset data attributes to default values - // without this, data attributes from the last active anchor will still be used - Object.values(handleDataAttributes).forEach((handler) => handler(null)) - Object.entries(dataAttributes).forEach(([key, value]) => { - handleDataAttributes[key as DataAttribute]?.(value) - }) - } - useEffect(() => { - setTooltipContent(content) - }, [content]) + useEffect(() => { + setTooltipContent(content) + }, [content]) - useEffect(() => { - setTooltipHtml(html) - }, [html]) + useEffect(() => { + setTooltipHtml(html) + }, [html]) - useEffect(() => { - setTooltipPlace(place) - }, [place]) + useEffect(() => { + setTooltipPlace(place) + }, [place]) - useEffect(() => { - setTooltipVariant(variant) - }, [variant]) + useEffect(() => { + setTooltipVariant(variant) + }, [variant]) - useEffect(() => { - setTooltipOffset(offset) - }, [offset]) + useEffect(() => { + setTooltipOffset(offset) + }, [offset]) - useEffect(() => { - setTooltipDelayShow(delayShow) - }, [delayShow]) + useEffect(() => { + setTooltipDelayShow(delayShow) + }, [delayShow]) - useEffect(() => { - setTooltipDelayHide(delayHide) - }, [delayHide]) + useEffect(() => { + setTooltipDelayHide(delayHide) + }, [delayHide]) - useEffect(() => { - setTooltipFloat(float) - }, [float]) + useEffect(() => { + setTooltipFloat(float) + }, [float]) - useEffect(() => { - setTooltipHidden(hidden) - }, [hidden]) + useEffect(() => { + setTooltipHidden(hidden) + }, [hidden]) - useEffect(() => { - setTooltipPositionStrategy(positionStrategy) - }, [positionStrategy]) + useEffect(() => { + setTooltipPositionStrategy(positionStrategy) + }, [positionStrategy]) - useEffect(() => { - if (styleInjectionRef.current === disableStyleInjection) { - return - } - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn('[react-tooltip] Do not change `disableStyleInjection` dynamically.') - } - }, [disableStyleInjection]) + useEffect(() => { + if (styleInjectionRef.current === disableStyleInjection) { + return + } + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn('[react-tooltip] Do not change `disableStyleInjection` dynamically.') + } + }, [disableStyleInjection]) - useEffect(() => { - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('react-tooltip-inject-styles', { - detail: { - disableCore: disableStyleInjection === 'core', - disableBase: disableStyleInjection, - }, - }), - ) - } - }, []) + useEffect(() => { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('react-tooltip-inject-styles', { + detail: { + disableCore: disableStyleInjection === 'core', + disableBase: disableStyleInjection, + }, + }), + ) + } + }, []) - useEffect(() => { - const elementRefs = new Set(anchorRefs) + useEffect(() => { + const elementRefs = new Set(anchorRefs) - let selector = anchorSelect - if (!selector && id) { - selector = `[data-tooltip-id='${id}']` - } - if (selector) { - try { - const anchorsBySelect = document.querySelectorAll(selector) - anchorsBySelect.forEach((anchor) => { - elementRefs.add({ current: anchor }) - }) - } catch { - if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.warn(`[react-tooltip] "${selector}" is not a valid CSS selector`) + let selector = anchorSelect + if (!selector && id) { + selector = `[data-tooltip-id='${id}']` + } + if (selector) { + try { + const anchorsBySelect = document.querySelectorAll(selector) + anchorsBySelect.forEach((anchor) => { + elementRefs.add({ current: anchor }) + }) + } catch { + if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(`[react-tooltip] "${selector}" is not a valid CSS selector`) + } } } - } - const anchorById = document.querySelector(`[id='${anchorId}']`) - if (anchorById) { - elementRefs.add({ current: anchorById }) - } + const anchorById = document.querySelector(`[id='${anchorId}']`) + if (anchorById) { + elementRefs.add({ current: anchorById }) + } - if (!elementRefs.size) { - return () => null - } + if (!elementRefs.size) { + return () => null + } - const anchorElement = activeAnchor ?? anchorById ?? providerActiveAnchor.current + const anchorElement = activeAnchor ?? anchorById ?? providerActiveAnchor.current - const observerCallback: MutationCallback = (mutationList) => { - mutationList.forEach((mutation) => { - if ( - !anchorElement || - mutation.type !== 'attributes' || - !mutation.attributeName?.startsWith('data-tooltip-') - ) { - return - } - // make sure to get all set attributes, since all unset attributes are reset + const observerCallback: MutationCallback = (mutationList) => { + mutationList.forEach((mutation) => { + if ( + !anchorElement || + mutation.type !== 'attributes' || + !mutation.attributeName?.startsWith('data-tooltip-') + ) { + return + } + // make sure to get all set attributes, since all unset attributes are reset + const dataAttributes = getDataAttributesFromAnchorElement(anchorElement) + applyAllDataAttributesFromAnchorElement(dataAttributes) + }) + } + + // Create an observer instance linked to the callback function + const observer = new MutationObserver(observerCallback) + + // do not check for subtree and childrens, we only want to know attribute changes + // to stay watching `data-attributes-*` from anchor element + const observerConfig = { attributes: true, childList: false, subtree: false } + + if (anchorElement) { const dataAttributes = getDataAttributesFromAnchorElement(anchorElement) applyAllDataAttributesFromAnchorElement(dataAttributes) - }) - } + // Start observing the target node for configured mutations + observer.observe(anchorElement, observerConfig) + } - // Create an observer instance linked to the callback function - const observer = new MutationObserver(observerCallback) + return () => { + // Remove the observer when the tooltip is destroyed + observer.disconnect() + } + }, [anchorRefs, providerActiveAnchor, activeAnchor, anchorId, anchorSelect]) - // do not check for subtree and childrens, we only want to know attribute changes - // to stay watching `data-attributes-*` from anchor element - const observerConfig = { attributes: true, childList: false, subtree: false } + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + return + } + if (style?.border) { + // eslint-disable-next-line no-console + console.warn('[react-tooltip] Do not set `style.border`. Use `border` prop instead.') + } + if (border && !cssSupports('border', `${border}`)) { + // eslint-disable-next-line no-console + console.warn(`[react-tooltip] "${border}" is not a valid \`border\`.`) + } + if (style?.opacity) { + // eslint-disable-next-line no-console + console.warn('[react-tooltip] Do not set `style.opacity`. Use `opacity` prop instead.') + } + if (opacity && !cssSupports('opacity', `${opacity}`)) { + // eslint-disable-next-line no-console + console.warn(`[react-tooltip] "${opacity}" is not a valid \`opacity\`.`) + } + }, []) - if (anchorElement) { - const dataAttributes = getDataAttributesFromAnchorElement(anchorElement) - applyAllDataAttributesFromAnchorElement(dataAttributes) - // Start observing the target node for configured mutations - observer.observe(anchorElement, observerConfig) + /** + * content priority: children < render or content < html + * children should be lower priority so that it can be used as the "default" content + */ + let renderedContent: ChildrenType = children + const contentWrapperRef = useRef(null) + if (render) { + const rendered = render({ content: tooltipContent ?? null, activeAnchor }) as React.ReactNode + renderedContent = rendered ? ( +
+ {rendered} +
+ ) : null + } else if (tooltipContent) { + renderedContent = tooltipContent } - - return () => { - // Remove the observer when the tooltip is destroyed - observer.disconnect() + if (tooltipHtml) { + renderedContent = } - }, [anchorRefs, providerActiveAnchor, activeAnchor, anchorId, anchorSelect]) - useEffect(() => { - if (process.env.NODE_ENV === 'production') { - return - } - if (style?.border) { - // eslint-disable-next-line no-console - console.warn('[react-tooltip] Do not set `style.border`. Use `border` prop instead.') + const props: ITooltip = { + forwardRef: ref, + id, + anchorId, + anchorSelect, + className, + classNameArrow, + content: renderedContent, + contentWrapperRef, + place: tooltipPlace, + variant: tooltipVariant, + offset: tooltipOffset, + wrapper: tooltipWrapper, + events: tooltipEvents, + openOnClick, + positionStrategy: tooltipPositionStrategy, + middlewares, + delayShow: tooltipDelayShow, + delayHide: tooltipDelayHide, + float: tooltipFloat, + hidden: tooltipHidden, + noArrow, + clickable, + closeOnEsc, + closeOnScroll, + closeOnResize, + openEvents, + closeEvents, + globalCloseEvents, + imperativeModeOnly, + style, + position, + isOpen, + border, + opacity, + arrowColor, + setIsOpen, + afterShow, + afterHide, + activeAnchor, + setActiveAnchor: (anchor: HTMLElement | null) => setActiveAnchor(anchor), } - if (border && !cssSupports('border', `${border}`)) { - // eslint-disable-next-line no-console - console.warn(`[react-tooltip] "${border}" is not a valid \`border\`.`) - } - if (style?.opacity) { - // eslint-disable-next-line no-console - console.warn('[react-tooltip] Do not set `style.opacity`. Use `opacity` prop instead.') - } - if (opacity && !cssSupports('opacity', `${opacity}`)) { - // eslint-disable-next-line no-console - console.warn(`[react-tooltip] "${opacity}" is not a valid \`opacity\`.`) - } - }, []) - - /** - * content priority: children < render or content < html - * children should be lower priority so that it can be used as the "default" content - */ - let renderedContent: ChildrenType = children - const contentWrapperRef = useRef(null) - if (render) { - const rendered = render({ content: tooltipContent ?? null, activeAnchor }) as React.ReactNode - renderedContent = rendered ? ( -
- {rendered} -
- ) : null - } else if (tooltipContent) { - renderedContent = tooltipContent - } - if (tooltipHtml) { - renderedContent = - } - - const props: ITooltip = { - id, - anchorId, - anchorSelect, - className, - classNameArrow, - content: renderedContent, - contentWrapperRef, - place: tooltipPlace, - variant: tooltipVariant, - offset: tooltipOffset, - wrapper: tooltipWrapper, - events: tooltipEvents, - openOnClick, - positionStrategy: tooltipPositionStrategy, - middlewares, - delayShow: tooltipDelayShow, - delayHide: tooltipDelayHide, - float: tooltipFloat, - hidden: tooltipHidden, - noArrow, - clickable, - closeOnEsc, - closeOnScroll, - closeOnResize, - openEvents, - closeEvents, - globalCloseEvents, - style, - position, - isOpen, - border, - opacity, - arrowColor, - setIsOpen, - afterShow, - afterHide, - activeAnchor, - setActiveAnchor: (anchor: HTMLElement | null) => setActiveAnchor(anchor), - } - return -} + return + }, +) export default TooltipController diff --git a/src/components/TooltipController/TooltipControllerTypes.d.ts b/src/components/TooltipController/TooltipControllerTypes.d.ts index deeb2afc..101463eb 100644 --- a/src/components/TooltipController/TooltipControllerTypes.d.ts +++ b/src/components/TooltipController/TooltipControllerTypes.d.ts @@ -72,6 +72,11 @@ export interface ITooltipController { * @description The global events listened to close the tooltip. */ globalCloseEvents?: GlobalCloseEvents + /** + * @description Used to disable default tooltip behavior. + * Overrides `openEvents`, `closeEvents`, and `globalCloseEvents`. + */ + imperativeModeOnly?: boolean style?: CSSProperties position?: IPosition isOpen?: boolean diff --git a/src/index.tsx b/src/index.tsx index 6dc35544..c11ef9cf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,7 @@ import type { WrapperType, IPosition, Middleware, + TooltipRefProps, } from './components/Tooltip/TooltipTypes' import type { ITooltipController } from './components/TooltipController/TooltipControllerTypes' import type { ITooltipWrapper } from './components/TooltipProvider/TooltipProviderTypes' @@ -47,6 +48,7 @@ export type { ITooltipWrapper, IPosition, Middleware, + TooltipRefProps, } export { removeStyle } from './utils/handle-style'