@automattic/jetpack-components 3.1.2 → 3.2.0
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/CHANGELOG.md +9 -0
- package/build/components/icon-tooltip/index.js +128 -10
- package/build/components/icon-tooltip/style.scss +32 -0
- package/build/components/icon-tooltip/types.d.ts +15 -1
- package/components/icon-tooltip/index.tsx +188 -23
- package/components/icon-tooltip/style.scss +32 -0
- package/components/icon-tooltip/types.ts +18 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
### This is a list detailing changes for the Jetpack RNA Components package releases.
|
|
4
4
|
|
|
5
|
+
## [3.2.0] - 2026-09-23
|
|
6
|
+
### Added
|
|
7
|
+
- IconTooltip: Add a `trigger` prop that renders a text trigger in place of the icon, and a `closeOnClickOutside` prop to keep a tooltip open while the visitor works elsewhere on the page. [#52704]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Tooltips: Open info tooltips with Enter or Space, close them with Escape, and keep hover tooltips from taking focus. [#52661]
|
|
11
|
+
- Tooltips: Resume tab order from the tooltip's trigger when focus leaves it. [#52661]
|
|
12
|
+
|
|
5
13
|
## [3.1.2] - 2026-09-23
|
|
6
14
|
### Fixed
|
|
7
15
|
- DiffViewer: Fix some inefficient regular expressions. [#52582]
|
|
@@ -1955,6 +1963,7 @@
|
|
|
1955
1963
|
### Changed
|
|
1956
1964
|
- Update node version requirement to 14.16.1
|
|
1957
1965
|
|
|
1966
|
+
[3.2.0]: https://github.com/Automattic/jetpack-components/compare/3.1.2...3.2.0
|
|
1958
1967
|
[3.1.2]: https://github.com/Automattic/jetpack-components/compare/3.1.1...3.1.2
|
|
1959
1968
|
[3.1.1]: https://github.com/Automattic/jetpack-components/compare/3.1.0...3.1.1
|
|
1960
1969
|
[3.1.0]: https://github.com/Automattic/jetpack-components/compare/3.0.3...3.1.0
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Popover } from '@wordpress/components';
|
|
3
|
+
import { focus } from '@wordpress/dom';
|
|
3
4
|
import { Icon, info } from '@wordpress/icons';
|
|
4
5
|
import clsx from 'clsx';
|
|
5
|
-
import { useCallback, useState } from 'react';
|
|
6
|
+
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
6
7
|
import Button from '../button/index.js';
|
|
7
8
|
import './style.scss';
|
|
8
9
|
const placementsToPositions = (placement) => {
|
|
@@ -22,15 +23,90 @@ const placementsToPositions = (placement) => {
|
|
|
22
23
|
* @param {IconTooltipProps} props - Props
|
|
23
24
|
* @return {ReactElement} - JSX element
|
|
24
25
|
*/
|
|
25
|
-
const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', placement = 'bottom-end', animate = true, iconCode = info, iconSize = 18, offset = 10, title, children, popoverAnchorStyle = 'icon', forceShow = false, hoverShow = false, wide = false, inline = true, shift = false, }) => {
|
|
26
|
+
const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', placement = 'bottom-end', animate = true, iconCode = info, iconSize = 18, offset = 10, title, children, popoverAnchorStyle = 'icon', trigger, onTriggerClick, closeOnClickOutside = true, forceShow = false, hoverShow = false, wide = false, inline = true, shift = false, }) => {
|
|
26
27
|
const POPOVER_HELPER_WIDTH = 124;
|
|
27
28
|
const [isVisible, setIsVisible] = useState(false);
|
|
28
29
|
const [hoverTimeout, setHoverTimeout] = useState(null);
|
|
30
|
+
const wrapperRef = useRef(null);
|
|
31
|
+
const popoverRef = useRef(null);
|
|
32
|
+
const triggerRef = useRef(null);
|
|
33
|
+
const hasTextTrigger = trigger !== undefined;
|
|
34
|
+
// Where focus should land after Tab leaves the tooltip. The effect below applies it, rather
|
|
35
|
+
// than the handler, because Popover puts focus back on the trigger as it unmounts.
|
|
36
|
+
const focusAfterClose = useRef(null);
|
|
37
|
+
// Opening on hover must not pull focus off whatever the visitor is using.
|
|
38
|
+
const openedByHover = useRef(false);
|
|
29
39
|
const hideTooltip = useCallback(() => setIsVisible(false), [setIsVisible]);
|
|
30
40
|
const toggleTooltip = useCallback(e => {
|
|
31
41
|
e.preventDefault();
|
|
42
|
+
onTriggerClick?.();
|
|
43
|
+
openedByHover.current = false;
|
|
32
44
|
setIsVisible(!isVisible);
|
|
33
|
-
}, [isVisible, setIsVisible]);
|
|
45
|
+
}, [isVisible, setIsVisible, onTriggerClick]);
|
|
46
|
+
// Focus can stay on a text trigger while its tooltip is open, so it handles the dialog keys too.
|
|
47
|
+
const handleTriggerKeyDown = useCallback((event) => {
|
|
48
|
+
// A link only activates on Enter; a button also activates on Space.
|
|
49
|
+
if (event.key === ' ') {
|
|
50
|
+
if (!event.repeat) {
|
|
51
|
+
toggleTooltip(event);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!isVisible) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (event.key === 'Escape') {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
hideTooltip();
|
|
61
|
+
}
|
|
62
|
+
else if (event.key === 'Tab') {
|
|
63
|
+
// Step into the popover even when it is portaled away from the trigger.
|
|
64
|
+
const first = event.shiftKey ? null : focus.tabbable.find(popoverRef.current)[0];
|
|
65
|
+
if (first) {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
first.focus();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
hideTooltip();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, [isVisible, hideTooltip, toggleTooltip]);
|
|
74
|
+
const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
|
|
75
|
+
const isForcedToShow = isAnchorWrapper && forceShow;
|
|
76
|
+
const handlePopoverKeyDown = useCallback((event) => {
|
|
77
|
+
const wrapper = wrapperRef.current;
|
|
78
|
+
const popover = popoverRef.current;
|
|
79
|
+
if (event.key !== 'Tab' || !wrapper || !popover) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const tabbables = focus.tabbable.find(popover);
|
|
83
|
+
const boundary = event.shiftKey ? tabbables[0] : tabbables[tabbables.length - 1];
|
|
84
|
+
const leaving = !tabbables.length ||
|
|
85
|
+
event.target === boundary ||
|
|
86
|
+
(event.shiftKey && event.target === popover);
|
|
87
|
+
if (!leaving) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (event.shiftKey && triggerRef.current) {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
triggerRef.current.focus();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// A popover rendered in a portal sits at the end of the document, so Tab out of it has
|
|
96
|
+
// to resume from the trigger's place in the page instead of the popover's.
|
|
97
|
+
const step = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext;
|
|
98
|
+
let destination = step(wrapper);
|
|
99
|
+
while (destination &&
|
|
100
|
+
(wrapper.contains(destination) || popover.contains(destination))) {
|
|
101
|
+
destination = step(destination);
|
|
102
|
+
}
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
focusAfterClose.current = destination ?? null;
|
|
105
|
+
hideTooltip();
|
|
106
|
+
}, [hideTooltip]);
|
|
107
|
+
const focusOnOpen = isForcedToShow || (hasTextTrigger && !openedByHover.current)
|
|
108
|
+
? 'firstElement'
|
|
109
|
+
: !openedByHover.current;
|
|
34
110
|
const args = {
|
|
35
111
|
// To be compatible with deprecating prop `position`.
|
|
36
112
|
position: placementsToPositions(placement),
|
|
@@ -40,24 +116,65 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
|
|
|
40
116
|
resize: false,
|
|
41
117
|
flip: false,
|
|
42
118
|
offset, // The distance (in px) between the anchor and the popover.
|
|
43
|
-
|
|
44
|
-
|
|
119
|
+
// Focusing the popover itself puts Escape in reach even with nothing tabbable inside. A text
|
|
120
|
+
// trigger or caller-controlled popover moves focus to its first link, if it has one.
|
|
121
|
+
focusOnMount: focusOnOpen,
|
|
122
|
+
// Tab moves through the popover in document order rather than cycling inside it, and
|
|
123
|
+
// handlePopoverKeyDown decides where it lands on the way out.
|
|
124
|
+
constrainTabbing: false,
|
|
125
|
+
onKeyDownCapture: handlePopoverKeyDown,
|
|
126
|
+
ref: popoverRef,
|
|
127
|
+
onClose: () => {
|
|
128
|
+
const popover = popoverRef.current;
|
|
129
|
+
if (hasTextTrigger && popover?.contains(popover.ownerDocument.activeElement)) {
|
|
130
|
+
focusAfterClose.current = triggerRef.current;
|
|
131
|
+
}
|
|
132
|
+
hideTooltip();
|
|
133
|
+
},
|
|
134
|
+
onFocusOutside: (event) => {
|
|
135
|
+
// A pointer press on our own trigger dismisses through that trigger instead.
|
|
136
|
+
if (closeOnClickOutside && !wrapperRef.current?.contains(event.relatedTarget)) {
|
|
137
|
+
hideTooltip();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
45
140
|
className: clsx('icon-tooltip-container', popoverClassName),
|
|
46
141
|
inline,
|
|
47
142
|
shift,
|
|
48
143
|
};
|
|
49
|
-
const
|
|
50
|
-
const wrapperClassNames = clsx('icon-tooltip-wrapper', className);
|
|
144
|
+
const wrapperClassNames = clsx('icon-tooltip-wrapper', { 'has-text-trigger': hasTextTrigger }, className);
|
|
51
145
|
const iconShiftBySize = {
|
|
52
146
|
left: isAnchorWrapper ? 0 : -(POPOVER_HELPER_WIDTH / 2 - iconSize / 2) + 'px',
|
|
53
147
|
};
|
|
54
|
-
|
|
148
|
+
// Focus may never enter the popover, so outside presses are watched directly.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!isVisible || isForcedToShow || !closeOnClickOutside) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
154
|
+
const handlePointerDown = (event) => {
|
|
155
|
+
const target = event.target;
|
|
156
|
+
if (!wrapperRef.current?.contains(target) && !popoverRef.current?.contains(target)) {
|
|
157
|
+
hideTooltip();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
doc?.addEventListener('pointerdown', handlePointerDown);
|
|
161
|
+
return () => doc?.removeEventListener('pointerdown', handlePointerDown);
|
|
162
|
+
}, [isVisible, isForcedToShow, closeOnClickOutside, hideTooltip]);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (isForcedToShow || isVisible) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const destination = focusAfterClose.current;
|
|
168
|
+
focusAfterClose.current = null;
|
|
169
|
+
destination?.focus();
|
|
170
|
+
}, [isForcedToShow, isVisible]);
|
|
55
171
|
const handleMouseEnter = useCallback(() => {
|
|
56
172
|
if (hoverShow) {
|
|
57
173
|
if (hoverTimeout) {
|
|
58
174
|
clearTimeout(hoverTimeout);
|
|
59
175
|
setHoverTimeout(null);
|
|
60
176
|
}
|
|
177
|
+
openedByHover.current = true;
|
|
61
178
|
setIsVisible(true);
|
|
62
179
|
}
|
|
63
180
|
}, [hoverShow, hoverTimeout]);
|
|
@@ -70,6 +187,7 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
|
|
|
70
187
|
setHoverTimeout(id);
|
|
71
188
|
}
|
|
72
189
|
}, [hoverShow]);
|
|
73
|
-
|
|
190
|
+
const helper = (_jsx("div", { className: clsx('icon-tooltip-helper', { 'is-wide': wide }), style: iconShiftBySize, children: (isForcedToShow || isVisible) && (_jsx(Popover, { ...args, children: _jsxs("div", { children: [title && _jsx("div", { className: "icon-tooltip-title", children: title }), _jsx("div", { className: "icon-tooltip-content", children: children })] }) })) }));
|
|
191
|
+
return (_jsxs("div", { ref: wrapperRef, className: wrapperClassNames, "data-testid": "icon-tooltip_wrapper", onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [hasTextTrigger && (_jsxs(_Fragment, { children: [_jsx("a", { ref: triggerRef, href: "#", role: "button", className: "icon-tooltip-trigger", "aria-expanded": isVisible, onClick: toggleTooltip, onKeyDown: handleTriggerKeyDown, children: trigger }), _jsx("span", { className: "icon-tooltip-anchor", children: _jsx("span", { children: helper }) })] })), !hasTextTrigger && !isAnchorWrapper && (_jsx(Button, { variant: "link", "aria-expanded": isVisible, onClick: toggleTooltip, children: _jsx(Icon, { className: iconClassName, icon: iconCode, size: iconSize }) })), !hasTextTrigger && helper] }));
|
|
74
192
|
};
|
|
75
193
|
export default IconTooltip;
|
|
@@ -79,9 +79,41 @@ $arrow-color: var(--jp-gray);
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// A text trigger flows with the sentence around it.
|
|
83
|
+
.icon-tooltip-wrapper.has-text-trigger {
|
|
84
|
+
display: inline;
|
|
85
|
+
color: inherit;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The popover anchors at the trigger's baseline and paints under it.
|
|
89
|
+
.icon-tooltip-trigger {
|
|
90
|
+
position: relative;
|
|
91
|
+
z-index: 10;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.icon-tooltip-anchor {
|
|
95
|
+
position: absolute;
|
|
96
|
+
inset-block-start: 0;
|
|
97
|
+
inset-inline-start: 0;
|
|
98
|
+
z-index: 9;
|
|
99
|
+
|
|
100
|
+
> span {
|
|
101
|
+
display: inline-block;
|
|
102
|
+
position: relative;
|
|
103
|
+
color: var(--jp-black);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
.icon-tooltip-container {
|
|
83
108
|
// Recover events
|
|
84
109
|
pointer-events: all;
|
|
110
|
+
// Takes focus on open, but its box is wider than the visible content,
|
|
111
|
+
// so the ring goes on the content instead.
|
|
112
|
+
outline: none;
|
|
113
|
+
|
|
114
|
+
&:focus-visible .components-popover__content {
|
|
115
|
+
outline: var(--wp-admin-border-width-focus, 2px) solid var(--wp-admin-theme-color, #3858e9);
|
|
116
|
+
}
|
|
85
117
|
|
|
86
118
|
// __content styles here covers both the inline and portal modes
|
|
87
119
|
.components-popover__content {
|
|
@@ -52,7 +52,21 @@ export type IconTooltipProps = {
|
|
|
52
52
|
*/
|
|
53
53
|
popoverAnchorStyle?: 'icon' | 'wrapper';
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* Text to render as the trigger in place of the icon. The component owns the open state.
|
|
56
|
+
*/
|
|
57
|
+
trigger?: ReactNode;
|
|
58
|
+
/**
|
|
59
|
+
* Called on every press of the trigger, e.g. to record analytics.
|
|
60
|
+
*/
|
|
61
|
+
onTriggerClick?: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Whether a press outside the trigger and popover closes the tooltip. Turn it off for a
|
|
64
|
+
* reference the visitor keeps open while working elsewhere on the page.
|
|
65
|
+
*/
|
|
66
|
+
closeOnClickOutside?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Force the Popover to show without an event trigger. Only for a wrapper-anchored
|
|
69
|
+
* tooltip with no `trigger`, such as a programmatic tour step.
|
|
56
70
|
*/
|
|
57
71
|
forceShow?: boolean;
|
|
58
72
|
/**
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { Popover } from '@wordpress/components';
|
|
2
|
+
import { focus } from '@wordpress/dom';
|
|
2
3
|
import { Icon, info } from '@wordpress/icons';
|
|
3
4
|
import clsx from 'clsx';
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
ReactElement,
|
|
11
|
+
FC,
|
|
12
|
+
FocusEvent,
|
|
13
|
+
KeyboardEvent,
|
|
14
|
+
} from 'react';
|
|
5
15
|
import Button from '../button/index.tsx';
|
|
6
16
|
import { IconTooltipProps, Placement, Position } from './types.ts';
|
|
7
17
|
|
|
@@ -38,6 +48,9 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
38
48
|
title,
|
|
39
49
|
children,
|
|
40
50
|
popoverAnchorStyle = 'icon',
|
|
51
|
+
trigger,
|
|
52
|
+
onTriggerClick,
|
|
53
|
+
closeOnClickOutside = true,
|
|
41
54
|
forceShow = false,
|
|
42
55
|
hoverShow = false,
|
|
43
56
|
wide = false,
|
|
@@ -47,15 +60,101 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
47
60
|
const POPOVER_HELPER_WIDTH = 124;
|
|
48
61
|
const [ isVisible, setIsVisible ] = useState( false );
|
|
49
62
|
const [ hoverTimeout, setHoverTimeout ] = useState( null );
|
|
63
|
+
const wrapperRef = useRef< HTMLDivElement >( null );
|
|
64
|
+
const popoverRef = useRef< HTMLDivElement >( null );
|
|
65
|
+
const triggerRef = useRef< HTMLAnchorElement >( null );
|
|
66
|
+
const hasTextTrigger = trigger !== undefined;
|
|
67
|
+
// Where focus should land after Tab leaves the tooltip. The effect below applies it, rather
|
|
68
|
+
// than the handler, because Popover puts focus back on the trigger as it unmounts.
|
|
69
|
+
const focusAfterClose = useRef< HTMLElement | null >( null );
|
|
70
|
+
// Opening on hover must not pull focus off whatever the visitor is using.
|
|
71
|
+
const openedByHover = useRef( false );
|
|
50
72
|
const hideTooltip = useCallback( () => setIsVisible( false ), [ setIsVisible ] );
|
|
51
73
|
const toggleTooltip = useCallback(
|
|
52
74
|
e => {
|
|
53
75
|
e.preventDefault();
|
|
76
|
+
onTriggerClick?.();
|
|
77
|
+
openedByHover.current = false;
|
|
54
78
|
setIsVisible( ! isVisible );
|
|
55
79
|
},
|
|
56
|
-
[ isVisible, setIsVisible ]
|
|
80
|
+
[ isVisible, setIsVisible, onTriggerClick ]
|
|
57
81
|
);
|
|
58
82
|
|
|
83
|
+
// Focus can stay on a text trigger while its tooltip is open, so it handles the dialog keys too.
|
|
84
|
+
const handleTriggerKeyDown = useCallback(
|
|
85
|
+
( event: KeyboardEvent< HTMLAnchorElement > ) => {
|
|
86
|
+
// A link only activates on Enter; a button also activates on Space.
|
|
87
|
+
if ( event.key === ' ' ) {
|
|
88
|
+
if ( ! event.repeat ) {
|
|
89
|
+
toggleTooltip( event );
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if ( ! isVisible ) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if ( event.key === 'Escape' ) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
hideTooltip();
|
|
99
|
+
} else if ( event.key === 'Tab' ) {
|
|
100
|
+
// Step into the popover even when it is portaled away from the trigger.
|
|
101
|
+
const first = event.shiftKey ? null : focus.tabbable.find( popoverRef.current )[ 0 ];
|
|
102
|
+
if ( first ) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
first.focus();
|
|
105
|
+
} else {
|
|
106
|
+
hideTooltip();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[ isVisible, hideTooltip, toggleTooltip ]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
|
|
114
|
+
const isForcedToShow = isAnchorWrapper && forceShow;
|
|
115
|
+
|
|
116
|
+
const handlePopoverKeyDown = useCallback(
|
|
117
|
+
( event: KeyboardEvent< HTMLDivElement > ) => {
|
|
118
|
+
const wrapper = wrapperRef.current;
|
|
119
|
+
const popover = popoverRef.current;
|
|
120
|
+
if ( event.key !== 'Tab' || ! wrapper || ! popover ) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const tabbables = focus.tabbable.find( popover );
|
|
124
|
+
const boundary = event.shiftKey ? tabbables[ 0 ] : tabbables[ tabbables.length - 1 ];
|
|
125
|
+
const leaving =
|
|
126
|
+
! tabbables.length ||
|
|
127
|
+
event.target === boundary ||
|
|
128
|
+
( event.shiftKey && event.target === popover );
|
|
129
|
+
if ( ! leaving ) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if ( event.shiftKey && triggerRef.current ) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
triggerRef.current.focus();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// A popover rendered in a portal sits at the end of the document, so Tab out of it has
|
|
138
|
+
// to resume from the trigger's place in the page instead of the popover's.
|
|
139
|
+
const step = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext;
|
|
140
|
+
let destination = step( wrapper );
|
|
141
|
+
while (
|
|
142
|
+
destination &&
|
|
143
|
+
( wrapper.contains( destination ) || popover.contains( destination ) )
|
|
144
|
+
) {
|
|
145
|
+
destination = step( destination );
|
|
146
|
+
}
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
focusAfterClose.current = destination ?? null;
|
|
149
|
+
hideTooltip();
|
|
150
|
+
},
|
|
151
|
+
[ hideTooltip ]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const focusOnOpen =
|
|
155
|
+
isForcedToShow || ( hasTextTrigger && ! openedByHover.current )
|
|
156
|
+
? 'firstElement'
|
|
157
|
+
: ! openedByHover.current;
|
|
59
158
|
const args = {
|
|
60
159
|
// To be compatible with deprecating prop `position`.
|
|
61
160
|
position: placementsToPositions( placement ),
|
|
@@ -65,21 +164,65 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
65
164
|
resize: false,
|
|
66
165
|
flip: false,
|
|
67
166
|
offset, // The distance (in px) between the anchor and the popover.
|
|
68
|
-
|
|
69
|
-
|
|
167
|
+
// Focusing the popover itself puts Escape in reach even with nothing tabbable inside. A text
|
|
168
|
+
// trigger or caller-controlled popover moves focus to its first link, if it has one.
|
|
169
|
+
focusOnMount: focusOnOpen,
|
|
170
|
+
// Tab moves through the popover in document order rather than cycling inside it, and
|
|
171
|
+
// handlePopoverKeyDown decides where it lands on the way out.
|
|
172
|
+
constrainTabbing: false,
|
|
173
|
+
onKeyDownCapture: handlePopoverKeyDown,
|
|
174
|
+
ref: popoverRef,
|
|
175
|
+
onClose: () => {
|
|
176
|
+
const popover = popoverRef.current;
|
|
177
|
+
if ( hasTextTrigger && popover?.contains( popover.ownerDocument.activeElement ) ) {
|
|
178
|
+
focusAfterClose.current = triggerRef.current;
|
|
179
|
+
}
|
|
180
|
+
hideTooltip();
|
|
181
|
+
},
|
|
182
|
+
onFocusOutside: ( event: FocusEvent ) => {
|
|
183
|
+
// A pointer press on our own trigger dismisses through that trigger instead.
|
|
184
|
+
if ( closeOnClickOutside && ! wrapperRef.current?.contains( event.relatedTarget as Node ) ) {
|
|
185
|
+
hideTooltip();
|
|
186
|
+
}
|
|
187
|
+
},
|
|
70
188
|
className: clsx( 'icon-tooltip-container', popoverClassName ),
|
|
71
189
|
inline,
|
|
72
190
|
shift,
|
|
73
191
|
} satisfies Omit< React.ComponentProps< typeof Popover >, 'children' >;
|
|
74
192
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
193
|
+
const wrapperClassNames = clsx(
|
|
194
|
+
'icon-tooltip-wrapper',
|
|
195
|
+
{ 'has-text-trigger': hasTextTrigger },
|
|
196
|
+
className
|
|
197
|
+
);
|
|
78
198
|
const iconShiftBySize = {
|
|
79
199
|
left: isAnchorWrapper ? 0 : -( POPOVER_HELPER_WIDTH / 2 - iconSize / 2 ) + 'px',
|
|
80
200
|
};
|
|
81
201
|
|
|
82
|
-
|
|
202
|
+
// Focus may never enter the popover, so outside presses are watched directly.
|
|
203
|
+
useEffect( () => {
|
|
204
|
+
if ( ! isVisible || isForcedToShow || ! closeOnClickOutside ) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
208
|
+
const handlePointerDown = ( event: PointerEvent ) => {
|
|
209
|
+
const target = event.target as Node;
|
|
210
|
+
if ( ! wrapperRef.current?.contains( target ) && ! popoverRef.current?.contains( target ) ) {
|
|
211
|
+
hideTooltip();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
doc?.addEventListener( 'pointerdown', handlePointerDown );
|
|
215
|
+
return () => doc?.removeEventListener( 'pointerdown', handlePointerDown );
|
|
216
|
+
}, [ isVisible, isForcedToShow, closeOnClickOutside, hideTooltip ] );
|
|
217
|
+
|
|
218
|
+
useEffect( () => {
|
|
219
|
+
if ( isForcedToShow || isVisible ) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const destination = focusAfterClose.current;
|
|
223
|
+
focusAfterClose.current = null;
|
|
224
|
+
destination?.focus();
|
|
225
|
+
}, [ isForcedToShow, isVisible ] );
|
|
83
226
|
|
|
84
227
|
const handleMouseEnter = useCallback( () => {
|
|
85
228
|
if ( hoverShow ) {
|
|
@@ -87,6 +230,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
87
230
|
clearTimeout( hoverTimeout );
|
|
88
231
|
setHoverTimeout( null );
|
|
89
232
|
}
|
|
233
|
+
openedByHover.current = true;
|
|
90
234
|
setIsVisible( true );
|
|
91
235
|
}
|
|
92
236
|
}, [ hoverShow, hoverTimeout ] );
|
|
@@ -101,31 +245,52 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
101
245
|
}
|
|
102
246
|
}, [ hoverShow ] );
|
|
103
247
|
|
|
248
|
+
const helper = (
|
|
249
|
+
<div className={ clsx( 'icon-tooltip-helper', { 'is-wide': wide } ) } style={ iconShiftBySize }>
|
|
250
|
+
{ ( isForcedToShow || isVisible ) && (
|
|
251
|
+
<Popover { ...args }>
|
|
252
|
+
<div>
|
|
253
|
+
{ title && <div className="icon-tooltip-title">{ title }</div> }
|
|
254
|
+
<div className="icon-tooltip-content">{ children }</div>
|
|
255
|
+
</div>
|
|
256
|
+
</Popover>
|
|
257
|
+
) }
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
|
|
104
261
|
return (
|
|
105
262
|
<div
|
|
263
|
+
ref={ wrapperRef }
|
|
106
264
|
className={ wrapperClassNames }
|
|
107
265
|
data-testid="icon-tooltip_wrapper"
|
|
108
266
|
onMouseEnter={ handleMouseEnter }
|
|
109
267
|
onMouseLeave={ handleMouseLeave }
|
|
110
268
|
>
|
|
111
|
-
{
|
|
112
|
-
|
|
269
|
+
{ hasTextTrigger && (
|
|
270
|
+
<>
|
|
271
|
+
{ /* A link, not a button, so it keeps the host page's link styling. */ }
|
|
272
|
+
<a
|
|
273
|
+
ref={ triggerRef }
|
|
274
|
+
href="#"
|
|
275
|
+
role="button"
|
|
276
|
+
className="icon-tooltip-trigger"
|
|
277
|
+
aria-expanded={ isVisible }
|
|
278
|
+
onClick={ toggleTooltip }
|
|
279
|
+
onKeyDown={ handleTriggerKeyDown }
|
|
280
|
+
>
|
|
281
|
+
{ trigger }
|
|
282
|
+
</a>
|
|
283
|
+
<span className="icon-tooltip-anchor">
|
|
284
|
+
<span>{ helper }</span>
|
|
285
|
+
</span>
|
|
286
|
+
</>
|
|
287
|
+
) }
|
|
288
|
+
{ ! hasTextTrigger && ! isAnchorWrapper && (
|
|
289
|
+
<Button variant="link" aria-expanded={ isVisible } onClick={ toggleTooltip }>
|
|
113
290
|
<Icon className={ iconClassName } icon={ iconCode } size={ iconSize } />
|
|
114
291
|
</Button>
|
|
115
292
|
) }
|
|
116
|
-
|
|
117
|
-
className={ clsx( 'icon-tooltip-helper', { 'is-wide': wide } ) }
|
|
118
|
-
style={ iconShiftBySize }
|
|
119
|
-
>
|
|
120
|
-
{ ( isForcedToShow || isVisible ) && (
|
|
121
|
-
<Popover { ...args }>
|
|
122
|
-
<div>
|
|
123
|
-
{ title && <div className="icon-tooltip-title">{ title }</div> }
|
|
124
|
-
<div className="icon-tooltip-content">{ children }</div>
|
|
125
|
-
</div>
|
|
126
|
-
</Popover>
|
|
127
|
-
) }
|
|
128
|
-
</div>
|
|
293
|
+
{ ! hasTextTrigger && helper }
|
|
129
294
|
</div>
|
|
130
295
|
);
|
|
131
296
|
};
|
|
@@ -79,9 +79,41 @@ $arrow-color: var(--jp-gray);
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// A text trigger flows with the sentence around it.
|
|
83
|
+
.icon-tooltip-wrapper.has-text-trigger {
|
|
84
|
+
display: inline;
|
|
85
|
+
color: inherit;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The popover anchors at the trigger's baseline and paints under it.
|
|
89
|
+
.icon-tooltip-trigger {
|
|
90
|
+
position: relative;
|
|
91
|
+
z-index: 10;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.icon-tooltip-anchor {
|
|
95
|
+
position: absolute;
|
|
96
|
+
inset-block-start: 0;
|
|
97
|
+
inset-inline-start: 0;
|
|
98
|
+
z-index: 9;
|
|
99
|
+
|
|
100
|
+
> span {
|
|
101
|
+
display: inline-block;
|
|
102
|
+
position: relative;
|
|
103
|
+
color: var(--jp-black);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
.icon-tooltip-container {
|
|
83
108
|
// Recover events
|
|
84
109
|
pointer-events: all;
|
|
110
|
+
// Takes focus on open, but its box is wider than the visible content,
|
|
111
|
+
// so the ring goes on the content instead.
|
|
112
|
+
outline: none;
|
|
113
|
+
|
|
114
|
+
&:focus-visible .components-popover__content {
|
|
115
|
+
outline: var(--wp-admin-border-width-focus, 2px) solid var(--wp-admin-theme-color, #3858e9);
|
|
116
|
+
}
|
|
85
117
|
|
|
86
118
|
// __content styles here covers both the inline and portal modes
|
|
87
119
|
.components-popover__content {
|
|
@@ -67,7 +67,24 @@ export type IconTooltipProps = {
|
|
|
67
67
|
popoverAnchorStyle?: 'icon' | 'wrapper';
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
70
|
+
* Text to render as the trigger in place of the icon. The component owns the open state.
|
|
71
|
+
*/
|
|
72
|
+
trigger?: ReactNode;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Called on every press of the trigger, e.g. to record analytics.
|
|
76
|
+
*/
|
|
77
|
+
onTriggerClick?: () => void;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a press outside the trigger and popover closes the tooltip. Turn it off for a
|
|
81
|
+
* reference the visitor keeps open while working elsewhere on the page.
|
|
82
|
+
*/
|
|
83
|
+
closeOnClickOutside?: boolean;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Force the Popover to show without an event trigger. Only for a wrapper-anchored
|
|
87
|
+
* tooltip with no `trigger`, such as a programmatic tour step.
|
|
71
88
|
*/
|
|
72
89
|
forceShow?: boolean;
|
|
73
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/jetpack-components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Jetpack Components Package",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/components/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"@wordpress/compose": "8.8.0",
|
|
85
85
|
"@wordpress/data": "10.55.0",
|
|
86
86
|
"@wordpress/date": "5.55.0",
|
|
87
|
+
"@wordpress/dom": "4.55.0",
|
|
87
88
|
"@wordpress/element": "8.7.0",
|
|
88
89
|
"@wordpress/i18n": "6.28.0",
|
|
89
90
|
"@wordpress/icons": "16.0.0",
|