@automattic/jetpack-components 3.1.2 → 3.3.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 +25 -0
- package/build/components/decorative-card/style.scss +4 -1
- package/build/components/global-notices/global-notices.d.ts +4 -0
- package/build/components/global-notices/global-notices.js +4 -0
- package/build/components/global-notices/use-global-notices.d.ts +3 -0
- package/build/components/global-notices/use-global-notices.js +3 -0
- package/build/components/icon-tooltip/index.js +150 -10
- package/build/components/icon-tooltip/style.scss +25 -1
- package/build/components/icon-tooltip/types.d.ts +19 -1
- package/build/components/jitm-slot/index.d.ts +15 -0
- package/build/components/jitm-slot/index.js +52 -0
- package/build/components/jitm-slot/style.scss +16 -0
- package/build/components/jitm-slot/types.d.ts +10 -0
- package/build/components/jitm-slot/types.js +1 -0
- package/build/components/text/style.module.scss +4 -4
- package/build/components/text/types.d.ts +4 -4
- package/build/index.d.ts +1 -0
- package/build/index.js +1 -0
- package/components/decorative-card/style.scss +4 -1
- package/components/global-notices/README.md +25 -0
- package/components/global-notices/global-notices.tsx +4 -0
- package/components/global-notices/use-global-notices.ts +3 -0
- package/components/icon-tooltip/index.tsx +219 -23
- package/components/icon-tooltip/style.scss +25 -1
- package/components/icon-tooltip/types.ts +23 -1
- package/components/jitm-slot/index.tsx +67 -0
- package/components/jitm-slot/style.scss +16 -0
- package/components/jitm-slot/types.ts +10 -0
- package/components/text/style.module.scss +4 -4
- package/components/text/types.ts +4 -4
- package/index.ts +1 -0
- package/package.json +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
### This is a list detailing changes for the Jetpack RNA Components package releases.
|
|
4
4
|
|
|
5
|
+
## [3.3.0] - 2026-09-28
|
|
6
|
+
### Added
|
|
7
|
+
- Add a JitmSlot component so every dashboard renders the Jetpack in-dashboard message slot the same way, and keeps the message across route changes. [#52641]
|
|
8
|
+
- IconTooltip: Add a `label` prop to give the icon trigger an accessible name. [#52800]
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- IconTooltip: Keep focus on the trigger when a tooltip opens, announce its content to screen readers, and show the trigger's focus ring the way other WordPress buttons do. [#52736]
|
|
12
|
+
- Use logical CSS properties so layouts mirror in right-to-left languages. [#52749]
|
|
13
|
+
|
|
14
|
+
### Deprecated
|
|
15
|
+
- Deprecate GlobalNotices and useGlobalNotices. Use SnackbarNotices from @wordpress/notices instead. [#52193]
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- IconTooltip: Close a tooltip opened on hover when Escape is pressed, without moving focus. [#52799]
|
|
19
|
+
|
|
20
|
+
## [3.2.0] - 2026-09-23
|
|
21
|
+
### Added
|
|
22
|
+
- 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]
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Tooltips: Open info tooltips with Enter or Space, close them with Escape, and keep hover tooltips from taking focus. [#52661]
|
|
26
|
+
- Tooltips: Resume tab order from the tooltip's trigger when focus leaves it. [#52661]
|
|
27
|
+
|
|
5
28
|
## [3.1.2] - 2026-09-23
|
|
6
29
|
### Fixed
|
|
7
30
|
- DiffViewer: Fix some inefficient regular expressions. [#52582]
|
|
@@ -1955,6 +1978,8 @@
|
|
|
1955
1978
|
### Changed
|
|
1956
1979
|
- Update node version requirement to 14.16.1
|
|
1957
1980
|
|
|
1981
|
+
[3.3.0]: https://github.com/Automattic/jetpack-components/compare/3.2.0...3.3.0
|
|
1982
|
+
[3.2.0]: https://github.com/Automattic/jetpack-components/compare/3.1.2...3.2.0
|
|
1958
1983
|
[3.1.2]: https://github.com/Automattic/jetpack-components/compare/3.1.1...3.1.2
|
|
1959
1984
|
[3.1.1]: https://github.com/Automattic/jetpack-components/compare/3.1.0...3.1.1
|
|
1960
1985
|
[3.1.0]: https://github.com/Automattic/jetpack-components/compare/3.0.3...3.1.0
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
display: block;
|
|
30
30
|
position: absolute;
|
|
31
31
|
top: 24px;
|
|
32
|
-
|
|
32
|
+
inset-inline-start: 24px;
|
|
33
33
|
width: 38px;
|
|
34
34
|
height: 8px;
|
|
35
35
|
background-image: url('data:image/svg+xml,<svg width="38" height="8" viewBox="0 0 38 8" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 7C1 7 2.37087 1 6.89831 1C11.4257 1 14.3709 7 18.8983 7C23.4257 7 26.7777 1 31.3051 1C35.912 1 37 7 37 7" stroke="white" stroke-width="1.5" stroke-linejoin="round"/></svg>');
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
&__icon-container {
|
|
44
44
|
position: absolute;
|
|
45
45
|
top: 50%;
|
|
46
|
+
// Kept physical: translate(-50%) shifts by a fixed direction, not a
|
|
47
|
+
// logical one, so left must stay paired with it or the box goes
|
|
48
|
+
// off-center by its own width under RTL without rtlcss.
|
|
46
49
|
left: 50%;
|
|
47
50
|
transform: translate(-50%, -50%);
|
|
48
51
|
width: 80px;
|
|
@@ -4,6 +4,10 @@ export type GlobalNoticesProps = {
|
|
|
4
4
|
/**
|
|
5
5
|
* Renders the global notices.
|
|
6
6
|
*
|
|
7
|
+
* @deprecated Use `SnackbarNotices` from `@wordpress/notices` instead. Pages
|
|
8
|
+
* rendered by `@wordpress/boot` already mount one, so mounting this as well
|
|
9
|
+
* shows every notice twice.
|
|
10
|
+
*
|
|
7
11
|
* @param {GlobalNoticesProps} props - Component props.
|
|
8
12
|
*
|
|
9
13
|
* @return {import('react').ReactNode} The rendered notices list.
|
|
@@ -5,6 +5,10 @@ import { useGlobalNotices } from './use-global-notices.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* Renders the global notices.
|
|
7
7
|
*
|
|
8
|
+
* @deprecated Use `SnackbarNotices` from `@wordpress/notices` instead. Pages
|
|
9
|
+
* rendered by `@wordpress/boot` already mount one, so mounting this as well
|
|
10
|
+
* shows every notice twice.
|
|
11
|
+
*
|
|
8
12
|
* @param {GlobalNoticesProps} props - Component props.
|
|
9
13
|
*
|
|
10
14
|
* @return {import('react').ReactNode} The rendered notices list.
|
|
@@ -4,6 +4,9 @@ export type TGlobalNotices = ReturnType<NoticesStore['getActions']> & ReturnType
|
|
|
4
4
|
/**
|
|
5
5
|
* The global notices hook.
|
|
6
6
|
*
|
|
7
|
+
* @deprecated Dispatch to `@wordpress/notices` directly, passing
|
|
8
|
+
* `{ type: 'snackbar' }` — this hook only defaulted that option.
|
|
9
|
+
*
|
|
7
10
|
* @return {TGlobalNotices} The global notices selectors and actions.
|
|
8
11
|
*/
|
|
9
12
|
export declare function useGlobalNotices(): TGlobalNotices;
|
|
@@ -3,6 +3,9 @@ import { store as noticesStore } from '@wordpress/notices';
|
|
|
3
3
|
/**
|
|
4
4
|
* The global notices hook.
|
|
5
5
|
*
|
|
6
|
+
* @deprecated Dispatch to `@wordpress/notices` directly, passing
|
|
7
|
+
* `{ type: 'snackbar' }` — this hook only defaulted that option.
|
|
8
|
+
*
|
|
6
9
|
* @return {TGlobalNotices} The global notices selectors and actions.
|
|
7
10
|
*/
|
|
8
11
|
export function useGlobalNotices() {
|
|
@@ -1,8 +1,10 @@
|
|
|
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
|
+
import { speak } from '@wordpress/a11y';
|
|
2
3
|
import { Popover } from '@wordpress/components';
|
|
4
|
+
import { focus } from '@wordpress/dom';
|
|
3
5
|
import { Icon, info } from '@wordpress/icons';
|
|
4
6
|
import clsx from 'clsx';
|
|
5
|
-
import { useCallback, useState } from 'react';
|
|
7
|
+
import { useCallback, useEffect, useRef, useState, } from 'react';
|
|
6
8
|
import Button from '../button/index.js';
|
|
7
9
|
import './style.scss';
|
|
8
10
|
const placementsToPositions = (placement) => {
|
|
@@ -22,15 +24,90 @@ const placementsToPositions = (placement) => {
|
|
|
22
24
|
* @param {IconTooltipProps} props - Props
|
|
23
25
|
* @return {ReactElement} - JSX element
|
|
24
26
|
*/
|
|
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, }) => {
|
|
27
|
+
const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', placement = 'bottom-end', animate = true, iconCode = info, label, iconSize = 18, offset = 10, title, children, popoverAnchorStyle = 'icon', trigger, onTriggerClick, closeOnClickOutside = true, forceShow = false, hoverShow = false, wide = false, inline = true, shift = false, }) => {
|
|
26
28
|
const POPOVER_HELPER_WIDTH = 124;
|
|
27
29
|
const [isVisible, setIsVisible] = useState(false);
|
|
28
30
|
const [hoverTimeout, setHoverTimeout] = useState(null);
|
|
31
|
+
const wrapperRef = useRef(null);
|
|
32
|
+
const popoverRef = useRef(null);
|
|
33
|
+
const triggerRef = useRef(null);
|
|
34
|
+
const hasTextTrigger = trigger !== undefined;
|
|
35
|
+
// Where focus should land after Tab leaves the tooltip. The effect below applies it, rather
|
|
36
|
+
// than the handler, because Popover puts focus back on the trigger as it unmounts.
|
|
37
|
+
const focusAfterClose = useRef(null);
|
|
38
|
+
// Opening on hover must not pull focus off whatever the visitor is using.
|
|
39
|
+
const openedByHover = useRef(false);
|
|
29
40
|
const hideTooltip = useCallback(() => setIsVisible(false), [setIsVisible]);
|
|
30
41
|
const toggleTooltip = useCallback(e => {
|
|
31
42
|
e.preventDefault();
|
|
43
|
+
onTriggerClick?.();
|
|
44
|
+
openedByHover.current = false;
|
|
32
45
|
setIsVisible(!isVisible);
|
|
33
|
-
}, [isVisible, setIsVisible]);
|
|
46
|
+
}, [isVisible, setIsVisible, onTriggerClick]);
|
|
47
|
+
// Focus can stay on the trigger while its tooltip is open, so it handles the dialog keys too.
|
|
48
|
+
const handleTriggerKeyDown = useCallback((event) => {
|
|
49
|
+
// A held key repeats activation, so only its first press toggles.
|
|
50
|
+
if (event.repeat && (event.key === 'Enter' || event.key === ' ')) {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// A link only activates on Enter; a button also activates on Space.
|
|
55
|
+
if (hasTextTrigger && event.key === ' ') {
|
|
56
|
+
toggleTooltip(event);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!isVisible) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (event.key === 'Escape') {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
hideTooltip();
|
|
65
|
+
}
|
|
66
|
+
else if (event.key === 'Tab') {
|
|
67
|
+
// Step into the popover even when it is portaled away from the trigger.
|
|
68
|
+
const first = event.shiftKey ? null : focus.tabbable.find(popoverRef.current)[0];
|
|
69
|
+
if (first) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
first.focus();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
hideTooltip();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, [hasTextTrigger, isVisible, hideTooltip, toggleTooltip]);
|
|
78
|
+
const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
|
|
79
|
+
const isForcedToShow = isAnchorWrapper && forceShow;
|
|
80
|
+
const handlePopoverKeyDown = useCallback((event) => {
|
|
81
|
+
const wrapper = wrapperRef.current;
|
|
82
|
+
const popover = popoverRef.current;
|
|
83
|
+
if (event.key !== 'Tab' || !wrapper || !popover) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const tabbables = focus.tabbable.find(popover);
|
|
87
|
+
const boundary = event.shiftKey ? tabbables[0] : tabbables[tabbables.length - 1];
|
|
88
|
+
const leaving = !tabbables.length ||
|
|
89
|
+
event.target === boundary ||
|
|
90
|
+
(event.shiftKey && event.target === popover);
|
|
91
|
+
if (!leaving) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.shiftKey && triggerRef.current) {
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
triggerRef.current.focus();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// A popover rendered in a portal sits at the end of the document, so Tab out of it has
|
|
100
|
+
// to resume from the trigger's place in the page instead of the popover's.
|
|
101
|
+
const step = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext;
|
|
102
|
+
let destination = step(wrapper);
|
|
103
|
+
while (destination &&
|
|
104
|
+
(wrapper.contains(destination) || popover.contains(destination))) {
|
|
105
|
+
destination = step(destination);
|
|
106
|
+
}
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
focusAfterClose.current = destination ?? null;
|
|
109
|
+
hideTooltip();
|
|
110
|
+
}, [hideTooltip]);
|
|
34
111
|
const args = {
|
|
35
112
|
// To be compatible with deprecating prop `position`.
|
|
36
113
|
position: placementsToPositions(placement),
|
|
@@ -40,24 +117,86 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
|
|
|
40
117
|
resize: false,
|
|
41
118
|
flip: false,
|
|
42
119
|
offset, // The distance (in px) between the anchor and the popover.
|
|
43
|
-
|
|
44
|
-
|
|
120
|
+
// Focus stays on the trigger, which handles the dialog keys; a caller-controlled popover
|
|
121
|
+
// has no trigger, so it focuses its first button instead.
|
|
122
|
+
focusOnMount: isForcedToShow ? 'firstElement' : false,
|
|
123
|
+
// Tab moves through the popover in document order rather than cycling inside it, and
|
|
124
|
+
// handlePopoverKeyDown decides where it lands on the way out.
|
|
125
|
+
constrainTabbing: false,
|
|
126
|
+
onKeyDownCapture: handlePopoverKeyDown,
|
|
127
|
+
ref: popoverRef,
|
|
128
|
+
onClose: () => {
|
|
129
|
+
const popover = popoverRef.current;
|
|
130
|
+
if (popover?.contains(popover.ownerDocument.activeElement)) {
|
|
131
|
+
focusAfterClose.current = triggerRef.current;
|
|
132
|
+
}
|
|
133
|
+
hideTooltip();
|
|
134
|
+
},
|
|
135
|
+
onFocusOutside: (event) => {
|
|
136
|
+
// A pointer press on our own trigger dismisses through that trigger instead.
|
|
137
|
+
if (closeOnClickOutside && !wrapperRef.current?.contains(event.relatedTarget)) {
|
|
138
|
+
hideTooltip();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
45
141
|
className: clsx('icon-tooltip-container', popoverClassName),
|
|
46
142
|
inline,
|
|
47
143
|
shift,
|
|
48
144
|
};
|
|
49
|
-
const
|
|
50
|
-
const wrapperClassNames = clsx('icon-tooltip-wrapper', className);
|
|
145
|
+
const wrapperClassNames = clsx('icon-tooltip-wrapper', { 'has-text-trigger': hasTextTrigger }, className);
|
|
51
146
|
const iconShiftBySize = {
|
|
52
147
|
left: isAnchorWrapper ? 0 : -(POPOVER_HELPER_WIDTH / 2 - iconSize / 2) + 'px',
|
|
53
148
|
};
|
|
54
|
-
|
|
149
|
+
// Focus may never enter the popover, so outside presses are watched directly.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!isVisible || isForcedToShow || !closeOnClickOutside) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
155
|
+
const handlePointerDown = (event) => {
|
|
156
|
+
const target = event.target;
|
|
157
|
+
if (!wrapperRef.current?.contains(target) && !popoverRef.current?.contains(target)) {
|
|
158
|
+
hideTooltip();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
doc?.addEventListener('pointerdown', handlePointerDown);
|
|
162
|
+
return () => doc?.removeEventListener('pointerdown', handlePointerDown);
|
|
163
|
+
}, [isVisible, isForcedToShow, closeOnClickOutside, hideTooltip]);
|
|
164
|
+
// Hover leaves focus elsewhere, so Escape is watched directly to keep the content dismissible.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!isVisible || !openedByHover.current) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
170
|
+
const handleKeyDown = (event) => {
|
|
171
|
+
if (event.key === 'Escape') {
|
|
172
|
+
hideTooltip();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
doc?.addEventListener('keydown', handleKeyDown);
|
|
176
|
+
return () => doc?.removeEventListener('keydown', handleKeyDown);
|
|
177
|
+
}, [isVisible, hideTooltip]);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (isForcedToShow || isVisible) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const destination = focusAfterClose.current;
|
|
183
|
+
focusAfterClose.current = null;
|
|
184
|
+
destination?.focus();
|
|
185
|
+
}, [isForcedToShow, isVisible]);
|
|
186
|
+
// Focus stays on the trigger, so a screen reader hears the popover through an announcement.
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const popover = popoverRef.current;
|
|
189
|
+
if (isVisible && !openedByHover.current && popover) {
|
|
190
|
+
speak(popover.innerText ?? popover.textContent, 'polite');
|
|
191
|
+
}
|
|
192
|
+
}, [isVisible]);
|
|
55
193
|
const handleMouseEnter = useCallback(() => {
|
|
56
194
|
if (hoverShow) {
|
|
57
195
|
if (hoverTimeout) {
|
|
58
196
|
clearTimeout(hoverTimeout);
|
|
59
197
|
setHoverTimeout(null);
|
|
60
198
|
}
|
|
199
|
+
openedByHover.current = true;
|
|
61
200
|
setIsVisible(true);
|
|
62
201
|
}
|
|
63
202
|
}, [hoverShow, hoverTimeout]);
|
|
@@ -70,6 +209,7 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
|
|
|
70
209
|
setHoverTimeout(id);
|
|
71
210
|
}
|
|
72
211
|
}, [hoverShow]);
|
|
73
|
-
|
|
212
|
+
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 })] }) })) }));
|
|
213
|
+
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, { ref: triggerRef, variant: "link", "aria-label": label, "aria-expanded": isVisible, onClick: toggleTooltip, onKeyDown: handleTriggerKeyDown, children: _jsx(Icon, { className: iconClassName, icon: iconCode, size: iconSize }) })), !hasTextTrigger && helper] }));
|
|
74
214
|
};
|
|
75
215
|
export default IconTooltip;
|
|
@@ -79,10 +79,34 @@ $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;
|
|
85
|
-
|
|
86
110
|
// __content styles here covers both the inline and portal modes
|
|
87
111
|
.components-popover__content {
|
|
88
112
|
padding: 24px;
|
|
@@ -31,6 +31,10 @@ export type IconTooltipProps = {
|
|
|
31
31
|
* The icon to display. Accepts icon components from `@wordpress/icons`.
|
|
32
32
|
*/
|
|
33
33
|
iconCode?: ComponentProps<typeof Icon>['icon'];
|
|
34
|
+
/**
|
|
35
|
+
* The accessible name of the icon button. Ignored when `trigger` is set, since its text names it.
|
|
36
|
+
*/
|
|
37
|
+
label?: string;
|
|
34
38
|
/**
|
|
35
39
|
* The title of Popover.
|
|
36
40
|
*/
|
|
@@ -52,7 +56,21 @@ export type IconTooltipProps = {
|
|
|
52
56
|
*/
|
|
53
57
|
popoverAnchorStyle?: 'icon' | 'wrapper';
|
|
54
58
|
/**
|
|
55
|
-
*
|
|
59
|
+
* Text to render as the trigger in place of the icon. The component owns the open state.
|
|
60
|
+
*/
|
|
61
|
+
trigger?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Called on every press of the trigger, e.g. to record analytics.
|
|
64
|
+
*/
|
|
65
|
+
onTriggerClick?: () => void;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a press outside the trigger and popover closes the tooltip. Turn it off for a
|
|
68
|
+
* reference the visitor keeps open while working elsewhere on the page.
|
|
69
|
+
*/
|
|
70
|
+
closeOnClickOutside?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Force the Popover to show without an event trigger. Only for a wrapper-anchored
|
|
73
|
+
* tooltip with no `trigger`, such as a programmatic tour step.
|
|
56
74
|
*/
|
|
57
75
|
forceShow?: boolean;
|
|
58
76
|
/**
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import './style.scss';
|
|
2
|
+
import type { JitmSlotProps } from './types.ts';
|
|
3
|
+
import type { FC } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Hosts the element the JITM script re-parents its card into.
|
|
6
|
+
*
|
|
7
|
+
* The JITM script places its card once per page load, so the element outlives any
|
|
8
|
+
* one slot: an unmounted slot parks it, hidden, and the next slot takes it back.
|
|
9
|
+
* `className` is for width and alignment; `style.scss` owns spacing.
|
|
10
|
+
*
|
|
11
|
+
* @param {JitmSlotProps} props - Component props.
|
|
12
|
+
* @return {JSX.Element} The slot.
|
|
13
|
+
*/
|
|
14
|
+
declare const JitmSlot: FC<JitmSlotProps>;
|
|
15
|
+
export default JitmSlot;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
4
|
+
import './style.scss';
|
|
5
|
+
const NOTICES_ID = 'jp-admin-notices';
|
|
6
|
+
/**
|
|
7
|
+
* Returns the page's one `#jp-admin-notices`, creating it on first use.
|
|
8
|
+
*
|
|
9
|
+
* Found through the DOM, not a module variable: each wp-build route bundles its own copy
|
|
10
|
+
* of this module.
|
|
11
|
+
*
|
|
12
|
+
* @return {HTMLElement} The element.
|
|
13
|
+
*/
|
|
14
|
+
const getNotices = () => {
|
|
15
|
+
let element = document.getElementById(NOTICES_ID);
|
|
16
|
+
if (!element) {
|
|
17
|
+
element = document.createElement('div');
|
|
18
|
+
element.id = NOTICES_ID;
|
|
19
|
+
}
|
|
20
|
+
return element;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Hosts the element the JITM script re-parents its card into.
|
|
24
|
+
*
|
|
25
|
+
* The JITM script places its card once per page load, so the element outlives any
|
|
26
|
+
* one slot: an unmounted slot parks it, hidden, and the next slot takes it back.
|
|
27
|
+
* `className` is for width and alignment; `style.scss` owns spacing.
|
|
28
|
+
*
|
|
29
|
+
* @param {JitmSlotProps} props - Component props.
|
|
30
|
+
* @return {JSX.Element} The slot.
|
|
31
|
+
*/
|
|
32
|
+
const JitmSlot = ({ className, inset }) => {
|
|
33
|
+
const ref = useRef(null);
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const host = ref.current;
|
|
36
|
+
if (!host) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const element = getNotices();
|
|
40
|
+
element.hidden = false;
|
|
41
|
+
host.appendChild(element);
|
|
42
|
+
return () => {
|
|
43
|
+
// A slot that mounted before this one unmounted may already hold it.
|
|
44
|
+
if (element.parentNode === host) {
|
|
45
|
+
element.hidden = true;
|
|
46
|
+
document.body.appendChild(element);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
return (_jsx("div", { ref: ref, className: clsx('jp-jitm-slot', { 'jp-jitm-slot--inset': inset }, className), "data-testid": "jp-jitm-slot" }));
|
|
51
|
+
};
|
|
52
|
+
export default JitmSlot;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The slot is always rendered and usually empty, so it takes no space
|
|
2
|
+
// of its own; spacing sits on the card so a dismissed one leaves no gap.
|
|
3
|
+
.jp-jitm-slot {
|
|
4
|
+
margin: 0;
|
|
5
|
+
padding: 0;
|
|
6
|
+
|
|
7
|
+
// `jitm-card` comes from packages/jitm/src/js/jetpack-jitm.js.
|
|
8
|
+
.jitm-card {
|
|
9
|
+
margin: var(--wpds-dimension-gap-xl) 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Same specificity as the rule above, so it has to come after it.
|
|
14
|
+
.jp-jitm-slot--inset .jitm-card {
|
|
15
|
+
margin-inline: var(--wpds-dimension-padding-2xl);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type JitmSlotProps = {
|
|
2
|
+
/**
|
|
3
|
+
* Layout the dashboard owns: width, column span, centering. Not spacing.
|
|
4
|
+
*/
|
|
5
|
+
className?: string;
|
|
6
|
+
/**
|
|
7
|
+
* The slot sits outside the page's padded container, so the card takes the page gutter.
|
|
8
|
+
*/
|
|
9
|
+
inset?: boolean;
|
|
10
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -89,9 +89,9 @@ $props: (
|
|
|
89
89
|
|
|
90
90
|
$sides: (
|
|
91
91
|
"top": "t",
|
|
92
|
-
"
|
|
92
|
+
"inline-end": "r",
|
|
93
93
|
"bottom": "b",
|
|
94
|
-
"
|
|
94
|
+
"inline-start": "l",
|
|
95
95
|
);
|
|
96
96
|
|
|
97
97
|
@each $prop, $p in $props {
|
|
@@ -106,8 +106,8 @@ $sides: (
|
|
|
106
106
|
|
|
107
107
|
/* mx, px */
|
|
108
108
|
.#{$p}x-#{$size} {
|
|
109
|
-
#{$prop}-
|
|
110
|
-
#{$prop}-
|
|
109
|
+
#{$prop}-inline-start: calc(var(--spacing-base) * #{$size});
|
|
110
|
+
#{$prop}-inline-end: calc(var(--spacing-base) * #{$size});
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
.#{$p}y-#{$size} {
|
|
@@ -7,11 +7,11 @@ export type TextProps = {
|
|
|
7
7
|
m?: SpacingValue;
|
|
8
8
|
/** margin-top */
|
|
9
9
|
mt?: SpacingValue;
|
|
10
|
-
/** margin-right */
|
|
10
|
+
/** margin-inline-end (right in LTR) */
|
|
11
11
|
mr?: SpacingValue;
|
|
12
12
|
/** margin-bottom */
|
|
13
13
|
mb?: SpacingValue;
|
|
14
|
-
/** margin-left */
|
|
14
|
+
/** margin-inline-start (left in LTR) */
|
|
15
15
|
ml?: SpacingValue;
|
|
16
16
|
/** margin left and right */
|
|
17
17
|
mx?: SpacingValue;
|
|
@@ -21,11 +21,11 @@ export type TextProps = {
|
|
|
21
21
|
p?: SpacingValue;
|
|
22
22
|
/** padding-top */
|
|
23
23
|
pt?: SpacingValue;
|
|
24
|
-
/** padding-right */
|
|
24
|
+
/** padding-inline-end (right in LTR) */
|
|
25
25
|
pr?: SpacingValue;
|
|
26
26
|
/** padding-bottom */
|
|
27
27
|
pb?: SpacingValue;
|
|
28
|
-
/** padding-left */
|
|
28
|
+
/** padding-inline-start (left in LTR) */
|
|
29
29
|
pl?: SpacingValue;
|
|
30
30
|
/** padding left and right */
|
|
31
31
|
px?: SpacingValue;
|
package/build/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { default as Col } from './components/layout/col/index.tsx';
|
|
|
20
20
|
export { default as Testimonials } from './components/testimonials/index.tsx';
|
|
21
21
|
export { default as Container } from './components/layout/container/index.tsx';
|
|
22
22
|
export { default as CopyToClipboard } from './components/copy-to-clipboard/index.tsx';
|
|
23
|
+
export { default as JitmSlot } from './components/jitm-slot/index.tsx';
|
|
23
24
|
export * from './components/icons/index.tsx';
|
|
24
25
|
export { default as ThemeProvider } from './components/theme-provider/index.tsx';
|
|
25
26
|
export { default as Text, H2, H3, Title } from './components/text/index.tsx';
|
package/build/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export { default as Col } from './components/layout/col/index.js';
|
|
|
33
33
|
export { default as Testimonials } from './components/testimonials/index.js';
|
|
34
34
|
export { default as Container } from './components/layout/container/index.js';
|
|
35
35
|
export { default as CopyToClipboard } from './components/copy-to-clipboard/index.js';
|
|
36
|
+
export { default as JitmSlot } from './components/jitm-slot/index.js';
|
|
36
37
|
export * from './components/icons/index.js';
|
|
37
38
|
export { default as ThemeProvider } from './components/theme-provider/index.js';
|
|
38
39
|
export { default as Text, H2, H3, Title } from './components/text/index.js';
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
display: block;
|
|
30
30
|
position: absolute;
|
|
31
31
|
top: 24px;
|
|
32
|
-
|
|
32
|
+
inset-inline-start: 24px;
|
|
33
33
|
width: 38px;
|
|
34
34
|
height: 8px;
|
|
35
35
|
background-image: url('data:image/svg+xml,<svg width="38" height="8" viewBox="0 0 38 8" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 7C1 7 2.37087 1 6.89831 1C11.4257 1 14.3709 7 18.8983 7C23.4257 7 26.7777 1 31.3051 1C35.912 1 37 7 37 7" stroke="white" stroke-width="1.5" stroke-linejoin="round"/></svg>');
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
&__icon-container {
|
|
44
44
|
position: absolute;
|
|
45
45
|
top: 50%;
|
|
46
|
+
// Kept physical: translate(-50%) shifts by a fixed direction, not a
|
|
47
|
+
// logical one, so left must stay paired with it or the box goes
|
|
48
|
+
// off-center by its own width under RTL without rtlcss.
|
|
46
49
|
left: 50%;
|
|
47
50
|
transform: translate(-50%, -50%);
|
|
48
51
|
width: 80px;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
GlobalNotices
|
|
2
|
+
=========
|
|
3
|
+
|
|
4
|
+
> **Deprecated.** Use `SnackbarNotices` from `@wordpress/notices` instead, and
|
|
5
|
+
> dispatch to that store directly with `{ type: 'snackbar' }`. Pages rendered by
|
|
6
|
+
> `@wordpress/boot` already mount a `SnackbarNotices`, so mounting this one too
|
|
7
|
+
> shows every notice twice. Retained as a compatibility shim for downstream
|
|
8
|
+
> consumers and will be removed in the next major version.
|
|
9
|
+
|
|
10
|
+
Renders the snackbar notices held in the `@wordpress/notices` store.
|
|
11
|
+
|
|
12
|
+
## General Usage:
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { GlobalNotices } from '@automattic/jetpack-components';
|
|
16
|
+
render() {
|
|
17
|
+
return (
|
|
18
|
+
<GlobalNotices />
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Props
|
|
24
|
+
|
|
25
|
+
- `maxVisibleNotices`: how many notices to show at once. Defaults to 3.
|
|
@@ -9,6 +9,10 @@ export type GlobalNoticesProps = {
|
|
|
9
9
|
/**
|
|
10
10
|
* Renders the global notices.
|
|
11
11
|
*
|
|
12
|
+
* @deprecated Use `SnackbarNotices` from `@wordpress/notices` instead. Pages
|
|
13
|
+
* rendered by `@wordpress/boot` already mount one, so mounting this as well
|
|
14
|
+
* shows every notice twice.
|
|
15
|
+
*
|
|
12
16
|
* @param {GlobalNoticesProps} props - Component props.
|
|
13
17
|
*
|
|
14
18
|
* @return {import('react').ReactNode} The rendered notices list.
|
|
@@ -9,6 +9,9 @@ export type TGlobalNotices = ReturnType< NoticesStore[ 'getActions' ] > &
|
|
|
9
9
|
/**
|
|
10
10
|
* The global notices hook.
|
|
11
11
|
*
|
|
12
|
+
* @deprecated Dispatch to `@wordpress/notices` directly, passing
|
|
13
|
+
* `{ type: 'snackbar' }` — this hook only defaulted that option.
|
|
14
|
+
*
|
|
12
15
|
* @return {TGlobalNotices} The global notices selectors and actions.
|
|
13
16
|
*/
|
|
14
17
|
export function useGlobalNotices(): TGlobalNotices {
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import { speak } from '@wordpress/a11y';
|
|
1
2
|
import { Popover } from '@wordpress/components';
|
|
3
|
+
import { focus } from '@wordpress/dom';
|
|
2
4
|
import { Icon, info } from '@wordpress/icons';
|
|
3
5
|
import clsx from 'clsx';
|
|
4
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
ReactElement,
|
|
12
|
+
FC,
|
|
13
|
+
FocusEvent,
|
|
14
|
+
KeyboardEvent,
|
|
15
|
+
} from 'react';
|
|
5
16
|
import Button from '../button/index.tsx';
|
|
6
17
|
import { IconTooltipProps, Placement, Position } from './types.ts';
|
|
7
18
|
|
|
@@ -33,11 +44,15 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
33
44
|
placement = 'bottom-end',
|
|
34
45
|
animate = true,
|
|
35
46
|
iconCode = info,
|
|
47
|
+
label,
|
|
36
48
|
iconSize = 18,
|
|
37
49
|
offset = 10,
|
|
38
50
|
title,
|
|
39
51
|
children,
|
|
40
52
|
popoverAnchorStyle = 'icon',
|
|
53
|
+
trigger,
|
|
54
|
+
onTriggerClick,
|
|
55
|
+
closeOnClickOutside = true,
|
|
41
56
|
forceShow = false,
|
|
42
57
|
hoverShow = false,
|
|
43
58
|
wide = false,
|
|
@@ -47,13 +62,98 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
47
62
|
const POPOVER_HELPER_WIDTH = 124;
|
|
48
63
|
const [ isVisible, setIsVisible ] = useState( false );
|
|
49
64
|
const [ hoverTimeout, setHoverTimeout ] = useState( null );
|
|
65
|
+
const wrapperRef = useRef< HTMLDivElement >( null );
|
|
66
|
+
const popoverRef = useRef< HTMLDivElement >( null );
|
|
67
|
+
const triggerRef = useRef< HTMLElement >( null );
|
|
68
|
+
const hasTextTrigger = trigger !== undefined;
|
|
69
|
+
// Where focus should land after Tab leaves the tooltip. The effect below applies it, rather
|
|
70
|
+
// than the handler, because Popover puts focus back on the trigger as it unmounts.
|
|
71
|
+
const focusAfterClose = useRef< HTMLElement | null >( null );
|
|
72
|
+
// Opening on hover must not pull focus off whatever the visitor is using.
|
|
73
|
+
const openedByHover = useRef( false );
|
|
50
74
|
const hideTooltip = useCallback( () => setIsVisible( false ), [ setIsVisible ] );
|
|
51
75
|
const toggleTooltip = useCallback(
|
|
52
76
|
e => {
|
|
53
77
|
e.preventDefault();
|
|
78
|
+
onTriggerClick?.();
|
|
79
|
+
openedByHover.current = false;
|
|
54
80
|
setIsVisible( ! isVisible );
|
|
55
81
|
},
|
|
56
|
-
[ isVisible, setIsVisible ]
|
|
82
|
+
[ isVisible, setIsVisible, onTriggerClick ]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Focus can stay on the trigger while its tooltip is open, so it handles the dialog keys too.
|
|
86
|
+
const handleTriggerKeyDown = useCallback(
|
|
87
|
+
( event: KeyboardEvent< HTMLElement > ) => {
|
|
88
|
+
// A held key repeats activation, so only its first press toggles.
|
|
89
|
+
if ( event.repeat && ( event.key === 'Enter' || event.key === ' ' ) ) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// A link only activates on Enter; a button also activates on Space.
|
|
94
|
+
if ( hasTextTrigger && event.key === ' ' ) {
|
|
95
|
+
toggleTooltip( event );
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if ( ! isVisible ) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ( event.key === 'Escape' ) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
hideTooltip();
|
|
104
|
+
} else if ( event.key === 'Tab' ) {
|
|
105
|
+
// Step into the popover even when it is portaled away from the trigger.
|
|
106
|
+
const first = event.shiftKey ? null : focus.tabbable.find( popoverRef.current )[ 0 ];
|
|
107
|
+
if ( first ) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
first.focus();
|
|
110
|
+
} else {
|
|
111
|
+
hideTooltip();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[ hasTextTrigger, isVisible, hideTooltip, toggleTooltip ]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
|
|
119
|
+
const isForcedToShow = isAnchorWrapper && forceShow;
|
|
120
|
+
|
|
121
|
+
const handlePopoverKeyDown = useCallback(
|
|
122
|
+
( event: KeyboardEvent< HTMLDivElement > ) => {
|
|
123
|
+
const wrapper = wrapperRef.current;
|
|
124
|
+
const popover = popoverRef.current;
|
|
125
|
+
if ( event.key !== 'Tab' || ! wrapper || ! popover ) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const tabbables = focus.tabbable.find( popover );
|
|
129
|
+
const boundary = event.shiftKey ? tabbables[ 0 ] : tabbables[ tabbables.length - 1 ];
|
|
130
|
+
const leaving =
|
|
131
|
+
! tabbables.length ||
|
|
132
|
+
event.target === boundary ||
|
|
133
|
+
( event.shiftKey && event.target === popover );
|
|
134
|
+
if ( ! leaving ) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if ( event.shiftKey && triggerRef.current ) {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
triggerRef.current.focus();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// A popover rendered in a portal sits at the end of the document, so Tab out of it has
|
|
143
|
+
// to resume from the trigger's place in the page instead of the popover's.
|
|
144
|
+
const step = event.shiftKey ? focus.tabbable.findPrevious : focus.tabbable.findNext;
|
|
145
|
+
let destination = step( wrapper );
|
|
146
|
+
while (
|
|
147
|
+
destination &&
|
|
148
|
+
( wrapper.contains( destination ) || popover.contains( destination ) )
|
|
149
|
+
) {
|
|
150
|
+
destination = step( destination );
|
|
151
|
+
}
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
focusAfterClose.current = destination ?? null;
|
|
154
|
+
hideTooltip();
|
|
155
|
+
},
|
|
156
|
+
[ hideTooltip ]
|
|
57
157
|
);
|
|
58
158
|
|
|
59
159
|
const args = {
|
|
@@ -65,21 +165,88 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
65
165
|
resize: false,
|
|
66
166
|
flip: false,
|
|
67
167
|
offset, // The distance (in px) between the anchor and the popover.
|
|
68
|
-
|
|
69
|
-
|
|
168
|
+
// Focus stays on the trigger, which handles the dialog keys; a caller-controlled popover
|
|
169
|
+
// has no trigger, so it focuses its first button instead.
|
|
170
|
+
focusOnMount: isForcedToShow ? 'firstElement' : false,
|
|
171
|
+
// Tab moves through the popover in document order rather than cycling inside it, and
|
|
172
|
+
// handlePopoverKeyDown decides where it lands on the way out.
|
|
173
|
+
constrainTabbing: false,
|
|
174
|
+
onKeyDownCapture: handlePopoverKeyDown,
|
|
175
|
+
ref: popoverRef,
|
|
176
|
+
onClose: () => {
|
|
177
|
+
const popover = popoverRef.current;
|
|
178
|
+
if ( popover?.contains( popover.ownerDocument.activeElement ) ) {
|
|
179
|
+
focusAfterClose.current = triggerRef.current;
|
|
180
|
+
}
|
|
181
|
+
hideTooltip();
|
|
182
|
+
},
|
|
183
|
+
onFocusOutside: ( event: FocusEvent ) => {
|
|
184
|
+
// A pointer press on our own trigger dismisses through that trigger instead.
|
|
185
|
+
if ( closeOnClickOutside && ! wrapperRef.current?.contains( event.relatedTarget as Node ) ) {
|
|
186
|
+
hideTooltip();
|
|
187
|
+
}
|
|
188
|
+
},
|
|
70
189
|
className: clsx( 'icon-tooltip-container', popoverClassName ),
|
|
71
190
|
inline,
|
|
72
191
|
shift,
|
|
73
192
|
} satisfies Omit< React.ComponentProps< typeof Popover >, 'children' >;
|
|
74
193
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
194
|
+
const wrapperClassNames = clsx(
|
|
195
|
+
'icon-tooltip-wrapper',
|
|
196
|
+
{ 'has-text-trigger': hasTextTrigger },
|
|
197
|
+
className
|
|
198
|
+
);
|
|
78
199
|
const iconShiftBySize = {
|
|
79
200
|
left: isAnchorWrapper ? 0 : -( POPOVER_HELPER_WIDTH / 2 - iconSize / 2 ) + 'px',
|
|
80
201
|
};
|
|
81
202
|
|
|
82
|
-
|
|
203
|
+
// Focus may never enter the popover, so outside presses are watched directly.
|
|
204
|
+
useEffect( () => {
|
|
205
|
+
if ( ! isVisible || isForcedToShow || ! closeOnClickOutside ) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
209
|
+
const handlePointerDown = ( event: PointerEvent ) => {
|
|
210
|
+
const target = event.target as Node;
|
|
211
|
+
if ( ! wrapperRef.current?.contains( target ) && ! popoverRef.current?.contains( target ) ) {
|
|
212
|
+
hideTooltip();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
doc?.addEventListener( 'pointerdown', handlePointerDown );
|
|
216
|
+
return () => doc?.removeEventListener( 'pointerdown', handlePointerDown );
|
|
217
|
+
}, [ isVisible, isForcedToShow, closeOnClickOutside, hideTooltip ] );
|
|
218
|
+
|
|
219
|
+
// Hover leaves focus elsewhere, so Escape is watched directly to keep the content dismissible.
|
|
220
|
+
useEffect( () => {
|
|
221
|
+
if ( ! isVisible || ! openedByHover.current ) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const doc = wrapperRef.current?.ownerDocument;
|
|
225
|
+
const handleKeyDown = ( event: globalThis.KeyboardEvent ) => {
|
|
226
|
+
if ( event.key === 'Escape' ) {
|
|
227
|
+
hideTooltip();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
doc?.addEventListener( 'keydown', handleKeyDown );
|
|
231
|
+
return () => doc?.removeEventListener( 'keydown', handleKeyDown );
|
|
232
|
+
}, [ isVisible, hideTooltip ] );
|
|
233
|
+
|
|
234
|
+
useEffect( () => {
|
|
235
|
+
if ( isForcedToShow || isVisible ) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const destination = focusAfterClose.current;
|
|
239
|
+
focusAfterClose.current = null;
|
|
240
|
+
destination?.focus();
|
|
241
|
+
}, [ isForcedToShow, isVisible ] );
|
|
242
|
+
|
|
243
|
+
// Focus stays on the trigger, so a screen reader hears the popover through an announcement.
|
|
244
|
+
useEffect( () => {
|
|
245
|
+
const popover = popoverRef.current;
|
|
246
|
+
if ( isVisible && ! openedByHover.current && popover ) {
|
|
247
|
+
speak( popover.innerText ?? popover.textContent, 'polite' );
|
|
248
|
+
}
|
|
249
|
+
}, [ isVisible ] );
|
|
83
250
|
|
|
84
251
|
const handleMouseEnter = useCallback( () => {
|
|
85
252
|
if ( hoverShow ) {
|
|
@@ -87,6 +254,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
87
254
|
clearTimeout( hoverTimeout );
|
|
88
255
|
setHoverTimeout( null );
|
|
89
256
|
}
|
|
257
|
+
openedByHover.current = true;
|
|
90
258
|
setIsVisible( true );
|
|
91
259
|
}
|
|
92
260
|
}, [ hoverShow, hoverTimeout ] );
|
|
@@ -101,31 +269,59 @@ const IconTooltip: FC< IconTooltipProps > = ( {
|
|
|
101
269
|
}
|
|
102
270
|
}, [ hoverShow ] );
|
|
103
271
|
|
|
272
|
+
const helper = (
|
|
273
|
+
<div className={ clsx( 'icon-tooltip-helper', { 'is-wide': wide } ) } style={ iconShiftBySize }>
|
|
274
|
+
{ ( isForcedToShow || isVisible ) && (
|
|
275
|
+
<Popover { ...args }>
|
|
276
|
+
<div>
|
|
277
|
+
{ title && <div className="icon-tooltip-title">{ title }</div> }
|
|
278
|
+
<div className="icon-tooltip-content">{ children }</div>
|
|
279
|
+
</div>
|
|
280
|
+
</Popover>
|
|
281
|
+
) }
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
|
|
104
285
|
return (
|
|
105
286
|
<div
|
|
287
|
+
ref={ wrapperRef }
|
|
106
288
|
className={ wrapperClassNames }
|
|
107
289
|
data-testid="icon-tooltip_wrapper"
|
|
108
290
|
onMouseEnter={ handleMouseEnter }
|
|
109
291
|
onMouseLeave={ handleMouseLeave }
|
|
110
292
|
>
|
|
111
|
-
{
|
|
112
|
-
|
|
293
|
+
{ hasTextTrigger && (
|
|
294
|
+
<>
|
|
295
|
+
{ /* A link, not a button, so it keeps the host page's link styling. */ }
|
|
296
|
+
<a
|
|
297
|
+
ref={ triggerRef as React.RefObject< HTMLAnchorElement > }
|
|
298
|
+
href="#"
|
|
299
|
+
role="button"
|
|
300
|
+
className="icon-tooltip-trigger"
|
|
301
|
+
aria-expanded={ isVisible }
|
|
302
|
+
onClick={ toggleTooltip }
|
|
303
|
+
onKeyDown={ handleTriggerKeyDown }
|
|
304
|
+
>
|
|
305
|
+
{ trigger }
|
|
306
|
+
</a>
|
|
307
|
+
<span className="icon-tooltip-anchor">
|
|
308
|
+
<span>{ helper }</span>
|
|
309
|
+
</span>
|
|
310
|
+
</>
|
|
311
|
+
) }
|
|
312
|
+
{ ! hasTextTrigger && ! isAnchorWrapper && (
|
|
313
|
+
<Button
|
|
314
|
+
ref={ triggerRef }
|
|
315
|
+
variant="link"
|
|
316
|
+
aria-label={ label }
|
|
317
|
+
aria-expanded={ isVisible }
|
|
318
|
+
onClick={ toggleTooltip }
|
|
319
|
+
onKeyDown={ handleTriggerKeyDown }
|
|
320
|
+
>
|
|
113
321
|
<Icon className={ iconClassName } icon={ iconCode } size={ iconSize } />
|
|
114
322
|
</Button>
|
|
115
323
|
) }
|
|
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>
|
|
324
|
+
{ ! hasTextTrigger && helper }
|
|
129
325
|
</div>
|
|
130
326
|
);
|
|
131
327
|
};
|
|
@@ -79,10 +79,34 @@ $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;
|
|
85
|
-
|
|
86
110
|
// __content styles here covers both the inline and portal modes
|
|
87
111
|
.components-popover__content {
|
|
88
112
|
padding: 24px;
|
|
@@ -41,6 +41,11 @@ export type IconTooltipProps = {
|
|
|
41
41
|
*/
|
|
42
42
|
iconCode?: ComponentProps< typeof Icon >[ 'icon' ];
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The accessible name of the icon button. Ignored when `trigger` is set, since its text names it.
|
|
46
|
+
*/
|
|
47
|
+
label?: string;
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* The title of Popover.
|
|
46
51
|
*/
|
|
@@ -67,7 +72,24 @@ export type IconTooltipProps = {
|
|
|
67
72
|
popoverAnchorStyle?: 'icon' | 'wrapper';
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
|
-
*
|
|
75
|
+
* Text to render as the trigger in place of the icon. The component owns the open state.
|
|
76
|
+
*/
|
|
77
|
+
trigger?: ReactNode;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Called on every press of the trigger, e.g. to record analytics.
|
|
81
|
+
*/
|
|
82
|
+
onTriggerClick?: () => void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a press outside the trigger and popover closes the tooltip. Turn it off for a
|
|
86
|
+
* reference the visitor keeps open while working elsewhere on the page.
|
|
87
|
+
*/
|
|
88
|
+
closeOnClickOutside?: boolean;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Force the Popover to show without an event trigger. Only for a wrapper-anchored
|
|
92
|
+
* tooltip with no `trigger`, such as a programmatic tour step.
|
|
71
93
|
*/
|
|
72
94
|
forceShow?: boolean;
|
|
73
95
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import clsx from 'clsx';
|
|
2
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
3
|
+
import './style.scss';
|
|
4
|
+
import type { JitmSlotProps } from './types.ts';
|
|
5
|
+
import type { FC } from 'react';
|
|
6
|
+
|
|
7
|
+
const NOTICES_ID = 'jp-admin-notices';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the page's one `#jp-admin-notices`, creating it on first use.
|
|
11
|
+
*
|
|
12
|
+
* Found through the DOM, not a module variable: each wp-build route bundles its own copy
|
|
13
|
+
* of this module.
|
|
14
|
+
*
|
|
15
|
+
* @return {HTMLElement} The element.
|
|
16
|
+
*/
|
|
17
|
+
const getNotices = (): HTMLElement => {
|
|
18
|
+
let element = document.getElementById( NOTICES_ID );
|
|
19
|
+
if ( ! element ) {
|
|
20
|
+
element = document.createElement( 'div' );
|
|
21
|
+
element.id = NOTICES_ID;
|
|
22
|
+
}
|
|
23
|
+
return element;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hosts the element the JITM script re-parents its card into.
|
|
28
|
+
*
|
|
29
|
+
* The JITM script places its card once per page load, so the element outlives any
|
|
30
|
+
* one slot: an unmounted slot parks it, hidden, and the next slot takes it back.
|
|
31
|
+
* `className` is for width and alignment; `style.scss` owns spacing.
|
|
32
|
+
*
|
|
33
|
+
* @param {JitmSlotProps} props - Component props.
|
|
34
|
+
* @return {JSX.Element} The slot.
|
|
35
|
+
*/
|
|
36
|
+
const JitmSlot: FC< JitmSlotProps > = ( { className, inset } ) => {
|
|
37
|
+
const ref = useRef< HTMLDivElement >( null );
|
|
38
|
+
|
|
39
|
+
useLayoutEffect( () => {
|
|
40
|
+
const host = ref.current;
|
|
41
|
+
if ( ! host ) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const element = getNotices();
|
|
46
|
+
element.hidden = false;
|
|
47
|
+
host.appendChild( element );
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
// A slot that mounted before this one unmounted may already hold it.
|
|
51
|
+
if ( element.parentNode === host ) {
|
|
52
|
+
element.hidden = true;
|
|
53
|
+
document.body.appendChild( element );
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}, [] );
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={ ref }
|
|
61
|
+
className={ clsx( 'jp-jitm-slot', { 'jp-jitm-slot--inset': inset }, className ) }
|
|
62
|
+
data-testid="jp-jitm-slot"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default JitmSlot;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The slot is always rendered and usually empty, so it takes no space
|
|
2
|
+
// of its own; spacing sits on the card so a dismissed one leaves no gap.
|
|
3
|
+
.jp-jitm-slot {
|
|
4
|
+
margin: 0;
|
|
5
|
+
padding: 0;
|
|
6
|
+
|
|
7
|
+
// `jitm-card` comes from packages/jitm/src/js/jetpack-jitm.js.
|
|
8
|
+
.jitm-card {
|
|
9
|
+
margin: var(--wpds-dimension-gap-xl) 0;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Same specificity as the rule above, so it has to come after it.
|
|
14
|
+
.jp-jitm-slot--inset .jitm-card {
|
|
15
|
+
margin-inline: var(--wpds-dimension-padding-2xl);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type JitmSlotProps = {
|
|
2
|
+
/**
|
|
3
|
+
* Layout the dashboard owns: width, column span, centering. Not spacing.
|
|
4
|
+
*/
|
|
5
|
+
className?: string;
|
|
6
|
+
/**
|
|
7
|
+
* The slot sits outside the page's padded container, so the card takes the page gutter.
|
|
8
|
+
*/
|
|
9
|
+
inset?: boolean;
|
|
10
|
+
};
|
|
@@ -89,9 +89,9 @@ $props: (
|
|
|
89
89
|
|
|
90
90
|
$sides: (
|
|
91
91
|
"top": "t",
|
|
92
|
-
"
|
|
92
|
+
"inline-end": "r",
|
|
93
93
|
"bottom": "b",
|
|
94
|
-
"
|
|
94
|
+
"inline-start": "l",
|
|
95
95
|
);
|
|
96
96
|
|
|
97
97
|
@each $prop, $p in $props {
|
|
@@ -106,8 +106,8 @@ $sides: (
|
|
|
106
106
|
|
|
107
107
|
/* mx, px */
|
|
108
108
|
.#{$p}x-#{$size} {
|
|
109
|
-
#{$prop}-
|
|
110
|
-
#{$prop}-
|
|
109
|
+
#{$prop}-inline-start: calc(var(--spacing-base) * #{$size});
|
|
110
|
+
#{$prop}-inline-end: calc(var(--spacing-base) * #{$size});
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
.#{$p}y-#{$size} {
|
package/components/text/types.ts
CHANGED
|
@@ -9,11 +9,11 @@ export type TextProps = {
|
|
|
9
9
|
m?: SpacingValue;
|
|
10
10
|
/** margin-top */
|
|
11
11
|
mt?: SpacingValue;
|
|
12
|
-
/** margin-right */
|
|
12
|
+
/** margin-inline-end (right in LTR) */
|
|
13
13
|
mr?: SpacingValue;
|
|
14
14
|
/** margin-bottom */
|
|
15
15
|
mb?: SpacingValue;
|
|
16
|
-
/** margin-left */
|
|
16
|
+
/** margin-inline-start (left in LTR) */
|
|
17
17
|
ml?: SpacingValue;
|
|
18
18
|
/** margin left and right */
|
|
19
19
|
mx?: SpacingValue;
|
|
@@ -23,11 +23,11 @@ export type TextProps = {
|
|
|
23
23
|
p?: SpacingValue;
|
|
24
24
|
/** padding-top */
|
|
25
25
|
pt?: SpacingValue;
|
|
26
|
-
/** padding-right */
|
|
26
|
+
/** padding-inline-end (right in LTR) */
|
|
27
27
|
pr?: SpacingValue;
|
|
28
28
|
/** padding-bottom */
|
|
29
29
|
pb?: SpacingValue;
|
|
30
|
-
/** padding-left */
|
|
30
|
+
/** padding-inline-start (left in LTR) */
|
|
31
31
|
pl?: SpacingValue;
|
|
32
32
|
/** padding left and right */
|
|
33
33
|
px?: SpacingValue;
|
package/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { default as Col } from './components/layout/col/index.tsx';
|
|
|
35
35
|
export { default as Testimonials } from './components/testimonials/index.tsx';
|
|
36
36
|
export { default as Container } from './components/layout/container/index.tsx';
|
|
37
37
|
export { default as CopyToClipboard } from './components/copy-to-clipboard/index.tsx';
|
|
38
|
+
export { default as JitmSlot } from './components/jitm-slot/index.tsx';
|
|
38
39
|
export * from './components/icons/index.tsx';
|
|
39
40
|
export { default as ThemeProvider } from './components/theme-provider/index.tsx';
|
|
40
41
|
export { default as Text, H2, H3, Title } from './components/text/index.tsx';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/jetpack-components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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": {
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./build/components/jetpack-logo/index.d.ts",
|
|
47
47
|
"default": "./build/components/jetpack-logo/index.js"
|
|
48
48
|
},
|
|
49
|
+
"./jitm-slot": {
|
|
50
|
+
"types": "./build/components/jitm-slot/index.d.ts",
|
|
51
|
+
"default": "./build/components/jitm-slot/index.js"
|
|
52
|
+
},
|
|
49
53
|
"./pricing-table": {
|
|
50
54
|
"types": "./build/components/pricing-table/index.d.ts",
|
|
51
55
|
"default": "./build/components/pricing-table/index.js"
|
|
@@ -75,15 +79,17 @@
|
|
|
75
79
|
"@automattic/jetpack-api": "^2.0.0",
|
|
76
80
|
"@automattic/jetpack-boost-score-api": "^1.1.0",
|
|
77
81
|
"@automattic/jetpack-script-data": "^0.6.8",
|
|
78
|
-
"@automattic/number-formatters": "^1.2.
|
|
82
|
+
"@automattic/number-formatters": "^1.2.10",
|
|
79
83
|
"@babel/runtime": "^7",
|
|
80
84
|
"@gravatar-com/hovercards": "0.16.0",
|
|
85
|
+
"@wordpress/a11y": "4.55.0",
|
|
81
86
|
"@wordpress/admin-ui": "2.10.0",
|
|
82
87
|
"@wordpress/browserslist-config": "6.55.0",
|
|
83
88
|
"@wordpress/components": "40.1.0",
|
|
84
89
|
"@wordpress/compose": "8.8.0",
|
|
85
90
|
"@wordpress/data": "10.55.0",
|
|
86
91
|
"@wordpress/date": "5.55.0",
|
|
92
|
+
"@wordpress/dom": "4.55.0",
|
|
87
93
|
"@wordpress/element": "8.7.0",
|
|
88
94
|
"@wordpress/i18n": "6.28.0",
|
|
89
95
|
"@wordpress/icons": "16.0.0",
|
|
@@ -94,12 +100,12 @@
|
|
|
94
100
|
"js-sha256": "0.11.1",
|
|
95
101
|
"prop-types": "^15.7.2",
|
|
96
102
|
"qrcode.react": "4.2.0",
|
|
97
|
-
"social-logos": "^3.
|
|
103
|
+
"social-logos": "^3.4.0",
|
|
98
104
|
"uplot": "1.6.31",
|
|
99
105
|
"uplot-react": "1.1.4"
|
|
100
106
|
},
|
|
101
107
|
"devDependencies": {
|
|
102
|
-
"@automattic/jetpack-base-styles": "^1.2.
|
|
108
|
+
"@automattic/jetpack-base-styles": "^1.2.15",
|
|
103
109
|
"@babel/core": "7.29.7",
|
|
104
110
|
"@jest/globals": "30.5.1",
|
|
105
111
|
"@storybook/addon-docs": "10.4.6",
|