react-tooltip 5.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-tooltip",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
4
4
  "description": "react tooltip component",
5
5
  "scripts": {
6
6
  "dev": "node ./cli.js --env=development && node --max_old_space_size=2048 ./node_modules/rollup/dist/bin/rollup -c rollup.config.dev.js --watch",
package/src/App.tsx ADDED
@@ -0,0 +1,119 @@
1
+ import { TooltipController as Tooltip } from 'components/TooltipController'
2
+ import { TooltipProvider, TooltipWrapper } from 'components/TooltipProvider'
3
+ import { useState } from 'react'
4
+ import styles from './styles.module.css'
5
+
6
+ function WithProviderMinimal() {
7
+ return (
8
+ <section style={{ marginTop: '100px' }}>
9
+ <p>
10
+ <TooltipWrapper place="bottom" content="Shared Global Tooltip">
11
+ <button>Minimal 1</button>
12
+ </TooltipWrapper>
13
+ <TooltipWrapper place="right" content="Shared Global Tooltip">
14
+ <button>Minimal 2</button>
15
+ </TooltipWrapper>
16
+ </p>
17
+ <Tooltip />
18
+ </section>
19
+ )
20
+ }
21
+
22
+ function WithProviderMultiple() {
23
+ return (
24
+ <section style={{ marginTop: '100px' }}>
25
+ <p>
26
+ <TooltipWrapper tooltipId="tooltip-1" place="bottom">
27
+ <button>Multiple 1</button>
28
+ </TooltipWrapper>
29
+ <TooltipWrapper tooltipId="tooltip-2" place="right">
30
+ <button>Multiple 2</button>
31
+ </TooltipWrapper>
32
+ </p>
33
+ <Tooltip id="tooltip-1" content="Tooltip 1" />
34
+ <Tooltip id="tooltip-2" content="Tooltip 2" />
35
+ </section>
36
+ )
37
+ }
38
+
39
+ function App() {
40
+ const [anchorId, setAnchorId] = useState('button')
41
+ const [isDarkOpen, setIsDarkOpen] = useState(false)
42
+
43
+ return (
44
+ <main className={styles['main']}>
45
+ <button
46
+ id="button"
47
+ aria-describedby="tooltip"
48
+ data-tooltip-content="My big tooltip content 1"
49
+ onClick={() => {
50
+ setAnchorId('button')
51
+ }}
52
+ >
53
+ My button
54
+ </button>
55
+ <Tooltip
56
+ place="bottom"
57
+ anchorId={anchorId}
58
+ // only shown if `data-tooltip-content` is unset
59
+ content={`Showing tooltip on ${anchorId}`}
60
+ isOpen={isDarkOpen}
61
+ setIsOpen={setIsDarkOpen}
62
+ />
63
+ <Tooltip
64
+ place="top"
65
+ variant="success"
66
+ anchorId="button2"
67
+ isOpen={isDarkOpen}
68
+ setIsOpen={setIsDarkOpen}
69
+ />
70
+ <Tooltip
71
+ place="top"
72
+ variant="info"
73
+ anchorId="button3"
74
+ isOpen={isDarkOpen}
75
+ setIsOpen={setIsDarkOpen}
76
+ />
77
+ <Tooltip
78
+ place="right"
79
+ variant="info"
80
+ anchorId="button3"
81
+ content="My big tooltip content"
82
+ isOpen={isDarkOpen}
83
+ setIsOpen={setIsDarkOpen}
84
+ style={{ backgroundColor: '#ff00ff' }}
85
+ />
86
+
87
+ <section style={{ marginTop: '100px' }}>
88
+ <p>
89
+ <button
90
+ id="button2"
91
+ data-tooltip-content="Hello World from a Tooltip 2"
92
+ onClick={() => {
93
+ setAnchorId('button2')
94
+ }}
95
+ >
96
+ Hover or focus me
97
+ </button>
98
+ <button
99
+ id="button3"
100
+ data-tooltip-content="Hello World from a Tooltip 3"
101
+ onClick={() => {
102
+ setAnchorId('button3')
103
+ }}
104
+ >
105
+ Hover or focus me 2
106
+ </button>
107
+ </p>
108
+ </section>
109
+ <TooltipProvider>
110
+ <WithProviderMinimal />
111
+ </TooltipProvider>
112
+ <TooltipProvider>
113
+ <WithProviderMultiple />
114
+ </TooltipProvider>
115
+ </main>
116
+ )
117
+ }
118
+
119
+ export default App
@@ -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
@@ -0,0 +1,33 @@
1
+ import type { ReactNode, RefObject } from 'react'
2
+ import type { ITooltipController } from 'components/TooltipController/TooltipControllerTypes'
3
+
4
+ export type AnchorRef = RefObject<HTMLElement>
5
+
6
+ export interface TooltipContextData {
7
+ anchorRefs: Set<AnchorRef>
8
+ activeAnchor: AnchorRef
9
+ attach: (...refs: AnchorRef[]) => void
10
+ detach: (...refs: AnchorRef[]) => void
11
+ setActiveAnchor: (ref: AnchorRef) => void
12
+ }
13
+
14
+ export type TooltipContextDataWrapper = TooltipContextData & {
15
+ // This means the context is a callable object
16
+ (tooltipId?: string): TooltipContextData
17
+ }
18
+
19
+ export interface ITooltipWrapper {
20
+ tooltipId?: string
21
+ children: ReactNode
22
+
23
+ place?: ITooltipController['place']
24
+ content?: ITooltipController['content']
25
+ html?: ITooltipController['html']
26
+ variant?: ITooltipController['variant']
27
+ offset?: ITooltipController['offset']
28
+ wrapper?: ITooltipController['wrapper']
29
+ events?: ITooltipController['events']
30
+ positionStrategy?: ITooltipController['positionStrategy']
31
+ delayShow?: ITooltipController['delayShow']
32
+ delayHide?: ITooltipController['delayHide']
33
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { useTooltip } from './TooltipProvider'
3
+ import type { ITooltipWrapper } from './TooltipProviderTypes'
4
+
5
+ const TooltipWrapper = ({
6
+ tooltipId,
7
+ children,
8
+ place,
9
+ content,
10
+ html,
11
+ variant,
12
+ offset,
13
+ wrapper,
14
+ events,
15
+ positionStrategy,
16
+ delayShow,
17
+ delayHide,
18
+ }: ITooltipWrapper) => {
19
+ const { attach, detach } = useTooltip()(tooltipId)
20
+ const anchorRef = useRef<HTMLElement | null>(null)
21
+
22
+ useEffect(() => {
23
+ attach(anchorRef)
24
+ return () => {
25
+ detach(anchorRef)
26
+ }
27
+ }, [])
28
+
29
+ return (
30
+ <span
31
+ ref={anchorRef}
32
+ data-tooltip-place={place}
33
+ data-tooltip-content={content}
34
+ data-tooltip-html={html}
35
+ data-tooltip-variant={variant}
36
+ data-tooltip-offset={offset}
37
+ data-tooltip-wrapper={wrapper}
38
+ data-tooltip-events={events}
39
+ data-tooltip-position-strategy={positionStrategy}
40
+ data-tooltip-delay-show={delayShow}
41
+ data-tooltip-delay-hide={delayHide}
42
+ >
43
+ {children}
44
+ </span>
45
+ )
46
+ }
47
+
48
+ export default TooltipWrapper
@@ -0,0 +1,2 @@
1
+ export { default as TooltipProvider, useTooltip } from './TooltipProvider'
2
+ export { default as TooltipWrapper } from './TooltipWrapper'
@@ -0,0 +1,16 @@
1
+ import { StrictMode, version } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './tokens.css'
4
+ import App from './App'
5
+
6
+ // eslint-disable-next-line no-console
7
+ console.log('Parent folder loaded react version: ', version)
8
+
9
+ const container = document.getElementById('app')
10
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
11
+ const root = createRoot(container!)
12
+ root.render(
13
+ <StrictMode>
14
+ <App />
15
+ </StrictMode>,
16
+ )
package/src/index.tsx ADDED
@@ -0,0 +1,4 @@
1
+ import './tokens.css'
2
+
3
+ export { TooltipController as Tooltip } from './components/TooltipController'
4
+ export { TooltipProvider, TooltipWrapper } from './components/TooltipProvider'
@@ -0,0 +1,5 @@
1
+ .main {
2
+ margin: 0 auto;
3
+ width: 100%;
4
+ height: 100vh;
5
+ }
@@ -0,0 +1,102 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`tooltip props basic tooltip component 1`] = `
4
+ [
5
+ <span
6
+ id="basic-example"
7
+ >
8
+ Lorem Ipsum
9
+ </span>,
10
+ <div
11
+ className=""
12
+ role="tooltip"
13
+ style={{}}
14
+ >
15
+ Hello World!
16
+ <div
17
+ className=""
18
+ style={{}}
19
+ />
20
+ </div>,
21
+ ]
22
+ `;
23
+
24
+ exports[`tooltip props tooltip component - getContent 1`] = `
25
+ [
26
+ <span
27
+ id="basic-example-get-content"
28
+ >
29
+ Lorem Ipsum
30
+ </span>,
31
+ <div
32
+ className=""
33
+ role="tooltip"
34
+ style={{}}
35
+ >
36
+ Hello World!
37
+ <div
38
+ className=""
39
+ style={{}}
40
+ />
41
+ </div>,
42
+ ]
43
+ `;
44
+
45
+ exports[`tooltip props tooltip component - html 1`] = `
46
+ [
47
+ <span
48
+ id="basic-example-html"
49
+ >
50
+ Lorem Ipsum
51
+ </span>,
52
+ <div
53
+ className=""
54
+ role="tooltip"
55
+ style={{}}
56
+ >
57
+ <span
58
+ dangerouslySetInnerHTML={
59
+ {
60
+ "__html": "Hello World!",
61
+ }
62
+ }
63
+ />
64
+ <div
65
+ className=""
66
+ style={{}}
67
+ />
68
+ </div>,
69
+ ]
70
+ `;
71
+
72
+ exports[`tooltip props tooltip component - without anchorId 1`] = `
73
+ [
74
+ <span>
75
+ Lorem Ipsum
76
+ </span>,
77
+ <div
78
+ className=""
79
+ role="tooltip"
80
+ style={{}}
81
+ >
82
+ Hello World!
83
+ <div
84
+ className=""
85
+ style={{}}
86
+ />
87
+ </div>,
88
+ ]
89
+ `;
90
+
91
+ exports[`tooltip props tooltip component - without element reference 1`] = `
92
+ <div
93
+ className=""
94
+ role="tooltip"
95
+ style={{}}
96
+ >
97
+ <div
98
+ className=""
99
+ style={{}}
100
+ />
101
+ </div>
102
+ `;
@@ -0,0 +1,143 @@
1
+ import renderer from 'react-test-renderer'
2
+ import debounce from 'utils/debounce'
3
+ import { computeTooltipPosition } from 'utils/compute-positions'
4
+ import { TooltipController as Tooltip } from '../components/TooltipController'
5
+
6
+ // Tell Jest to mock all timeout functions
7
+ jest.useFakeTimers()
8
+
9
+ // eslint-disable-next-line react/prop-types
10
+ const TooltipProps = ({ id, ...tooltipParams }) => (
11
+ <>
12
+ <span id={id}>Lorem Ipsum</span>
13
+ <Tooltip anchorId={id} {...tooltipParams} />
14
+ </>
15
+ )
16
+
17
+ describe('tooltip props', () => {
18
+ test('tooltip component - without anchorId', () => {
19
+ const component = renderer.create(<TooltipProps content="Hello World!" />)
20
+ const tree = component.toJSON()
21
+ expect(tree).toMatchSnapshot()
22
+ })
23
+
24
+ test('tooltip component - without element reference', () => {
25
+ const component = renderer.create(<Tooltip />)
26
+ const tree = component.toJSON()
27
+ expect(tree).toMatchSnapshot()
28
+ })
29
+
30
+ test('basic tooltip component', () => {
31
+ const component = renderer.create(<TooltipProps id="basic-example" content="Hello World!" />)
32
+ const tree = component.toJSON()
33
+ expect(tree).toMatchSnapshot()
34
+ })
35
+
36
+ test('tooltip component - html', () => {
37
+ const component = renderer.create(
38
+ <TooltipProps id="basic-example-html" html="Hello World!" variant="info" place="top" />,
39
+ )
40
+ const tree = component.toJSON()
41
+ expect(tree).toMatchSnapshot()
42
+ })
43
+
44
+ test('tooltip component - getContent', () => {
45
+ const component = renderer.create(
46
+ <TooltipProps
47
+ id="basic-example-get-content"
48
+ content="Hello World!"
49
+ getContent={(value) => `${value} Manipuled!`}
50
+ variant="info"
51
+ place="top"
52
+ />,
53
+ )
54
+ const tree = component.toJSON()
55
+ expect(tree).toMatchSnapshot()
56
+ })
57
+ })
58
+
59
+ describe('compute positions', () => {
60
+ test('empty reference elements', async () => {
61
+ const value = await computeTooltipPosition({
62
+ elementReference: null,
63
+ tooltipReference: null,
64
+ tooltipArrowReference: null,
65
+ })
66
+
67
+ expect(value).toEqual({ tooltipStyles: {}, tooltipArrowStyles: {} })
68
+ })
69
+
70
+ test('empty tooltip reference element', async () => {
71
+ const element = document.createElement('div')
72
+ const value = await computeTooltipPosition({
73
+ elementReference: element,
74
+ tooltipReference: null,
75
+ tooltipArrowReference: null,
76
+ })
77
+
78
+ expect(value).toEqual({ tooltipStyles: {}, tooltipArrowStyles: {} })
79
+ })
80
+
81
+ test('empty tooltip arrow reference element', async () => {
82
+ const element = document.createElement('div')
83
+ const elementTooltip = document.createElement('div')
84
+ const value = await computeTooltipPosition({
85
+ elementReference: element,
86
+ tooltipReference: elementTooltip,
87
+ tooltipArrowReference: null,
88
+ })
89
+
90
+ expect(value).toEqual({
91
+ tooltipArrowStyles: {},
92
+ tooltipStyles: {
93
+ left: '5px',
94
+ top: '10px',
95
+ },
96
+ })
97
+ })
98
+
99
+ test('all reference elements', async () => {
100
+ const element = document.createElement('div')
101
+ const elementTooltip = document.createElement('div')
102
+ const elementTooltipArrow = document.createElement('div')
103
+ const value = await computeTooltipPosition({
104
+ elementReference: element,
105
+ tooltipReference: elementTooltip,
106
+ tooltipArrowReference: elementTooltipArrow,
107
+ })
108
+
109
+ expect(value).toEqual({
110
+ tooltipArrowStyles: {
111
+ bottom: '-4px',
112
+ left: '0px',
113
+ right: '',
114
+ top: '',
115
+ },
116
+ tooltipStyles: {
117
+ left: '5px',
118
+ top: '-10px',
119
+ },
120
+ })
121
+ })
122
+ })
123
+
124
+ describe('debounce', () => {
125
+ let func
126
+ let debouncedFunc
127
+
128
+ beforeEach((timeout = 1000) => {
129
+ func = jest.fn()
130
+ debouncedFunc = debounce(func, timeout)
131
+ })
132
+
133
+ test('execute just once', () => {
134
+ for (let i = 0; i < 100; i += 1) {
135
+ debouncedFunc()
136
+ }
137
+
138
+ // Fast-forward time
139
+ jest.runAllTimers()
140
+
141
+ expect(func).toBeCalledTimes(1)
142
+ })
143
+ })
package/src/tokens.css ADDED
@@ -0,0 +1,8 @@
1
+ :root {
2
+ --rt-color-white: #fff;
3
+ --rt-color-dark: #222;
4
+ --rt-color-success: #8dc572;
5
+ --rt-color-error: #be6464;
6
+ --rt-color-warning: #f0ad4e;
7
+ --rt-color-info: #337ab7;
8
+ }
@@ -0,0 +1,8 @@
1
+ export interface IComputePositions {
2
+ elementReference?: Element | HTMLElement | null
3
+ tooltipReference?: Element | HTMLElement | null
4
+ tooltipArrowReference?: Element | HTMLElement | null
5
+ place?: 'top' | 'right' | 'bottom' | 'left'
6
+ offset?: number
7
+ strategy?: 'absolute' | 'fixed'
8
+ }
@@ -0,0 +1,65 @@
1
+ import { computePosition, offset, flip, shift, arrow } from '@floating-ui/dom'
2
+ import type { IComputePositions } from './compute-positions-types'
3
+
4
+ export const computeTooltipPosition = async ({
5
+ elementReference = null,
6
+ tooltipReference = null,
7
+ tooltipArrowReference = null,
8
+ place = 'top',
9
+ offset: offsetValue = 10,
10
+ strategy = 'absolute',
11
+ }: IComputePositions) => {
12
+ if (!elementReference) {
13
+ // elementReference can be null or undefined and we will not compute the position
14
+ // eslint-disable-next-line no-console
15
+ // console.error('The reference element for tooltip was not defined: ', elementReference)
16
+ return { tooltipStyles: {}, tooltipArrowStyles: {} }
17
+ }
18
+
19
+ if (tooltipReference === null) {
20
+ return { tooltipStyles: {}, tooltipArrowStyles: {} }
21
+ }
22
+
23
+ const middleware = [offset(Number(offsetValue)), flip(), shift({ padding: 5 })]
24
+
25
+ if (tooltipArrowReference) {
26
+ middleware.push(arrow({ element: tooltipArrowReference as HTMLElement }))
27
+ return computePosition(elementReference as HTMLElement, tooltipReference as HTMLElement, {
28
+ placement: place,
29
+ strategy,
30
+ middleware,
31
+ }).then(({ x, y, placement, middlewareData }) => {
32
+ const styles = { left: `${x}px`, top: `${y}px` }
33
+
34
+ const { x: arrowX, y: arrowY } = middlewareData.arrow ?? { x: 0, y: 0 }
35
+
36
+ const staticSide =
37
+ {
38
+ top: 'bottom',
39
+ right: 'left',
40
+ bottom: 'top',
41
+ left: 'right',
42
+ }[placement.split('-')[0]] ?? 'bottom'
43
+
44
+ const arrowStyle = {
45
+ left: arrowX != null ? `${arrowX}px` : '',
46
+ top: arrowY != null ? `${arrowY}px` : '',
47
+ right: '',
48
+ bottom: '',
49
+ [staticSide]: '-4px',
50
+ }
51
+
52
+ return { tooltipStyles: styles, tooltipArrowStyles: arrowStyle }
53
+ })
54
+ }
55
+
56
+ return computePosition(elementReference as HTMLElement, tooltipReference as HTMLElement, {
57
+ placement: 'bottom',
58
+ strategy,
59
+ middleware,
60
+ }).then(({ x, y }) => {
61
+ const styles = { left: `${x}px`, top: `${y}px` }
62
+
63
+ return { tooltipStyles: styles, tooltipArrowStyles: {} }
64
+ })
65
+ }
@@ -0,0 +1,27 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /**
3
+ * This function debounce the received function
4
+ * @param { function } func Function to be debounced
5
+ * @param { number } wait Time to wait before execut the function
6
+ * @param { boolean } immediate Param to define if the function will be executed immediately
7
+ */
8
+ const debounce = (func: (...args: any[]) => void, wait?: number, immediate?: true) => {
9
+ let timeout: NodeJS.Timeout | null = null
10
+
11
+ return function debounced(this: typeof func, ...args: any[]) {
12
+ const later = () => {
13
+ timeout = null
14
+ if (!immediate) {
15
+ func.apply(this, args)
16
+ }
17
+ }
18
+
19
+ if (timeout) {
20
+ clearTimeout(timeout)
21
+ }
22
+
23
+ timeout = setTimeout(later, wait)
24
+ }
25
+ }
26
+
27
+ export default debounce