@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/build/components/decorative-card/style.scss +4 -1
  3. package/build/components/global-notices/global-notices.d.ts +4 -0
  4. package/build/components/global-notices/global-notices.js +4 -0
  5. package/build/components/global-notices/use-global-notices.d.ts +3 -0
  6. package/build/components/global-notices/use-global-notices.js +3 -0
  7. package/build/components/icon-tooltip/index.js +150 -10
  8. package/build/components/icon-tooltip/style.scss +25 -1
  9. package/build/components/icon-tooltip/types.d.ts +19 -1
  10. package/build/components/jitm-slot/index.d.ts +15 -0
  11. package/build/components/jitm-slot/index.js +52 -0
  12. package/build/components/jitm-slot/style.scss +16 -0
  13. package/build/components/jitm-slot/types.d.ts +10 -0
  14. package/build/components/jitm-slot/types.js +1 -0
  15. package/build/components/text/style.module.scss +4 -4
  16. package/build/components/text/types.d.ts +4 -4
  17. package/build/index.d.ts +1 -0
  18. package/build/index.js +1 -0
  19. package/components/decorative-card/style.scss +4 -1
  20. package/components/global-notices/README.md +25 -0
  21. package/components/global-notices/global-notices.tsx +4 -0
  22. package/components/global-notices/use-global-notices.ts +3 -0
  23. package/components/icon-tooltip/index.tsx +219 -23
  24. package/components/icon-tooltip/style.scss +25 -1
  25. package/components/icon-tooltip/types.ts +23 -1
  26. package/components/jitm-slot/index.tsx +67 -0
  27. package/components/jitm-slot/style.scss +16 -0
  28. package/components/jitm-slot/types.ts +10 -0
  29. package/components/text/style.module.scss +4 -4
  30. package/components/text/types.ts +4 -4
  31. package/index.ts +1 -0
  32. 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
- left: 24px;
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
- focusOnMount: 'firstElement',
44
- onClose: hideTooltip,
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 isAnchorWrapper = popoverAnchorStyle === 'wrapper';
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
- const isForcedToShow = isAnchorWrapper && forceShow;
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
- return (_jsxs("div", { className: wrapperClassNames, "data-testid": "icon-tooltip_wrapper", onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [!isAnchorWrapper && (_jsx(Button, { variant: "link", onMouseDown: toggleTooltip, children: _jsx(Icon, { className: iconClassName, icon: iconCode, size: iconSize }) })), _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 })] }) })) })] }));
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
- * Force the Popover to show without an event trigger.
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
- "right": "r",
92
+ "inline-end": "r",
93
93
  "bottom": "b",
94
- "left": "l",
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}-left: calc(var(--spacing-base) * #{$size});
110
- #{$prop}-right: calc(var(--spacing-base) * #{$size});
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
- left: 24px;
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 { useCallback, useState, ReactElement, FC } from 'react';
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
- focusOnMount: 'firstElement',
69
- onClose: hideTooltip,
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 isAnchorWrapper = popoverAnchorStyle === 'wrapper';
76
-
77
- const wrapperClassNames = clsx( 'icon-tooltip-wrapper', className );
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
- const isForcedToShow = isAnchorWrapper && forceShow;
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
- { ! isAnchorWrapper && (
112
- <Button variant="link" onMouseDown={ toggleTooltip }>
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
- <div
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
- * Force the Popover to show without an event trigger.
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
- "right": "r",
92
+ "inline-end": "r",
93
93
  "bottom": "b",
94
- "left": "l",
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}-left: calc(var(--spacing-base) * #{$size});
110
- #{$prop}-right: calc(var(--spacing-base) * #{$size});
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} {
@@ -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.1.2",
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.9",
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.3.19",
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.14",
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",