react-tooltip 5.1.0 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/react-tooltip.cjs.js +1 -1
  2. package/dist/react-tooltip.cjs.min.js +1 -1
  3. package/dist/react-tooltip.d.ts +2 -3
  4. package/dist/react-tooltip.esm.js +1 -1
  5. package/dist/react-tooltip.esm.min.js +1 -1
  6. package/dist/react-tooltip.umd.js +1 -1
  7. package/dist/react-tooltip.umd.min.js +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +119 -0
  10. package/src/components/Tooltip/Tooltip.tsx +226 -0
  11. package/src/components/Tooltip/TooltipTypes.d.ts +47 -0
  12. package/src/components/Tooltip/index.ts +1 -0
  13. package/src/components/Tooltip/styles.module.css +62 -0
  14. package/src/components/TooltipContent/TooltipContent.tsx +8 -0
  15. package/src/components/TooltipContent/TooltipContentTypes.d.ts +3 -0
  16. package/src/components/TooltipContent/index.ts +1 -0
  17. package/src/components/TooltipController/TooltipController.tsx +187 -0
  18. package/src/components/TooltipController/TooltipControllerTypes.d.ts +46 -0
  19. package/src/components/TooltipController/index.ts +1 -0
  20. package/src/components/TooltipProvider/TooltipProvider.tsx +110 -0
  21. package/src/components/TooltipProvider/TooltipProviderTypes.d.ts +33 -0
  22. package/src/components/TooltipProvider/TooltipWrapper.tsx +48 -0
  23. package/src/components/TooltipProvider/index.ts +2 -0
  24. package/src/index-dev.tsx +16 -0
  25. package/src/index.tsx +4 -0
  26. package/src/styles.module.css +5 -0
  27. package/src/test/__snapshots__/index.spec.js.snap +102 -0
  28. package/src/test/index.spec.js +143 -0
  29. package/src/tokens.css +8 -0
  30. package/src/utils/compute-positions-types.d.ts +8 -0
  31. package/src/utils/compute-positions.ts +65 -0
  32. package/src/utils/debounce.ts +27 -0
@@ -0,0 +1,226 @@
1
+ import { useEffect, useState, useRef } from 'react'
2
+ import classNames from 'classnames'
3
+ import debounce from 'utils/debounce'
4
+ import { TooltipContent } from 'components/TooltipContent'
5
+ import { useTooltip } from 'components/TooltipProvider'
6
+ import { computeTooltipPosition } from '../../utils/compute-positions'
7
+ import styles from './styles.module.css'
8
+ import type { ITooltip } from './TooltipTypes'
9
+
10
+ const Tooltip = ({
11
+ // props
12
+ id,
13
+ className,
14
+ classNameArrow,
15
+ variant = 'dark',
16
+ anchorId,
17
+ place = 'top',
18
+ offset = 10,
19
+ events = ['hover'],
20
+ positionStrategy = 'absolute',
21
+ wrapper: WrapperElement = 'div',
22
+ children = null,
23
+ delayShow = 0,
24
+ delayHide = 0,
25
+ style: externalStyles,
26
+ // props handled by controller
27
+ isHtmlContent = false,
28
+ content,
29
+ isOpen,
30
+ setIsOpen,
31
+ }: ITooltip) => {
32
+ const tooltipRef = useRef<HTMLElement>(null)
33
+ const tooltipArrowRef = useRef<HTMLDivElement>(null)
34
+ const tooltipShowDelayTimerRef = useRef<NodeJS.Timeout | null>(null)
35
+ const tooltipHideDelayTimerRef = useRef<NodeJS.Timeout | null>(null)
36
+ const [inlineStyles, setInlineStyles] = useState({})
37
+ const [inlineArrowStyles, setInlineArrowStyles] = useState({})
38
+ const [show, setShow] = useState<boolean>(false)
39
+ const [calculatingPosition, setCalculatingPosition] = useState(false)
40
+ const { anchorRefs, setActiveAnchor: setProviderActiveAnchor } = useTooltip()(id)
41
+ const [activeAnchor, setActiveAnchor] = useState<React.RefObject<HTMLElement>>({ current: null })
42
+
43
+ const handleShow = (value: boolean) => {
44
+ if (setIsOpen) {
45
+ setIsOpen(value)
46
+ } else if (isOpen === undefined) {
47
+ setShow(value)
48
+ }
49
+ }
50
+
51
+ const handleShowTooltipDelayed = () => {
52
+ if (tooltipShowDelayTimerRef.current) {
53
+ clearTimeout(tooltipShowDelayTimerRef.current)
54
+ }
55
+
56
+ tooltipShowDelayTimerRef.current = setTimeout(() => {
57
+ handleShow(true)
58
+ }, delayShow)
59
+ }
60
+
61
+ const handleHideTooltipDelayed = () => {
62
+ if (tooltipHideDelayTimerRef.current) {
63
+ clearTimeout(tooltipHideDelayTimerRef.current)
64
+ }
65
+
66
+ tooltipHideDelayTimerRef.current = setTimeout(() => {
67
+ handleShow(false)
68
+ }, delayHide)
69
+ }
70
+
71
+ const handleShowTooltip = (event?: Event) => {
72
+ if (!event) {
73
+ return
74
+ }
75
+ if (delayShow) {
76
+ handleShowTooltipDelayed()
77
+ } else {
78
+ handleShow(true)
79
+ }
80
+ setActiveAnchor((anchor) =>
81
+ anchor.current === event.target ? anchor : { current: event.target as HTMLElement },
82
+ )
83
+ setProviderActiveAnchor({ current: event.target as HTMLElement })
84
+
85
+ if (tooltipHideDelayTimerRef.current) {
86
+ clearTimeout(tooltipHideDelayTimerRef.current)
87
+ }
88
+ }
89
+
90
+ const handleHideTooltip = () => {
91
+ if (delayHide) {
92
+ handleHideTooltipDelayed()
93
+ } else {
94
+ handleShow(false)
95
+ }
96
+
97
+ if (tooltipShowDelayTimerRef.current) {
98
+ clearTimeout(tooltipShowDelayTimerRef.current)
99
+ }
100
+ }
101
+
102
+ const handleClickTooltipAnchor = () => {
103
+ if (setIsOpen) {
104
+ setIsOpen(!isOpen)
105
+ } else if (isOpen === undefined) {
106
+ setShow((currentValue) => !currentValue)
107
+ }
108
+ }
109
+
110
+ // debounce handler to prevent call twice when
111
+ // mouse enter and focus events being triggered toggether
112
+ const debouncedHandleShowTooltip = debounce(handleShowTooltip, 50)
113
+ const debouncedHandleHideTooltip = debounce(handleHideTooltip, 50)
114
+
115
+ useEffect(() => {
116
+ const elementRefs = new Set(anchorRefs)
117
+
118
+ const anchorById = document.querySelector(`[id='${anchorId}']`) as HTMLElement
119
+ if (anchorById) {
120
+ setActiveAnchor((anchor) =>
121
+ anchor.current === anchorById ? anchor : { current: anchorById },
122
+ )
123
+ elementRefs.add({ current: anchorById })
124
+ }
125
+
126
+ if (!elementRefs.size) {
127
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
128
+ return () => {}
129
+ }
130
+
131
+ const enabledEvents: { event: string; listener: (event?: Event) => void }[] = []
132
+
133
+ if (events.find((event: string) => event === 'click')) {
134
+ enabledEvents.push({ event: 'click', listener: handleClickTooltipAnchor })
135
+ }
136
+
137
+ if (events.find((event: string) => event === 'hover')) {
138
+ enabledEvents.push(
139
+ { event: 'mouseenter', listener: debouncedHandleShowTooltip },
140
+ { event: 'mouseleave', listener: debouncedHandleHideTooltip },
141
+ { event: 'focus', listener: debouncedHandleShowTooltip },
142
+ { event: 'blur', listener: debouncedHandleHideTooltip },
143
+ )
144
+ }
145
+
146
+ enabledEvents.forEach(({ event, listener }) => {
147
+ elementRefs.forEach((ref) => {
148
+ ref.current?.addEventListener(event, listener)
149
+ })
150
+ })
151
+
152
+ return () => {
153
+ enabledEvents.forEach(({ event, listener }) => {
154
+ elementRefs.forEach((ref) => {
155
+ ref.current?.removeEventListener(event, listener)
156
+ })
157
+ })
158
+ }
159
+ }, [anchorRefs, anchorId, events, delayHide, delayShow])
160
+
161
+ useEffect(() => {
162
+ let elementReference = activeAnchor.current
163
+ if (anchorId) {
164
+ // `anchorId` element takes precedence
165
+ elementReference = document.querySelector(`[id='${anchorId}']`) as HTMLElement
166
+ }
167
+ setCalculatingPosition(true)
168
+ let mounted = true
169
+ computeTooltipPosition({
170
+ place,
171
+ offset,
172
+ elementReference,
173
+ tooltipReference: tooltipRef.current,
174
+ tooltipArrowReference: tooltipArrowRef.current,
175
+ strategy: positionStrategy,
176
+ }).then((computedStylesData) => {
177
+ if (!mounted) {
178
+ // invalidate computed positions after remount
179
+ return
180
+ }
181
+ setCalculatingPosition(false)
182
+ if (Object.keys(computedStylesData.tooltipStyles).length) {
183
+ setInlineStyles(computedStylesData.tooltipStyles)
184
+ }
185
+ if (Object.keys(computedStylesData.tooltipArrowStyles).length) {
186
+ setInlineArrowStyles(computedStylesData.tooltipArrowStyles)
187
+ }
188
+ })
189
+ return () => {
190
+ mounted = false
191
+ }
192
+ }, [show, isOpen, anchorId, activeAnchor, content, place, offset, positionStrategy])
193
+
194
+ useEffect(() => {
195
+ return () => {
196
+ if (tooltipShowDelayTimerRef.current) {
197
+ clearTimeout(tooltipShowDelayTimerRef.current)
198
+ }
199
+ if (tooltipHideDelayTimerRef.current) {
200
+ clearTimeout(tooltipHideDelayTimerRef.current)
201
+ }
202
+ }
203
+ }, [])
204
+
205
+ return (
206
+ <WrapperElement
207
+ id={id}
208
+ role="tooltip"
209
+ className={classNames(styles['tooltip'], styles[variant], className, {
210
+ [styles['show']]: !calculatingPosition && (isOpen || show),
211
+ [styles['fixed']]: positionStrategy === 'fixed',
212
+ })}
213
+ style={{ ...externalStyles, ...inlineStyles }}
214
+ ref={tooltipRef}
215
+ >
216
+ {children || (isHtmlContent ? <TooltipContent content={content as string} /> : content)}
217
+ <div
218
+ className={classNames(styles['arrow'], classNameArrow)}
219
+ style={inlineArrowStyles}
220
+ ref={tooltipArrowRef}
221
+ />
222
+ </WrapperElement>
223
+ )
224
+ }
225
+
226
+ export default Tooltip
@@ -0,0 +1,47 @@
1
+ import type { ElementType, ReactNode, Element, CSSProperties } from 'react'
2
+
3
+ export type PlacesType = 'top' | 'right' | 'bottom' | 'left'
4
+
5
+ export type VariantType = 'dark' | 'light' | 'success' | 'warning' | 'error' | 'info'
6
+
7
+ export type WrapperType = ElementType | 'div' | 'span'
8
+
9
+ export type ChildrenType = Element | ElementType | ReactNode
10
+
11
+ export type EventsType = 'hover' | 'click'
12
+
13
+ export type PositionStrategy = 'absolute' | 'fixed'
14
+
15
+ export type DataAttribute =
16
+ | 'place'
17
+ | 'content'
18
+ | 'html'
19
+ | 'variant'
20
+ | 'offset'
21
+ | 'wrapper'
22
+ | 'events'
23
+ | 'position-strategy'
24
+ | 'delay-show'
25
+ | 'delay-hide'
26
+
27
+ export interface ITooltip {
28
+ className?: string
29
+ classNameArrow?: string
30
+ content?: string
31
+ html?: string
32
+ place?: PlacesType
33
+ offset?: number
34
+ id?: string
35
+ variant?: VariantType
36
+ anchorId?: string
37
+ isHtmlContent?: boolean
38
+ wrapper?: WrapperType
39
+ children?: ChildrenType
40
+ events?: EventsType[]
41
+ positionStrategy?: PositionStrategy
42
+ delayShow?: number
43
+ delayHide?: number
44
+ style?: CSSProperties
45
+ isOpen?: boolean
46
+ setIsOpen?: (value: boolean) => void
47
+ }
@@ -0,0 +1 @@
1
+ export { default as Tooltip } from './Tooltip'
@@ -0,0 +1,62 @@
1
+ .tooltip {
2
+ visibility: hidden;
3
+ width: max-content;
4
+ position: absolute;
5
+ top: 0;
6
+ left: 0;
7
+ padding: 8px 16px;
8
+ border-radius: 3px;
9
+ font-size: 90%;
10
+ pointer-events: none;
11
+ opacity: 0;
12
+ transition: opacity 0.3s ease-out;
13
+ will-change: opacity, visibility;
14
+ }
15
+
16
+ .fixed {
17
+ position: fixed;
18
+ }
19
+
20
+ .arrow {
21
+ position: absolute;
22
+ background: inherit;
23
+ width: 8px;
24
+ height: 8px;
25
+ transform: rotate(45deg);
26
+ }
27
+
28
+ .show {
29
+ visibility: visible;
30
+ opacity: 0.9;
31
+ }
32
+
33
+ /** Types variant **/
34
+ .dark {
35
+ background: var(--rt-color-dark);
36
+ color: var(--rt-color-white);
37
+ }
38
+
39
+ .light {
40
+ background-color: var(--rt-color-white);
41
+ color: var(--rt-color-dark);
42
+ }
43
+
44
+ .success {
45
+ background-color: var(--rt-color-success);
46
+ color: var(--rt-color-white);
47
+ }
48
+
49
+ .warning {
50
+ background-color: var(--rt-color-warning);
51
+ color: var(--rt-color-white);
52
+ }
53
+
54
+ .error {
55
+ background-color: var(--rt-color-error);
56
+ color: var(--rt-color-white);
57
+ }
58
+
59
+ .info {
60
+ background-color: var(--rt-color-info);
61
+ color: var(--rt-color-white);
62
+ }
@@ -0,0 +1,8 @@
1
+ /* eslint-disable react/no-danger */
2
+ import type { ITooltipContent } from './TooltipContentTypes'
3
+
4
+ const TooltipContent = ({ content }: ITooltipContent) => {
5
+ return <span dangerouslySetInnerHTML={{ __html: content }} />
6
+ }
7
+
8
+ export default TooltipContent
@@ -0,0 +1,3 @@
1
+ export interface ITooltipContent {
2
+ content: string
3
+ }
@@ -0,0 +1 @@
1
+ export { default as TooltipContent } from './TooltipContent'
@@ -0,0 +1,187 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Tooltip } from 'components/Tooltip'
3
+ import type {
4
+ EventsType,
5
+ PositionStrategy,
6
+ PlacesType,
7
+ VariantType,
8
+ WrapperType,
9
+ DataAttribute,
10
+ ITooltip,
11
+ } from 'components/Tooltip/TooltipTypes'
12
+ import { useTooltip } from 'components/TooltipProvider'
13
+ import type { ITooltipController } from './TooltipControllerTypes'
14
+
15
+ const TooltipController = ({
16
+ id,
17
+ anchorId,
18
+ content,
19
+ html,
20
+ className,
21
+ classNameArrow,
22
+ variant = 'dark',
23
+ place = 'top',
24
+ offset = 10,
25
+ wrapper = 'div',
26
+ children = null,
27
+ events = ['hover'],
28
+ positionStrategy = 'absolute',
29
+ delayShow = 0,
30
+ delayHide = 0,
31
+ style,
32
+ isOpen,
33
+ setIsOpen,
34
+ }: ITooltipController) => {
35
+ const [tooltipContent, setTooltipContent] = useState(content || html)
36
+ const [tooltipPlace, setTooltipPlace] = useState(place)
37
+ const [tooltipVariant, setTooltipVariant] = useState(variant)
38
+ const [tooltipOffset, setTooltipOffset] = useState(offset)
39
+ const [tooltipDelayShow, setTooltipDelayShow] = useState(delayShow)
40
+ const [tooltipDelayHide, setTooltipDelayHide] = useState(delayHide)
41
+ const [tooltipWrapper, setTooltipWrapper] = useState<WrapperType>(wrapper)
42
+ const [tooltipEvents, setTooltipEvents] = useState(events)
43
+ const [tooltipPositionStrategy, setTooltipPositionStrategy] = useState(positionStrategy)
44
+ const [isHtmlContent, setIsHtmlContent] = useState(Boolean(html))
45
+ const { anchorRefs, activeAnchor } = useTooltip()(id)
46
+
47
+ const getDataAttributesFromAnchorElement = (elementReference: HTMLElement) => {
48
+ const dataAttributes = elementReference?.getAttributeNames().reduce((acc, name) => {
49
+ if (name.startsWith('data-tooltip-')) {
50
+ const parsedAttribute = name.replace(/^data-tooltip-/, '') as DataAttribute
51
+ acc[parsedAttribute] = elementReference?.getAttribute(name) ?? null
52
+ }
53
+ return acc
54
+ }, {} as Record<DataAttribute, string | null>)
55
+
56
+ return dataAttributes
57
+ }
58
+
59
+ const applyAllDataAttributesFromAnchorElement = (
60
+ dataAttributes: Record<string, string | null>,
61
+ ) => {
62
+ const handleDataAttributes: Record<DataAttribute, (value: string | null) => void> = {
63
+ place: (value) => {
64
+ setTooltipPlace((value as PlacesType) ?? place)
65
+ },
66
+ content: (value) => {
67
+ setIsHtmlContent(false)
68
+ setTooltipContent(value ?? content)
69
+ },
70
+ html: (value) => {
71
+ setIsHtmlContent(!!value)
72
+ setTooltipContent(value ?? html ?? content)
73
+ },
74
+ variant: (value) => {
75
+ setTooltipVariant((value as VariantType) ?? variant)
76
+ },
77
+ offset: (value) => {
78
+ setTooltipOffset(value === null ? offset : Number(value))
79
+ },
80
+ wrapper: (value) => {
81
+ setTooltipWrapper((value as WrapperType) ?? 'div')
82
+ },
83
+ events: (value) => {
84
+ const parsed = value?.split(' ') as EventsType[]
85
+ setTooltipEvents(parsed ?? events)
86
+ },
87
+ 'position-strategy': (value) => {
88
+ setTooltipPositionStrategy((value as PositionStrategy) ?? positionStrategy)
89
+ },
90
+ 'delay-show': (value) => {
91
+ setTooltipDelayShow(value === null ? delayShow : Number(value))
92
+ },
93
+ 'delay-hide': (value) => {
94
+ setTooltipDelayHide(value === null ? delayHide : Number(value))
95
+ },
96
+ }
97
+ // reset unset data attributes to default values
98
+ // without this, data attributes from the last active anchor will still be used
99
+ Object.values(handleDataAttributes).forEach((handler) => handler(null))
100
+ Object.entries(dataAttributes).forEach(([key, value]) => {
101
+ handleDataAttributes[key as DataAttribute]?.(value)
102
+ })
103
+ }
104
+
105
+ useEffect(() => {
106
+ if (content) {
107
+ setTooltipContent(content)
108
+ }
109
+ if (html) {
110
+ setTooltipContent(html)
111
+ }
112
+ }, [content, html])
113
+
114
+ useEffect(() => {
115
+ const elementRefs = new Set(anchorRefs)
116
+
117
+ const anchorById = document.querySelector(`[id='${anchorId}']`) as HTMLElement
118
+ if (anchorById) {
119
+ elementRefs.add({ current: anchorById })
120
+ }
121
+
122
+ if (!elementRefs.size) {
123
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
124
+ return () => {}
125
+ }
126
+
127
+ const observerCallback: MutationCallback = (mutationList) => {
128
+ mutationList.forEach((mutation) => {
129
+ if (
130
+ !activeAnchor.current ||
131
+ mutation.type !== 'attributes' ||
132
+ !mutation.attributeName?.startsWith('data-tooltip-')
133
+ ) {
134
+ return
135
+ }
136
+ // make sure to get all set attributes, since all unset attributes are reset
137
+ const dataAttributes = getDataAttributesFromAnchorElement(activeAnchor.current)
138
+ applyAllDataAttributesFromAnchorElement(dataAttributes)
139
+ })
140
+ }
141
+
142
+ // Create an observer instance linked to the callback function
143
+ const observer = new MutationObserver(observerCallback)
144
+
145
+ // do not check for subtree and childrens, we only want to know attribute changes
146
+ // to stay watching `data-attributes-*` from anchor element
147
+ const observerConfig = { attributes: true, childList: false, subtree: false }
148
+
149
+ const element = activeAnchor.current ?? anchorById
150
+
151
+ if (element) {
152
+ const dataAttributes = getDataAttributesFromAnchorElement(element)
153
+ applyAllDataAttributesFromAnchorElement(dataAttributes)
154
+ // Start observing the target node for configured mutations
155
+ observer.observe(element, observerConfig)
156
+ }
157
+
158
+ return () => {
159
+ // Remove the observer when the tooltip is destroyed
160
+ observer.disconnect()
161
+ }
162
+ }, [anchorRefs, activeAnchor, anchorId])
163
+
164
+ const props: ITooltip = {
165
+ id,
166
+ anchorId,
167
+ className,
168
+ classNameArrow,
169
+ content: tooltipContent,
170
+ isHtmlContent,
171
+ place: tooltipPlace,
172
+ variant: tooltipVariant,
173
+ offset: tooltipOffset,
174
+ wrapper: tooltipWrapper,
175
+ events: tooltipEvents,
176
+ positionStrategy: tooltipPositionStrategy,
177
+ delayShow: tooltipDelayShow,
178
+ delayHide: tooltipDelayHide,
179
+ style,
180
+ isOpen,
181
+ setIsOpen,
182
+ }
183
+
184
+ return children ? <Tooltip {...props}>{children}</Tooltip> : <Tooltip {...props} />
185
+ }
186
+
187
+ export default TooltipController
@@ -0,0 +1,46 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ import type {
4
+ PlacesType,
5
+ VariantType,
6
+ WrapperType,
7
+ ChildrenType,
8
+ EventsType,
9
+ PositionStrategy,
10
+ } from 'components/Tooltip/TooltipTypes'
11
+
12
+ export interface ITooltipController {
13
+ className?: string
14
+ classNameArrow?: string
15
+ content?: string
16
+ html?: string
17
+ place?: PlacesType
18
+ offset?: number
19
+ id?: string
20
+ variant?: VariantType
21
+ anchorId?: string
22
+ wrapper?: WrapperType
23
+ children?: ChildrenType
24
+ events?: EventsType[]
25
+ positionStrategy?: PositionStrategy
26
+ delayShow?: number
27
+ delayHide?: number
28
+ style?: CSSProperties
29
+ isOpen?: boolean
30
+ setIsOpen?: (value: boolean) => void
31
+ }
32
+
33
+ declare module 'react' {
34
+ interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
35
+ 'data-tooltip-place'?: PlacesType
36
+ 'data-tooltip-content'?: string
37
+ 'data-tooltip-html'?: string
38
+ 'data-tooltip-variant'?: VariantType
39
+ 'data-tooltip-offset'?: number
40
+ 'data-tooltip-wrapper'?: WrapperType
41
+ 'data-tooltip-events'?: EventsType[]
42
+ 'data-tooltip-position-strategy'?: PositionStrategy
43
+ 'data-tooltip-delay-show'?: number
44
+ 'data-tooltip-delay-hide'?: number
45
+ }
46
+ }
@@ -0,0 +1 @@
1
+ export { default as TooltipController } from './TooltipController'
@@ -0,0 +1,110 @@
1
+ import React, {
2
+ createContext,
3
+ PropsWithChildren,
4
+ useCallback,
5
+ useContext,
6
+ useId,
7
+ useMemo,
8
+ useState,
9
+ } from 'react'
10
+
11
+ import type {
12
+ AnchorRef,
13
+ TooltipContextData,
14
+ TooltipContextDataWrapper,
15
+ } from './TooltipProviderTypes'
16
+
17
+ const defaultContextData: TooltipContextData = {
18
+ anchorRefs: new Set(),
19
+ activeAnchor: { current: null },
20
+ attach: () => {
21
+ /* attach anchor element */
22
+ },
23
+ detach: () => {
24
+ /* detach anchor element */
25
+ },
26
+ setActiveAnchor: () => {
27
+ /* set active anchor */
28
+ },
29
+ }
30
+
31
+ const defaultContextWrapper = Object.assign(() => defaultContextData, defaultContextData)
32
+ const TooltipContext = createContext<TooltipContextDataWrapper>(defaultContextWrapper)
33
+
34
+ const TooltipProvider: React.FC<PropsWithChildren> = ({ children }) => {
35
+ const defaultTooltipId = useId()
36
+ const [anchorRefMap, setAnchorRefMap] = useState<Record<string, Set<AnchorRef>>>({
37
+ [defaultTooltipId]: new Set(),
38
+ })
39
+ const [activeAnchorMap, setActiveAnchorMap] = useState<Record<string, AnchorRef>>({
40
+ [defaultTooltipId]: { current: null },
41
+ })
42
+
43
+ const attach = (tooltipId: string, ...refs: AnchorRef[]) => {
44
+ setAnchorRefMap((oldMap) => {
45
+ const tooltipRefs = oldMap[tooltipId] ?? new Set()
46
+ refs.forEach((ref) => tooltipRefs.add(ref))
47
+ // create new object to trigger re-render
48
+ return { ...oldMap, [tooltipId]: new Set(tooltipRefs) }
49
+ })
50
+ }
51
+
52
+ const detach = (tooltipId: string, ...refs: AnchorRef[]) => {
53
+ setAnchorRefMap((oldMap) => {
54
+ const tooltipRefs = oldMap[tooltipId]
55
+ if (!tooltipRefs) {
56
+ // tooltip not found
57
+ // maybe thow error?
58
+ return oldMap
59
+ }
60
+ refs.forEach((ref) => tooltipRefs.delete(ref))
61
+ // create new object to trigger re-render
62
+ return { ...oldMap }
63
+ })
64
+ }
65
+
66
+ const setActiveAnchor = (tooltipId: string, ref: React.RefObject<HTMLElement>) => {
67
+ setActiveAnchorMap((oldMap) => {
68
+ if (oldMap[tooltipId]?.current === ref.current) {
69
+ return oldMap
70
+ }
71
+ // create new object to trigger re-render
72
+ return { ...oldMap, [tooltipId]: ref }
73
+ })
74
+ }
75
+
76
+ const getTooltipData = useCallback(
77
+ (tooltipId?: string) => ({
78
+ anchorRefs: anchorRefMap[tooltipId ?? defaultTooltipId] ?? new Set(),
79
+ activeAnchor: activeAnchorMap[tooltipId ?? defaultTooltipId] ?? { current: null },
80
+ attach: (...refs: AnchorRef[]) => attach(tooltipId ?? defaultTooltipId, ...refs),
81
+ detach: (...refs: AnchorRef[]) => detach(tooltipId ?? defaultTooltipId, ...refs),
82
+ setActiveAnchor: (ref: AnchorRef) => setActiveAnchor(tooltipId ?? defaultTooltipId, ref),
83
+ }),
84
+ [defaultTooltipId, anchorRefMap, activeAnchorMap, attach, detach],
85
+ )
86
+
87
+ const context = useMemo(() => {
88
+ const contextData: TooltipContextData = getTooltipData(defaultTooltipId)
89
+ const contextWrapper = Object.assign(
90
+ (tooltipId?: string) => getTooltipData(tooltipId),
91
+ contextData,
92
+ )
93
+ return contextWrapper
94
+ }, [getTooltipData])
95
+
96
+ return <TooltipContext.Provider value={context}>{children}</TooltipContext.Provider>
97
+ }
98
+
99
+ /*
100
+ // this will use the "global" tooltip (same as `useTooltip()()`)
101
+ const { anchorRefs, attach, detach } = useTooltip()
102
+
103
+ // this will use the tooltip with id `tooltip-id`
104
+ const { anchorRefs, attach, detach } = useTooltip()('tooltip-id')
105
+ */
106
+ export function useTooltip() {
107
+ return useContext(TooltipContext)
108
+ }
109
+
110
+ export default TooltipProvider