Skip to content

Commit 66808a9

Browse files
Initial Tooltip update to support hybrid (un)controlled state
1 parent 2f0cbca commit 66808a9

File tree

1 file changed

+192
-142
lines changed

1 file changed

+192
-142
lines changed

polaris-react/src/components/Tooltip/Tooltip.tsx

+192-142
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type {
77
import {Portal} from '../Portal';
88
import {useEphemeralPresenceManager} from '../../utilities/ephemeral-presence-manager';
99
import {findFirstFocusableNode} from '../../utilities/focus';
10-
import {useToggle} from '../../utilities/use-toggle';
1110
import {classNames} from '../../utilities/css';
1211

1312
import {TooltipOverlay} from './components';
@@ -81,7 +80,7 @@ export function Tooltip({
8180
children,
8281
content,
8382
dismissOnMouseOut,
84-
open,
83+
open: openProp,
8584
defaultOpen: defaultOpenProp,
8685
active: originalActive,
8786
hoverDelay,
@@ -93,149 +92,148 @@ export function Tooltip({
9392
borderRadius: borderRadiusProp,
9493
zIndexOverride,
9594
hasUnderline,
96-
persistOnClick,
95+
persistOnClick = false,
9796
onOpen,
9897
onClose,
9998
}: TooltipProps) {
10099
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,
112105
);
106+
const [shouldAnimate, setShouldAnimate] = useState(!defaultOpen);
113107

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);
122109
const hoverDelayTimeout = useRef<NodeJS.Timeout | null>(null);
123110
const hoverOutTimeout = useRef<NodeJS.Timeout | null>(null);
124111

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+
);
136120

137-
if (!accessibilityNode) return;
121+
const {presenceList, addPresence, removePresence} =
122+
useEphemeralPresenceManager();
138123

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+
}, []);
143130

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+
}
153136
}, []);
154137

155138
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+
157147
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+
]);
160156

161157
const handleClose = useCallback(() => {
158+
if (!open) return;
159+
160+
if (!isControlled) {
161+
setOpen(false);
162+
removePresence('tooltip');
163+
}
164+
162165
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+
164200
hoverOutTimeout.current = setTimeout(() => {
165-
removePresence('tooltip');
201+
handleClose();
166202
}, 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]);
168218

169219
const handleKeyUp = useCallback(
170220
(event: React.KeyboardEvent) => {
171221
if (event.key !== 'Escape') return;
172-
handleClose?.();
173-
handleBlur();
174-
persistOnClick && togglePersisting();
175-
},
176-
[handleBlur, handleClose, persistOnClick, togglePersisting],
177-
);
178222

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);
206224

207-
const wrapperClassNames = classNames(
208-
activatorWrapper === 'div' && styles.TooltipContainer,
209-
hasUnderline && styles.HasUnderline,
225+
handleClose();
226+
},
227+
[handleClose, isPersisting, setIsPersisting],
210228
);
211229

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;
237232

238-
function setActivator(node: HTMLElement | null) {
233+
setIsPersisting((prevIsPersisting) => !prevIsPersisting);
234+
}, [open, setIsPersisting]);
235+
236+
const setActivator = useCallback((node: HTMLElement | null) => {
239237
const activatorContainerRef: any = activatorContainer;
240238
if (node == null) {
241239
activatorContainerRef.current = null;
@@ -247,40 +245,92 @@ export function Tooltip({
247245
setActivatorNode(node.firstElementChild);
248246

249247
activatorContainerRef.current = node;
250-
}
248+
}, []);
251249

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');
259261
} else {
260-
handleOpen();
261-
handleFocus();
262+
setShouldAnimate(false);
263+
setOpen(false);
264+
removePresence('tooltip');
262265
}
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+
);
264285

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;
270292

271-
mouseEntered.current = false;
272-
handleClose();
293+
if (!accessibilityNode) return;
273294

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]);
278299

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+
);
284334
}
285335

286336
function noop() {}

0 commit comments

Comments
 (0)