@automattic/jetpack-components 3.2.0 → 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 +16 -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 +37 -15
  8. package/build/components/icon-tooltip/style.scss +0 -8
  9. package/build/components/icon-tooltip/types.d.ts +4 -0
  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 +49 -18
  24. package/components/icon-tooltip/style.scss +0 -8
  25. package/components/icon-tooltip/types.ts +5 -0
  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 +9 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
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
+
5
20
  ## [3.2.0] - 2026-09-23
6
21
  ### Added
7
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]
@@ -1963,6 +1978,7 @@
1963
1978
  ### Changed
1964
1979
  - Update node version requirement to 14.16.1
1965
1980
 
1981
+ [3.3.0]: https://github.com/Automattic/jetpack-components/compare/3.2.0...3.3.0
1966
1982
  [3.2.0]: https://github.com/Automattic/jetpack-components/compare/3.1.2...3.2.0
1967
1983
  [3.1.2]: https://github.com/Automattic/jetpack-components/compare/3.1.1...3.1.2
1968
1984
  [3.1.1]: https://github.com/Automattic/jetpack-components/compare/3.1.0...3.1.1
@@ -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,4 +1,5 @@
1
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';
3
4
  import { focus } from '@wordpress/dom';
4
5
  import { Icon, info } from '@wordpress/icons';
@@ -23,7 +24,7 @@ const placementsToPositions = (placement) => {
23
24
  * @param {IconTooltipProps} props - Props
24
25
  * @return {ReactElement} - JSX element
25
26
  */
26
- const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', placement = 'bottom-end', animate = true, iconCode = info, iconSize = 18, offset = 10, title, children, popoverAnchorStyle = 'icon', trigger, onTriggerClick, closeOnClickOutside = true, forceShow = false, hoverShow = false, wide = false, inline = true, shift = false, }) => {
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, }) => {
27
28
  const POPOVER_HELPER_WIDTH = 124;
28
29
  const [isVisible, setIsVisible] = useState(false);
29
30
  const [hoverTimeout, setHoverTimeout] = useState(null);
@@ -43,13 +44,16 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
43
44
  openedByHover.current = false;
44
45
  setIsVisible(!isVisible);
45
46
  }, [isVisible, setIsVisible, onTriggerClick]);
46
- // Focus can stay on a text trigger while its tooltip is open, so it handles the dialog keys too.
47
+ // Focus can stay on the trigger while its tooltip is open, so it handles the dialog keys too.
47
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
+ }
48
54
  // A link only activates on Enter; a button also activates on Space.
49
- if (event.key === ' ') {
50
- if (!event.repeat) {
51
- toggleTooltip(event);
52
- }
55
+ if (hasTextTrigger && event.key === ' ') {
56
+ toggleTooltip(event);
53
57
  return;
54
58
  }
55
59
  if (!isVisible) {
@@ -70,7 +74,7 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
70
74
  hideTooltip();
71
75
  }
72
76
  }
73
- }, [isVisible, hideTooltip, toggleTooltip]);
77
+ }, [hasTextTrigger, isVisible, hideTooltip, toggleTooltip]);
74
78
  const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
75
79
  const isForcedToShow = isAnchorWrapper && forceShow;
76
80
  const handlePopoverKeyDown = useCallback((event) => {
@@ -104,9 +108,6 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
104
108
  focusAfterClose.current = destination ?? null;
105
109
  hideTooltip();
106
110
  }, [hideTooltip]);
107
- const focusOnOpen = isForcedToShow || (hasTextTrigger && !openedByHover.current)
108
- ? 'firstElement'
109
- : !openedByHover.current;
110
111
  const args = {
111
112
  // To be compatible with deprecating prop `position`.
112
113
  position: placementsToPositions(placement),
@@ -116,9 +117,9 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
116
117
  resize: false,
117
118
  flip: false,
118
119
  offset, // The distance (in px) between the anchor and the popover.
119
- // Focusing the popover itself puts Escape in reach even with nothing tabbable inside. A text
120
- // trigger or caller-controlled popover moves focus to its first link, if it has one.
121
- focusOnMount: focusOnOpen,
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,
122
123
  // Tab moves through the popover in document order rather than cycling inside it, and
123
124
  // handlePopoverKeyDown decides where it lands on the way out.
124
125
  constrainTabbing: false,
@@ -126,7 +127,7 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
126
127
  ref: popoverRef,
127
128
  onClose: () => {
128
129
  const popover = popoverRef.current;
129
- if (hasTextTrigger && popover?.contains(popover.ownerDocument.activeElement)) {
130
+ if (popover?.contains(popover.ownerDocument.activeElement)) {
130
131
  focusAfterClose.current = triggerRef.current;
131
132
  }
132
133
  hideTooltip();
@@ -160,6 +161,20 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
160
161
  doc?.addEventListener('pointerdown', handlePointerDown);
161
162
  return () => doc?.removeEventListener('pointerdown', handlePointerDown);
162
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]);
163
178
  useEffect(() => {
164
179
  if (isForcedToShow || isVisible) {
165
180
  return;
@@ -168,6 +183,13 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
168
183
  focusAfterClose.current = null;
169
184
  destination?.focus();
170
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]);
171
193
  const handleMouseEnter = useCallback(() => {
172
194
  if (hoverShow) {
173
195
  if (hoverTimeout) {
@@ -188,6 +210,6 @@ const IconTooltip = ({ className = '', popoverClassName, iconClassName = '', pla
188
210
  }
189
211
  }, [hoverShow]);
190
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 })] }) })) }));
191
- return (_jsxs("div", { ref: wrapperRef, className: wrapperClassNames, "data-testid": "icon-tooltip_wrapper", onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [hasTextTrigger && (_jsxs(_Fragment, { children: [_jsx("a", { ref: triggerRef, href: "#", role: "button", className: "icon-tooltip-trigger", "aria-expanded": isVisible, onClick: toggleTooltip, onKeyDown: handleTriggerKeyDown, children: trigger }), _jsx("span", { className: "icon-tooltip-anchor", children: _jsx("span", { children: helper }) })] })), !hasTextTrigger && !isAnchorWrapper && (_jsx(Button, { variant: "link", "aria-expanded": isVisible, onClick: toggleTooltip, children: _jsx(Icon, { className: iconClassName, icon: iconCode, size: iconSize }) })), !hasTextTrigger && helper] }));
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] }));
192
214
  };
193
215
  export default IconTooltip;
@@ -107,14 +107,6 @@ $arrow-color: var(--jp-gray);
107
107
  .icon-tooltip-container {
108
108
  // Recover events
109
109
  pointer-events: all;
110
- // Takes focus on open, but its box is wider than the visible content,
111
- // so the ring goes on the content instead.
112
- outline: none;
113
-
114
- &:focus-visible .components-popover__content {
115
- outline: var(--wp-admin-border-width-focus, 2px) solid var(--wp-admin-theme-color, #3858e9);
116
- }
117
-
118
110
  // __content styles here covers both the inline and portal modes
119
111
  .components-popover__content {
120
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
  */
@@ -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,3 +1,4 @@
1
+ import { speak } from '@wordpress/a11y';
1
2
  import { Popover } from '@wordpress/components';
2
3
  import { focus } from '@wordpress/dom';
3
4
  import { Icon, info } from '@wordpress/icons';
@@ -43,6 +44,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
43
44
  placement = 'bottom-end',
44
45
  animate = true,
45
46
  iconCode = info,
47
+ label,
46
48
  iconSize = 18,
47
49
  offset = 10,
48
50
  title,
@@ -62,7 +64,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
62
64
  const [ hoverTimeout, setHoverTimeout ] = useState( null );
63
65
  const wrapperRef = useRef< HTMLDivElement >( null );
64
66
  const popoverRef = useRef< HTMLDivElement >( null );
65
- const triggerRef = useRef< HTMLAnchorElement >( null );
67
+ const triggerRef = useRef< HTMLElement >( null );
66
68
  const hasTextTrigger = trigger !== undefined;
67
69
  // Where focus should land after Tab leaves the tooltip. The effect below applies it, rather
68
70
  // than the handler, because Popover puts focus back on the trigger as it unmounts.
@@ -80,14 +82,17 @@ const IconTooltip: FC< IconTooltipProps > = ( {
80
82
  [ isVisible, setIsVisible, onTriggerClick ]
81
83
  );
82
84
 
83
- // Focus can stay on a text trigger while its tooltip is open, so it handles the dialog keys too.
85
+ // Focus can stay on the trigger while its tooltip is open, so it handles the dialog keys too.
84
86
  const handleTriggerKeyDown = useCallback(
85
- ( event: KeyboardEvent< HTMLAnchorElement > ) => {
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
+ }
86
93
  // A link only activates on Enter; a button also activates on Space.
87
- if ( event.key === ' ' ) {
88
- if ( ! event.repeat ) {
89
- toggleTooltip( event );
90
- }
94
+ if ( hasTextTrigger && event.key === ' ' ) {
95
+ toggleTooltip( event );
91
96
  return;
92
97
  }
93
98
  if ( ! isVisible ) {
@@ -107,7 +112,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
107
112
  }
108
113
  }
109
114
  },
110
- [ isVisible, hideTooltip, toggleTooltip ]
115
+ [ hasTextTrigger, isVisible, hideTooltip, toggleTooltip ]
111
116
  );
112
117
 
113
118
  const isAnchorWrapper = popoverAnchorStyle === 'wrapper';
@@ -151,10 +156,6 @@ const IconTooltip: FC< IconTooltipProps > = ( {
151
156
  [ hideTooltip ]
152
157
  );
153
158
 
154
- const focusOnOpen =
155
- isForcedToShow || ( hasTextTrigger && ! openedByHover.current )
156
- ? 'firstElement'
157
- : ! openedByHover.current;
158
159
  const args = {
159
160
  // To be compatible with deprecating prop `position`.
160
161
  position: placementsToPositions( placement ),
@@ -164,9 +165,9 @@ const IconTooltip: FC< IconTooltipProps > = ( {
164
165
  resize: false,
165
166
  flip: false,
166
167
  offset, // The distance (in px) between the anchor and the popover.
167
- // Focusing the popover itself puts Escape in reach even with nothing tabbable inside. A text
168
- // trigger or caller-controlled popover moves focus to its first link, if it has one.
169
- focusOnMount: focusOnOpen,
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,
170
171
  // Tab moves through the popover in document order rather than cycling inside it, and
171
172
  // handlePopoverKeyDown decides where it lands on the way out.
172
173
  constrainTabbing: false,
@@ -174,7 +175,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
174
175
  ref: popoverRef,
175
176
  onClose: () => {
176
177
  const popover = popoverRef.current;
177
- if ( hasTextTrigger && popover?.contains( popover.ownerDocument.activeElement ) ) {
178
+ if ( popover?.contains( popover.ownerDocument.activeElement ) ) {
178
179
  focusAfterClose.current = triggerRef.current;
179
180
  }
180
181
  hideTooltip();
@@ -215,6 +216,21 @@ const IconTooltip: FC< IconTooltipProps > = ( {
215
216
  return () => doc?.removeEventListener( 'pointerdown', handlePointerDown );
216
217
  }, [ isVisible, isForcedToShow, closeOnClickOutside, hideTooltip ] );
217
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
+
218
234
  useEffect( () => {
219
235
  if ( isForcedToShow || isVisible ) {
220
236
  return;
@@ -224,6 +240,14 @@ const IconTooltip: FC< IconTooltipProps > = ( {
224
240
  destination?.focus();
225
241
  }, [ isForcedToShow, isVisible ] );
226
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 ] );
250
+
227
251
  const handleMouseEnter = useCallback( () => {
228
252
  if ( hoverShow ) {
229
253
  if ( hoverTimeout ) {
@@ -270,7 +294,7 @@ const IconTooltip: FC< IconTooltipProps > = ( {
270
294
  <>
271
295
  { /* A link, not a button, so it keeps the host page's link styling. */ }
272
296
  <a
273
- ref={ triggerRef }
297
+ ref={ triggerRef as React.RefObject< HTMLAnchorElement > }
274
298
  href="#"
275
299
  role="button"
276
300
  className="icon-tooltip-trigger"
@@ -286,7 +310,14 @@ const IconTooltip: FC< IconTooltipProps > = ( {
286
310
  </>
287
311
  ) }
288
312
  { ! hasTextTrigger && ! isAnchorWrapper && (
289
- <Button variant="link" aria-expanded={ isVisible } onClick={ toggleTooltip }>
313
+ <Button
314
+ ref={ triggerRef }
315
+ variant="link"
316
+ aria-label={ label }
317
+ aria-expanded={ isVisible }
318
+ onClick={ toggleTooltip }
319
+ onKeyDown={ handleTriggerKeyDown }
320
+ >
290
321
  <Icon className={ iconClassName } icon={ iconCode } size={ iconSize } />
291
322
  </Button>
292
323
  ) }
@@ -107,14 +107,6 @@ $arrow-color: var(--jp-gray);
107
107
  .icon-tooltip-container {
108
108
  // Recover events
109
109
  pointer-events: all;
110
- // Takes focus on open, but its box is wider than the visible content,
111
- // so the ring goes on the content instead.
112
- outline: none;
113
-
114
- &:focus-visible .components-popover__content {
115
- outline: var(--wp-admin-border-width-focus, 2px) solid var(--wp-admin-theme-color, #3858e9);
116
- }
117
-
118
110
  // __content styles here covers both the inline and portal modes
119
111
  .components-popover__content {
120
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
  */
@@ -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.2.0",
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,9 +79,10 @@
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",
@@ -95,12 +100,12 @@
95
100
  "js-sha256": "0.11.1",
96
101
  "prop-types": "^15.7.2",
97
102
  "qrcode.react": "4.2.0",
98
- "social-logos": "^3.3.19",
103
+ "social-logos": "^3.4.0",
99
104
  "uplot": "1.6.31",
100
105
  "uplot-react": "1.1.4"
101
106
  },
102
107
  "devDependencies": {
103
- "@automattic/jetpack-base-styles": "^1.2.14",
108
+ "@automattic/jetpack-base-styles": "^1.2.15",
104
109
  "@babel/core": "7.29.7",
105
110
  "@jest/globals": "30.5.1",
106
111
  "@storybook/addon-docs": "10.4.6",