-
Notifications
You must be signed in to change notification settings - Fork 4.2k
/
index.js
293 lines (258 loc) · 7.41 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
// @ts-nocheck
/**
* External dependencies
*/
import classNames from 'classnames';
/**
* WordPress dependencies
*/
import {
Children,
cloneElement,
concatChildren,
useEffect,
useState,
} from '@wordpress/element';
import { useDebounce, useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
*/
import Popover from '../popover';
import Shortcut from '../shortcut';
/**
* Time over children to wait before showing tooltip
*
* @type {number}
*/
export const TOOLTIP_DELAY = 700;
const eventCatcher = <div className="event-catcher" />;
const getDisabledElement = ( {
eventHandlers,
child,
childrenWithPopover,
mergedRefs,
} ) => {
return cloneElement(
<span className="disabled-element-wrapper">
{ cloneElement( eventCatcher, eventHandlers ) }
{ cloneElement( child, {
children: childrenWithPopover,
ref: mergedRefs,
} ) }
</span>,
{ ...eventHandlers }
);
};
const getRegularElement = ( {
child,
eventHandlers,
childrenWithPopover,
mergedRefs,
} ) => {
return cloneElement( child, {
...eventHandlers,
children: childrenWithPopover,
ref: mergedRefs,
} );
};
const addPopoverToGrandchildren = ( {
anchor,
grandchildren,
isOver,
offset,
position,
shortcut,
text,
className,
...props
} ) =>
concatChildren(
grandchildren,
isOver && (
<Popover
focusOnMount={ false }
position={ position }
className={ classNames( 'components-tooltip', className ) }
aria-hidden="true"
animate={ false }
offset={ offset }
anchor={ anchor }
shift
{ ...props }
>
{ text }
<Shortcut
className="components-tooltip__shortcut"
shortcut={ shortcut }
/>
</Popover>
)
);
const emitToChild = ( children, eventName, event ) => {
if ( Children.count( children ) !== 1 ) {
return;
}
const child = Children.only( children );
// If the underlying element is disabled, do not emit the event.
if ( child.props.disabled ) {
return;
}
if ( typeof child.props[ eventName ] === 'function' ) {
child.props[ eventName ]( event );
}
};
function Tooltip( props ) {
const {
children,
position = 'bottom middle',
text,
shortcut,
delay = TOOLTIP_DELAY,
...popoverProps
} = props;
/**
* Whether a mouse is currently pressed, used in determining whether
* to handle a focus event as displaying the tooltip immediately.
*
* @type {boolean}
*/
const [ isMouseDown, setIsMouseDown ] = useState( false );
const [ isOver, setIsOver ] = useState( false );
const delayedSetIsOver = useDebounce( setIsOver, delay );
// Using internal state (instead of a ref) for the popover anchor to make sure
// that the component re-renders when the anchor updates.
const [ popoverAnchor, setPopoverAnchor ] = useState( null );
// Create a reference to the Tooltip's child, to be passed to the Popover
// so that the Tooltip can be correctly positioned. Also, merge with the
// existing ref for the first child, so that its ref is preserved.
const existingChildRef = Children.toArray( children )[ 0 ]?.ref;
const mergedChildRefs = useMergeRefs( [
setPopoverAnchor,
existingChildRef,
] );
const createMouseDown = ( event ) => {
// In firefox, the mouse down event is also fired when the select
// list is chosen.
// Cancel further processing because re-rendering of child components
// causes onChange to be triggered with the old value.
// See https://github.com/WordPress/gutenberg/pull/42483
if ( event.target.tagName === 'OPTION' ) {
return;
}
// Preserve original child callback behavior.
emitToChild( children, 'onMouseDown', event );
// On mouse down, the next `mouseup` should revert the value of the
// instance property and remove its own event handler. The bind is
// made on the document since the `mouseup` might not occur within
// the bounds of the element.
document.addEventListener( 'mouseup', cancelIsMouseDown );
setIsMouseDown( true );
};
const createMouseUp = ( event ) => {
// In firefox, the mouse up event is also fired when the select
// list is chosen.
// Cancel further processing because re-rendering of child components
// causes onChange to be triggered with the old value.
// See https://github.com/WordPress/gutenberg/pull/42483
if ( event.target.tagName === 'OPTION' ) {
return;
}
emitToChild( children, 'onMouseUp', event );
document.removeEventListener( 'mouseup', cancelIsMouseDown );
setIsMouseDown( false );
};
const createMouseEvent = ( type ) => {
if ( type === 'mouseUp' ) return createMouseUp;
if ( type === 'mouseDown' ) return createMouseDown;
};
/**
* Prebound `isInMouseDown` handler, created as a constant reference to
* assure ability to remove in component unmount.
*
* @type {Function}
*/
const cancelIsMouseDown = createMouseEvent( 'mouseUp' );
const createToggleIsOver = ( eventName, isDelayed ) => {
return ( event ) => {
// Preserve original child callback behavior.
emitToChild( children, eventName, event );
// Mouse events behave unreliably in React for disabled elements,
// firing on mouseenter but not mouseleave. Further, the default
// behavior for disabled elements in some browsers is to ignore
// mouse events. Don't bother trying to handle them.
//
// See: https://github.com/facebook/react/issues/4251
if ( event.currentTarget.disabled ) {
return;
}
// A focus event will occur as a result of a mouse click, but it
// should be disambiguated between interacting with the button and
// using an explicit focus shift as a cue to display the tooltip.
if ( 'focus' === event.type && isMouseDown ) {
return;
}
// Needed in case unsetting is over while delayed set pending, i.e.
// quickly blur/mouseleave before delayedSetIsOver is called.
delayedSetIsOver.cancel();
const _isOver = [ 'focus', 'mouseenter' ].includes( event.type );
if ( _isOver === isOver ) {
return;
}
if ( isDelayed ) {
delayedSetIsOver( _isOver );
} else {
setIsOver( _isOver );
}
};
};
const clearOnUnmount = () => {
delayedSetIsOver.cancel();
document.removeEventListener( 'mouseup', cancelIsMouseDown );
};
// Ignore reason: updating the deps array here could cause unexpected changes in behavior.
// Deferring until a more detailed investigation/refactor can be performed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect( () => clearOnUnmount, [] );
if ( Children.count( children ) !== 1 ) {
if ( 'development' === process.env.NODE_ENV ) {
// eslint-disable-next-line no-console
console.error(
'Tooltip should be called with only a single child element.'
);
}
return children;
}
const eventHandlers = {
onMouseEnter: createToggleIsOver( 'onMouseEnter', true ),
onMouseLeave: createToggleIsOver( 'onMouseLeave' ),
onClick: createToggleIsOver( 'onClick' ),
onFocus: createToggleIsOver( 'onFocus' ),
onBlur: createToggleIsOver( 'onBlur' ),
onMouseDown: createMouseEvent( 'mouseDown' ),
};
const child = Children.only( children );
const { children: grandchildren, disabled } = child.props;
const getElementWithPopover = disabled
? getDisabledElement
: getRegularElement;
const popoverData = {
anchor: popoverAnchor,
isOver,
offset: 4,
position,
shortcut,
text,
};
const childrenWithPopover = addPopoverToGrandchildren( {
grandchildren,
...popoverData,
...popoverProps,
} );
return getElementWithPopover( {
child,
eventHandlers,
childrenWithPopover,
mergedRefs: mergedChildRefs,
} );
}
export default Tooltip;