@@ -7,7 +7,6 @@ import type {
7
7
import { Portal } from '../Portal' ;
8
8
import { useEphemeralPresenceManager } from '../../utilities/ephemeral-presence-manager' ;
9
9
import { findFirstFocusableNode } from '../../utilities/focus' ;
10
- import { useToggle } from '../../utilities/use-toggle' ;
11
10
import { classNames } from '../../utilities/css' ;
12
11
13
12
import { TooltipOverlay } from './components' ;
@@ -81,7 +80,7 @@ export function Tooltip({
81
80
children,
82
81
content,
83
82
dismissOnMouseOut,
84
- open,
83
+ open : openProp ,
85
84
defaultOpen : defaultOpenProp ,
86
85
active : originalActive ,
87
86
hoverDelay,
@@ -93,149 +92,148 @@ export function Tooltip({
93
92
borderRadius : borderRadiusProp ,
94
93
zIndexOverride,
95
94
hasUnderline,
96
- persistOnClick,
95
+ persistOnClick = false ,
97
96
onOpen,
98
97
onClose,
99
98
} : TooltipProps ) {
100
99
const borderRadius = borderRadiusProp || '200' ;
101
-
102
- const WrapperComponent : any = activatorWrapper ;
103
- const defaultOpen = defaultOpenProp ?? originalActive ;
104
- const {
105
- value : active ,
106
- setTrue : setActiveTrue ,
107
- setFalse : handleBlur ,
108
- } = useToggle ( Boolean ( defaultOpen ) ) ;
109
-
110
- const { value : persist , toggle : togglePersisting } = useToggle (
111
- Boolean ( defaultOpen ) && Boolean ( persistOnClick ) ,
100
+ const isControlled = typeof openProp === 'boolean' ;
101
+ const defaultOpen = defaultOpenProp ?? originalActive ?? false ;
102
+ const [ open , setOpen ] = useState ( defaultOpen ) ;
103
+ const [ isPersisting , setIsPersisting ] = useState (
104
+ defaultOpen && persistOnClick ,
112
105
) ;
106
+ const [ shouldAnimate , setShouldAnimate ] = useState ( ! defaultOpen ) ;
113
107
114
- const [ activatorNode , setActivatorNode ] = useState < HTMLElement | null > ( null ) ;
115
- const { presenceList, addPresence, removePresence} =
116
- useEphemeralPresenceManager ( ) ;
117
-
118
- const id = useId ( ) ;
119
- const activatorContainer = useRef < HTMLElement > ( null ) ;
120
- const mouseEntered = useRef ( false ) ;
121
- const [ shouldAnimate , setShouldAnimate ] = useState ( Boolean ( ! defaultOpen ) ) ;
108
+ const isMouseEntered = useRef ( false ) ;
122
109
const hoverDelayTimeout = useRef < NodeJS . Timeout | null > ( null ) ;
123
110
const hoverOutTimeout = useRef < NodeJS . Timeout | null > ( null ) ;
124
111
125
- const handleFocus = useCallback ( ( ) => {
126
- if ( originalActive === false ) return ;
127
-
128
- setActiveTrue ( ) ;
129
- } , [ originalActive , setActiveTrue ] ) ;
130
-
131
- useEffect ( ( ) => {
132
- const firstFocusable = activatorContainer . current
133
- ? findFirstFocusableNode ( activatorContainer . current )
134
- : null ;
135
- const accessibilityNode = firstFocusable || activatorContainer . current ;
112
+ const id = useId ( ) ;
113
+ const WrapperComponent : any = activatorWrapper ;
114
+ const activatorContainer = useRef < HTMLElement > ( null ) ;
115
+ const [ activatorNode , setActivatorNode ] = useState < HTMLElement | null > ( null ) ;
116
+ const wrapperClassNames = classNames (
117
+ WrapperComponent === 'div' && styles . TooltipContainer ,
118
+ hasUnderline && styles . HasUnderline ,
119
+ ) ;
136
120
137
- if ( ! accessibilityNode ) return ;
121
+ const { presenceList, addPresence, removePresence} =
122
+ useEphemeralPresenceManager ( ) ;
138
123
139
- accessibilityNode . tabIndex = 0 ;
140
- accessibilityNode . setAttribute ( 'aria-describedby' , id ) ;
141
- accessibilityNode . setAttribute ( 'data-polaris-tooltip-activator' , 'true' ) ;
142
- } , [ id , children ] ) ;
124
+ const clearHoverDelayTimeout = useCallback ( ( ) => {
125
+ if ( hoverDelayTimeout . current ) {
126
+ clearTimeout ( hoverDelayTimeout . current ) ;
127
+ hoverDelayTimeout . current = null ;
128
+ }
129
+ } , [ ] ) ;
143
130
144
- useEffect ( ( ) => {
145
- return ( ) => {
146
- if ( hoverDelayTimeout . current ) {
147
- clearTimeout ( hoverDelayTimeout . current ) ;
148
- }
149
- if ( hoverOutTimeout . current ) {
150
- clearTimeout ( hoverOutTimeout . current ) ;
151
- }
152
- } ;
131
+ const clearHoverOutTimeout = useCallback ( ( ) => {
132
+ if ( hoverOutTimeout . current ) {
133
+ clearTimeout ( hoverOutTimeout . current ) ;
134
+ hoverOutTimeout . current = null ;
135
+ }
153
136
} , [ ] ) ;
154
137
155
138
const handleOpen = useCallback ( ( ) => {
156
- setShouldAnimate ( ! presenceList . tooltip && ! active ) ;
139
+ if ( open ) return ;
140
+
141
+ if ( ! isControlled && originalActive !== false ) {
142
+ setShouldAnimate ( ! open && ! presenceList . tooltip ) ;
143
+ setOpen ( true ) ;
144
+ addPresence ( 'tooltip' ) ;
145
+ }
146
+
157
147
onOpen ?.( ) ;
158
- addPresence ( 'tooltip' ) ;
159
- } , [ addPresence , presenceList . tooltip , onOpen , active ] ) ;
148
+ } , [
149
+ addPresence ,
150
+ isControlled ,
151
+ onOpen ,
152
+ open ,
153
+ originalActive ,
154
+ presenceList . tooltip ,
155
+ ] ) ;
160
156
161
157
const handleClose = useCallback ( ( ) => {
158
+ if ( ! open ) return ;
159
+
160
+ if ( ! isControlled ) {
161
+ setOpen ( false ) ;
162
+ removePresence ( 'tooltip' ) ;
163
+ }
164
+
162
165
onClose ?.( ) ;
163
- setShouldAnimate ( false ) ;
166
+ } , [ isControlled , open , onClose , removePresence ] ) ;
167
+
168
+ const handleMouseEnter = useCallback ( ( ) => {
169
+ // https://github.com/facebook/react/issues/10109
170
+ // Mouseenter event not triggered when cursor moves from disabled button
171
+ if ( isMouseEntered . current ) return ;
172
+ isMouseEntered . current = true ;
173
+
174
+ clearHoverOutTimeout ( ) ;
175
+
176
+ if ( open ) return ;
177
+
178
+ if ( hoverDelay && ! presenceList . tooltip ) {
179
+ hoverDelayTimeout . current = setTimeout ( ( ) => {
180
+ handleOpen ( ) ;
181
+ } , hoverDelay ) ;
182
+ } else {
183
+ handleOpen ( ) ;
184
+ }
185
+ } , [
186
+ clearHoverOutTimeout ,
187
+ handleOpen ,
188
+ hoverDelay ,
189
+ open ,
190
+ presenceList . tooltip ,
191
+ ] ) ;
192
+
193
+ const handleMouseLeave = useCallback ( ( ) => {
194
+ isMouseEntered . current = false ;
195
+
196
+ clearHoverDelayTimeout ( ) ;
197
+
198
+ if ( isPersisting ) return ;
199
+
164
200
hoverOutTimeout . current = setTimeout ( ( ) => {
165
- removePresence ( 'tooltip' ) ;
201
+ handleClose ( ) ;
166
202
} , HOVER_OUT_TIMEOUT ) ;
167
- } , [ removePresence , onClose ] ) ;
203
+ } , [ clearHoverDelayTimeout , handleClose , isPersisting ] ) ;
204
+
205
+ const handleFocus = useCallback ( ( ) => {
206
+ if ( open ) return ;
207
+
208
+ clearHoverDelayTimeout ( ) ;
209
+
210
+ handleOpen ( ) ;
211
+ } , [ clearHoverDelayTimeout , handleOpen , open ] ) ;
212
+
213
+ const handleBlur = useCallback ( ( ) => {
214
+ if ( isPersisting ) setIsPersisting ( false ) ;
215
+
216
+ handleClose ( ) ;
217
+ } , [ handleClose , isPersisting , setIsPersisting ] ) ;
168
218
169
219
const handleKeyUp = useCallback (
170
220
( event : React . KeyboardEvent ) => {
171
221
if ( event . key !== 'Escape' ) return ;
172
- handleClose ?.( ) ;
173
- handleBlur ( ) ;
174
- persistOnClick && togglePersisting ( ) ;
175
- } ,
176
- [ handleBlur , handleClose , persistOnClick , togglePersisting ] ,
177
- ) ;
178
222
179
- useEffect ( ( ) => {
180
- if ( originalActive === false && active ) {
181
- handleClose ( ) ;
182
- handleBlur ( ) ;
183
- }
184
- } , [ originalActive , active , handleClose , handleBlur ] ) ;
185
-
186
- const portal = activatorNode ? (
187
- < Portal idPrefix = "tooltip" >
188
- < TooltipOverlay
189
- id = { id }
190
- preferredPosition = { preferredPosition }
191
- activator = { activatorNode }
192
- active = { open ?? active }
193
- accessibilityLabel = { accessibilityLabel }
194
- onClose = { noop }
195
- preventInteraction = { dismissOnMouseOut }
196
- width = { width }
197
- padding = { padding }
198
- borderRadius = { borderRadius }
199
- zIndexOverride = { zIndexOverride }
200
- instant = { ! shouldAnimate }
201
- >
202
- { content }
203
- </ TooltipOverlay >
204
- </ Portal >
205
- ) : null ;
223
+ if ( isPersisting ) setIsPersisting ( false ) ;
206
224
207
- const wrapperClassNames = classNames (
208
- activatorWrapper === 'div' && styles . TooltipContainer ,
209
- hasUnderline && styles . HasUnderline ,
225
+ handleClose ( ) ;
226
+ } ,
227
+ [ handleClose , isPersisting , setIsPersisting ] ,
210
228
) ;
211
229
212
- return (
213
- < WrapperComponent
214
- onFocus = { ( ) => {
215
- handleOpen ( ) ;
216
- handleFocus ( ) ;
217
- } }
218
- onBlur = { ( ) => {
219
- handleClose ( ) ;
220
- handleBlur ( ) ;
221
-
222
- if ( persistOnClick ) {
223
- togglePersisting ( ) ;
224
- }
225
- } }
226
- onMouseLeave = { handleMouseLeave }
227
- onMouseOver = { handleMouseEnterFix }
228
- onMouseDown = { persistOnClick ? togglePersisting : undefined }
229
- ref = { setActivator }
230
- onKeyUp = { handleKeyUp }
231
- className = { wrapperClassNames }
232
- >
233
- { children }
234
- { portal }
235
- </ WrapperComponent >
236
- ) ;
230
+ const handleMouseDown = useCallback ( ( ) => {
231
+ if ( ! open ) return ;
237
232
238
- function setActivator ( node : HTMLElement | null ) {
233
+ setIsPersisting ( ( prevIsPersisting ) => ! prevIsPersisting ) ;
234
+ } , [ open , setIsPersisting ] ) ;
235
+
236
+ const setActivator = useCallback ( ( node : HTMLElement | null ) => {
239
237
const activatorContainerRef : any = activatorContainer ;
240
238
if ( node == null ) {
241
239
activatorContainerRef . current = null ;
@@ -247,40 +245,92 @@ export function Tooltip({
247
245
setActivatorNode ( node . firstElementChild ) ;
248
246
249
247
activatorContainerRef . current = node ;
250
- }
248
+ } , [ ] ) ;
251
249
252
- function handleMouseEnter ( ) {
253
- mouseEntered . current = true ;
254
- if ( hoverDelay && ! presenceList . tooltip ) {
255
- hoverDelayTimeout . current = setTimeout ( ( ) => {
256
- handleOpen ( ) ;
257
- handleFocus ( ) ;
258
- } , hoverDelay ) ;
250
+ // Sync controlled state with uncontrolled state
251
+ useEffect ( ( ) => {
252
+ if ( ! isControlled || openProp === open ) return ;
253
+
254
+ clearHoverDelayTimeout ( ) ;
255
+ clearHoverOutTimeout ( ) ;
256
+
257
+ if ( openProp ) {
258
+ setShouldAnimate ( ! open && ! presenceList . tooltip ) ;
259
+ setOpen ( true ) ;
260
+ addPresence ( 'tooltip' ) ;
259
261
} else {
260
- handleOpen ( ) ;
261
- handleFocus ( ) ;
262
+ setShouldAnimate ( false ) ;
263
+ setOpen ( false ) ;
264
+ removePresence ( 'tooltip' ) ;
262
265
}
263
- }
266
+ } , [
267
+ addPresence ,
268
+ clearHoverDelayTimeout ,
269
+ clearHoverOutTimeout ,
270
+ isControlled ,
271
+ open ,
272
+ openProp ,
273
+ presenceList . tooltip ,
274
+ removePresence ,
275
+ ] ) ;
276
+
277
+ // Clear timeouts on unmount
278
+ useEffect (
279
+ ( ) => ( ) => {
280
+ clearHoverDelayTimeout ( ) ;
281
+ clearHoverOutTimeout ( ) ;
282
+ } ,
283
+ [ clearHoverDelayTimeout , clearHoverOutTimeout ] ,
284
+ ) ;
264
285
265
- function handleMouseLeave ( ) {
266
- if ( hoverDelayTimeout . current ) {
267
- clearTimeout ( hoverDelayTimeout . current ) ;
268
- hoverDelayTimeout . current = null ;
269
- }
286
+ // Add `tabIndex` and other a11y attributes to the first focusable node
287
+ useEffect ( ( ) => {
288
+ const firstFocusable = activatorContainer . current
289
+ ? findFirstFocusableNode ( activatorContainer . current )
290
+ : null ;
291
+ const accessibilityNode = firstFocusable || activatorContainer . current ;
270
292
271
- mouseEntered . current = false ;
272
- handleClose ( ) ;
293
+ if ( ! accessibilityNode ) return ;
273
294
274
- if ( ! persist ) {
275
- handleBlur ( ) ;
276
- }
277
- }
295
+ accessibilityNode . tabIndex = 0 ;
296
+ accessibilityNode . setAttribute ( 'aria-describedby' , id ) ;
297
+ accessibilityNode . setAttribute ( 'data-polaris-tooltip-activator' , 'true' ) ;
298
+ } , [ id , children ] ) ;
278
299
279
- // https://github.com/facebook/react/issues/10109
280
- // Mouseenter event not triggered when cursor moves from disabled button
281
- function handleMouseEnterFix ( ) {
282
- ! mouseEntered . current && handleMouseEnter ( ) ;
283
- }
300
+ return (
301
+ < WrapperComponent
302
+ ref = { setActivator }
303
+ className = { wrapperClassNames }
304
+ onFocus = { handleFocus }
305
+ onBlur = { handleBlur }
306
+ onMouseOver = { handleMouseEnter }
307
+ onMouseLeave = { handleMouseLeave }
308
+ onMouseDown = { handleMouseDown }
309
+ onKeyUp = { handleKeyUp }
310
+ >
311
+ { children }
312
+ { activatorNode && (
313
+ < Portal idPrefix = "tooltip" >
314
+ < TooltipOverlay
315
+ id = { id }
316
+ preferredPosition = { preferredPosition }
317
+ activator = { activatorNode }
318
+ active = { open }
319
+ accessibilityLabel = { accessibilityLabel }
320
+ onClose = { noop }
321
+ preventInteraction = { dismissOnMouseOut }
322
+ width = { width }
323
+ padding = { padding }
324
+ borderRadius = { borderRadius }
325
+ zIndexOverride = { zIndexOverride }
326
+ instant = { ! shouldAnimate }
327
+ >
328
+ { content }
329
+ </ TooltipOverlay >
330
+ </ Portal >
331
+ ) }
332
+ </ WrapperComponent >
333
+ ) ;
284
334
}
285
335
286
336
function noop ( ) { }
0 commit comments