newspack-components 4.4.0 → 4.5.0-alpha.1

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 CHANGED
@@ -1,3 +1,17 @@
1
+ # newspack-components [4.5.0-alpha.1](https://github.com/Automattic/newspack-workspace/compare/newspack-components@4.4.0...newspack-components@4.5.0-alpha.1) (2026-07-10)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **audience:** avoid [object Object] in segment summary ([#541](https://github.com/Automattic/newspack-workspace/issues/541)) ([336e382](https://github.com/Automattic/newspack-workspace/commit/336e382e489ee65f98fb75e33dfe2517da76e78b))
7
+ * **components:** avoid interactive nesting in CoreCard header ([#396](https://github.com/Automattic/newspack-workspace/issues/396)) ([dca53d4](https://github.com/Automattic/newspack-workspace/commit/dca53d4f1539769302158897f54d3666b6787e7a))
8
+
9
+
10
+ ### Features
11
+
12
+ * **components:** standardize wp-admin snackbars, bottom-centered ([#447](https://github.com/Automattic/newspack-workspace/issues/447)) ([8c09d8b](https://github.com/Automattic/newspack-workspace/commit/8c09d8bc005d95032f04267b1bdd71df029708c5))
13
+ * **content-gate:** add pre-save checklist and gate preferences ([#408](https://github.com/Automattic/newspack-workspace/issues/408)) ([5ef856b](https://github.com/Automattic/newspack-workspace/commit/5ef856b5355f0929aea1a83ca744a98a64b947de))
14
+
1
15
  # newspack-components [4.4.0](https://github.com/Automattic/newspack-workspace/compare/newspack-components@4.3.0...newspack-components@4.4.0) (2026-06-29)
2
16
 
3
17
 
@@ -77,19 +77,24 @@ var CoreCard = function CoreCard(_ref) {
77
77
  if (noBorder) {
78
78
  otherProps.isBorderless = true;
79
79
  }
80
+ // A button header would nest its interactive children (toggle, header action, actions menu, or
81
+ // drag controls) inside a <button>, which is invalid. Only render the header as a button when a
82
+ // real click handler is supplied and the header has no interactive children of its own.
83
+ var hasInteractiveHeaderChildren = actionType === 'toggle' || !!headerAction || (actions === null || actions === void 0 ? void 0 : actions.length) > 0 || isDraggable;
84
+ var headerIsButton = !!onHeaderClick && !hasInteractiveHeaderChildren;
80
85
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_components.Card, _objectSpread(_objectSpread({
81
86
  as: as,
82
87
  className: classes
83
88
  }, otherProps), {}, {
84
89
  children: [(header || icon) && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_components.CardHeader, {
85
- as: onHeaderClick ? 'button' : undefined,
90
+ as: headerIsButton ? 'button' : undefined,
86
91
  className: (0, _classnames["default"])('newspack-card--core__header', isDraggable && 'newspack-card--core__header--is-draggable', !hasHeaderBorder && 'newspack-card--core__header--no-border'),
87
92
  style: headerStyle,
88
93
  size: sizeProps,
89
94
  gap: 4,
90
- onClick: disabled ? undefined : onHeaderClick,
91
- disabled: onHeaderClick && disabled ? true : undefined,
92
- "aria-disabled": onHeaderClick && disabled ? true : undefined,
95
+ onClick: headerIsButton && !disabled ? onHeaderClick : undefined,
96
+ disabled: headerIsButton && disabled ? true : undefined,
97
+ "aria-disabled": headerIsButton && disabled ? true : undefined,
93
98
  children: [isDraggable && /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
94
99
  className: "newspack-card--core__header__draggable-controls",
95
100
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ var _react = require("@testing-library/react");
5
+ var _coreCard = _interopRequireDefault(require("./core-card"));
6
+ var _jsxRuntime = require("react/jsx-runtime");
7
+ /**
8
+ * External dependencies.
9
+ */
10
+
11
+ /**
12
+ * Internal dependencies.
13
+ */
14
+
15
+ var getHeader = function getHeader(container) {
16
+ return container.querySelector('.newspack-card--core__header');
17
+ };
18
+ describe('CoreCard', function () {
19
+ it('renders the header as a <button> when onHeaderClick is supplied and it has no interactive children', function () {
20
+ var _render = (0, _react.render)(/*#__PURE__*/(0, _jsxRuntime.jsx)(_coreCard["default"], {
21
+ header: "Settings",
22
+ onHeaderClick: function onHeaderClick() {}
23
+ })),
24
+ container = _render.container;
25
+ expect(getHeader(container).tagName).toBe('BUTTON');
26
+ });
27
+ it('renders the header as a non-button when it also has a toggle', function () {
28
+ var _render2 = (0, _react.render)(/*#__PURE__*/(0, _jsxRuntime.jsx)(_coreCard["default"], {
29
+ header: "Settings",
30
+ actionType: "toggle",
31
+ onHeaderClick: function onHeaderClick() {}
32
+ })),
33
+ container = _render2.container;
34
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
35
+ });
36
+ it('renders the header as a non-button when it also has a header action', function () {
37
+ var _render3 = (0, _react.render)(/*#__PURE__*/(0, _jsxRuntime.jsx)(_coreCard["default"], {
38
+ header: "Settings",
39
+ headerAction: {
40
+ label: 'Edit',
41
+ onClick: function onClick() {}
42
+ },
43
+ onHeaderClick: function onHeaderClick() {}
44
+ })),
45
+ container = _render3.container;
46
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
47
+ });
48
+ it('renders the header as a non-button when it also has an actions menu', function () {
49
+ var _render4 = (0, _react.render)(/*#__PURE__*/(0, _jsxRuntime.jsx)(_coreCard["default"], {
50
+ header: "Settings",
51
+ actions: [{
52
+ label: 'Delete',
53
+ action: function action() {}
54
+ }],
55
+ onHeaderClick: function onHeaderClick() {}
56
+ })),
57
+ container = _render4.container;
58
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
59
+ });
60
+ it('renders the header as a non-button when it is also draggable', function () {
61
+ var _render5 = (0, _react.render)(/*#__PURE__*/(0, _jsxRuntime.jsx)(_coreCard["default"], {
62
+ header: "Settings",
63
+ isDraggable: true,
64
+ onHeaderClick: function onHeaderClick() {}
65
+ })),
66
+ container = _render5.container;
67
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
68
+ });
69
+ });
@@ -39,8 +39,7 @@ var CardSettingsGroup = function CardSettingsGroup(_ref) {
39
39
  isActive = _ref$isActive === void 0 ? false : _ref$isActive,
40
40
  _ref$onEnable = _ref.onEnable,
41
41
  onEnable = _ref$onEnable === void 0 ? function () {} : _ref$onEnable,
42
- _ref$onHeaderClick = _ref.onHeaderClick,
43
- onHeaderClick = _ref$onHeaderClick === void 0 ? function () {} : _ref$onHeaderClick;
42
+ onHeaderClick = _ref.onHeaderClick;
44
43
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_.Card, {
45
44
  className: (0, _classnames["default"])('newspack-card--core--settings-group', className),
46
45
  actionType: actionType,
@@ -42,7 +42,7 @@
42
42
  }
43
43
 
44
44
  &--variant-secondary {
45
- background-color: wp-colors.$gray-900;
45
+ background-color: wp-colors.$gray-200;
46
46
  }
47
47
 
48
48
  &--variant-tertiary {
@@ -103,10 +103,9 @@
103
103
  }
104
104
 
105
105
  &-right {
106
- display: grid;
106
+ display: flex;
107
107
  gap: 8px;
108
- grid-template-columns: repeat(2, 1fr);
109
- justify-self: end;
108
+ justify-content: flex-end;
110
109
  margin-left: auto;
111
110
  }
112
111
  }
@@ -13,7 +13,7 @@ var _store = require("../store");
13
13
  require("./style.scss");
14
14
  var _classnames = _interopRequireDefault(require("classnames"));
15
15
  var _jsxRuntime = require("react/jsx-runtime");
16
- var _excluded = ["children", "position", "type", "actions"];
16
+ var _excluded = ["children", "type", "actions"];
17
17
  /**
18
18
  * Snackbar.
19
19
  */
@@ -29,26 +29,31 @@ var _excluded = ["children", "position", "type", "actions"];
29
29
  /**
30
30
  * WizardSnackbar component.
31
31
  *
32
+ * Wraps core's Snackbar with the wizard-store `onRemove` glue. Positioning and
33
+ * styling are neutral (bottom-centered via the snackbar list container). The
34
+ * notice `type` no longer drives any visual styling, but it still maps to the
35
+ * screen-reader announcement politeness: only `error` announces assertively,
36
+ * every other severity announces politely.
37
+ *
38
+ * Remaining props are spread onto core's Snackbar. See:
39
+ * https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
40
+ *
32
41
  * @param {Object} props - The component props.
33
- * @param {Object[]} props.actions - The actions to display in the snackbar.
34
42
  * @param {JSX.Element} props.children - The component children.
35
- * @param {Object} props.props - The component props. See: https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
36
- * @param {string} props.position - The snackbar position.
37
- * @param {string} props.type - The snackbar type: 'info', 'success', 'warning', or 'error'.
43
+ * @param {string} props.type - The notice severity ('error' announces assertively, anything else politely).
44
+ * @param {Object[]} props.actions - The actions to display in the snackbar.
38
45
  * @return {JSX.Element} The component.
39
46
  */
40
47
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
41
48
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2["default"])(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
42
49
  var WizardSnackbar = function WizardSnackbar(_ref) {
43
50
  var children = _ref.children,
44
- _ref$position = _ref.position,
45
- position = _ref$position === void 0 ? 'bottom-left' : _ref$position,
46
- _ref$type = _ref.type,
47
- type = _ref$type === void 0 ? 'info' : _ref$type,
51
+ type = _ref.type,
48
52
  _ref$actions = _ref.actions,
49
53
  actions = _ref$actions === void 0 ? [] : _ref$actions,
50
54
  props = (0, _objectWithoutProperties2["default"])(_ref, _excluded);
51
- var className = (0, _classnames["default"])('newspack-wizard__snackbar', props.className, "newspack-wizard__snackbar--".concat(position), "newspack-wizard__snackbar--".concat(type));
55
+ var className = (0, _classnames["default"])('newspack-wizard__snackbar', props.className);
56
+ var politeness = 'error' === type ? 'assertive' : 'polite';
52
57
  var _useDispatch = (0, _data.useDispatch)(_store.WIZARD_STORE_NAMESPACE),
53
58
  removeNotice = _useDispatch.removeNotice,
54
59
  resetNotices = _useDispatch.resetNotices;
@@ -62,9 +67,9 @@ var WizardSnackbar = function WizardSnackbar(_ref) {
62
67
  resetNotices();
63
68
  }
64
69
  };
65
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Snackbar, _objectSpread(_objectSpread({
66
- className: className
67
- }, props), {}, {
70
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Snackbar, _objectSpread(_objectSpread({}, props), {}, {
71
+ className: className,
72
+ politeness: politeness,
68
73
  onRemove: onRemove,
69
74
  actions: actions,
70
75
  children: children
@@ -1,39 +1,14 @@
1
+ @use "~@wordpress/base-styles/mixins" as wp-mixins;
2
+ @use "~@wordpress/base-styles/variables" as wp-vars;
3
+
1
4
  .newspack-wizard {
2
- &__snackbar {
3
- border-bottom: 2px solid transparent;
4
- bottom: 16px;
5
- left: 16px;
6
- max-width: calc(100vw - 32px);
7
- position: fixed;
5
+ &__snackbar-list {
6
+ @include wp-mixins.snackbar-container();
7
+ gap: wp-vars.$grid-unit-10;
8
8
  z-index: 99999;
9
- &--success {
10
- border-color: var(--newspack-ui-color-success-60);
11
- }
12
- &--error {
13
- border-color: var(--newspack-ui-color-error-60);
14
- }
15
- &--warning {
16
- border-color: var(--newspack-ui-color-warning-40);
17
- }
18
- @media screen and ( min-width: 783px ) {
19
- left: calc(36px + 16px); /* 36px for admin menu bar */
20
- }
21
- @media screen and ( min-width: 960px ) {
22
- left: calc(160px + 16px); /* 160px for admin menu bar */
23
- }
24
- &.newspack-wizard__snackbar--bottom-right,
25
- &.newspack-wizard__snackbar--top-right {
26
- left: auto;
27
- right: calc(16px);
28
- }
29
- &.newspack-wizard__snackbar--top-left,
30
- &.newspack-wizard__snackbar--top-right {
31
- bottom: auto;
32
- top: calc(16px + 46px); /* 46px for admin bar */
33
- @media screen and ( min-width: 783px ) {
34
- top: calc(16px + 32px); /* 32px for admin bar */
35
- }
36
- }
9
+ }
10
+
11
+ &__snackbar {
37
12
  a,
38
13
  .components-button.is-link {
39
14
  color: var(--newspack-ui-color-neutral-0);
@@ -229,17 +229,31 @@ var Wizard = function Wizard(_ref, ref) {
229
229
  className: 'newspack-wizard__header__actions__more'
230
230
  },
231
231
  children: function children() {
232
- return actions.map(function (action, index) {
233
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.MenuItem, {
234
- className: action.type === 'primary' || action.type === 'secondary' ? 'newspack-wizard__header__actions__more__main' : 'newspack-wizard__header__actions__more__more',
235
- icon: action.icon,
236
- href: action.href,
237
- onClick: action.action,
238
- disabled: action.disabled || false,
239
- isDestructive: action.destructive || false,
240
- children: action.label
241
- }, index);
242
- });
232
+ return (
233
+ // Split actions into groups whenever an action opts in via `separator: true`.
234
+ // Consecutive MenuGroups render the WordPress-standard divider between them.
235
+ actions.reduce(function (groups, action) {
236
+ if (action.separator || groups.length === 0) {
237
+ groups.push([]);
238
+ }
239
+ groups[groups.length - 1].push(action);
240
+ return groups;
241
+ }, []).map(function (group, groupIndex) {
242
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.MenuGroup, {
243
+ children: group.map(function (action, index) {
244
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.MenuItem, {
245
+ className: action.type === 'primary' || action.type === 'secondary' ? 'newspack-wizard__header__actions__more__main' : 'newspack-wizard__header__actions__more__more',
246
+ icon: action.icon,
247
+ href: action.href,
248
+ onClick: action.action,
249
+ disabled: action.disabled || false,
250
+ isDestructive: action.destructive || false,
251
+ children: action.label
252
+ }, index);
253
+ })
254
+ }, groupIndex);
255
+ })
256
+ );
243
257
  }
244
258
  })]
245
259
  })]
@@ -281,13 +295,16 @@ var Wizard = function Wizard(_ref, ref) {
281
295
  })]
282
296
  })
283
297
  })]
284
- }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && notices.map(function (notice, index) {
285
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_WizardSnackbar["default"], {
286
- type: notice.type,
287
- id: notice.id,
288
- actions: notice.actions,
289
- children: notice.message
290
- }, notice.id || index);
298
+ }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
299
+ className: "newspack-wizard__snackbar-list",
300
+ children: notices.map(function (notice, index) {
301
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_WizardSnackbar["default"], {
302
+ id: notice.id,
303
+ type: notice.type,
304
+ actions: notice.actions,
305
+ children: notice.message
306
+ }, notice.id || index);
307
+ })
291
308
  })]
292
309
  }), !isLoading && /*#__PURE__*/(0, _jsxRuntime.jsx)(_.Footer, {
293
310
  simple: hasSimpleFooter
@@ -73,19 +73,24 @@ var CoreCard = function CoreCard(_ref) {
73
73
  if (noBorder) {
74
74
  otherProps.isBorderless = true;
75
75
  }
76
+ // A button header would nest its interactive children (toggle, header action, actions menu, or
77
+ // drag controls) inside a <button>, which is invalid. Only render the header as a button when a
78
+ // real click handler is supplied and the header has no interactive children of its own.
79
+ var hasInteractiveHeaderChildren = actionType === 'toggle' || !!headerAction || (actions === null || actions === void 0 ? void 0 : actions.length) > 0 || isDraggable;
80
+ var headerIsButton = !!onHeaderClick && !hasInteractiveHeaderChildren;
76
81
  return /*#__PURE__*/_jsxs(CardWrapper, _objectSpread(_objectSpread({
77
82
  as: as,
78
83
  className: classes
79
84
  }, otherProps), {}, {
80
85
  children: [(header || icon) && /*#__PURE__*/_jsxs(CardHeader, {
81
- as: onHeaderClick ? 'button' : undefined,
86
+ as: headerIsButton ? 'button' : undefined,
82
87
  className: classNames('newspack-card--core__header', isDraggable && 'newspack-card--core__header--is-draggable', !hasHeaderBorder && 'newspack-card--core__header--no-border'),
83
88
  style: headerStyle,
84
89
  size: sizeProps,
85
90
  gap: 4,
86
- onClick: disabled ? undefined : onHeaderClick,
87
- disabled: onHeaderClick && disabled ? true : undefined,
88
- "aria-disabled": onHeaderClick && disabled ? true : undefined,
91
+ onClick: headerIsButton && !disabled ? onHeaderClick : undefined,
92
+ disabled: headerIsButton && disabled ? true : undefined,
93
+ "aria-disabled": headerIsButton && disabled ? true : undefined,
89
94
  children: [isDraggable && /*#__PURE__*/_jsxs("div", {
90
95
  className: "newspack-card--core__header__draggable-controls",
91
96
  children: [/*#__PURE__*/_jsx("div", {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * External dependencies.
3
+ */
4
+ import { render } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies.
8
+ */
9
+ import CoreCard from './core-card';
10
+ import { jsx as _jsx } from "react/jsx-runtime";
11
+ var getHeader = function getHeader(container) {
12
+ return container.querySelector('.newspack-card--core__header');
13
+ };
14
+ describe('CoreCard', function () {
15
+ it('renders the header as a <button> when onHeaderClick is supplied and it has no interactive children', function () {
16
+ var _render = render(/*#__PURE__*/_jsx(CoreCard, {
17
+ header: "Settings",
18
+ onHeaderClick: function onHeaderClick() {}
19
+ })),
20
+ container = _render.container;
21
+ expect(getHeader(container).tagName).toBe('BUTTON');
22
+ });
23
+ it('renders the header as a non-button when it also has a toggle', function () {
24
+ var _render2 = render(/*#__PURE__*/_jsx(CoreCard, {
25
+ header: "Settings",
26
+ actionType: "toggle",
27
+ onHeaderClick: function onHeaderClick() {}
28
+ })),
29
+ container = _render2.container;
30
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
31
+ });
32
+ it('renders the header as a non-button when it also has a header action', function () {
33
+ var _render3 = render(/*#__PURE__*/_jsx(CoreCard, {
34
+ header: "Settings",
35
+ headerAction: {
36
+ label: 'Edit',
37
+ onClick: function onClick() {}
38
+ },
39
+ onHeaderClick: function onHeaderClick() {}
40
+ })),
41
+ container = _render3.container;
42
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
43
+ });
44
+ it('renders the header as a non-button when it also has an actions menu', function () {
45
+ var _render4 = render(/*#__PURE__*/_jsx(CoreCard, {
46
+ header: "Settings",
47
+ actions: [{
48
+ label: 'Delete',
49
+ action: function action() {}
50
+ }],
51
+ onHeaderClick: function onHeaderClick() {}
52
+ })),
53
+ container = _render4.container;
54
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
55
+ });
56
+ it('renders the header as a non-button when it is also draggable', function () {
57
+ var _render5 = render(/*#__PURE__*/_jsx(CoreCard, {
58
+ header: "Settings",
59
+ isDraggable: true,
60
+ onHeaderClick: function onHeaderClick() {}
61
+ })),
62
+ container = _render5.container;
63
+ expect(getHeader(container).tagName).not.toBe('BUTTON');
64
+ });
65
+ });
@@ -31,8 +31,7 @@ var CardSettingsGroup = function CardSettingsGroup(_ref) {
31
31
  isActive = _ref$isActive === void 0 ? false : _ref$isActive,
32
32
  _ref$onEnable = _ref.onEnable,
33
33
  onEnable = _ref$onEnable === void 0 ? function () {} : _ref$onEnable,
34
- _ref$onHeaderClick = _ref.onHeaderClick,
35
- onHeaderClick = _ref$onHeaderClick === void 0 ? function () {} : _ref$onHeaderClick;
34
+ onHeaderClick = _ref.onHeaderClick;
36
35
  return /*#__PURE__*/_jsx(Card, {
37
36
  className: classNames('newspack-card--core--settings-group', className),
38
37
  actionType: actionType,
@@ -42,7 +42,7 @@
42
42
  }
43
43
 
44
44
  &--variant-secondary {
45
- background-color: wp-colors.$gray-900;
45
+ background-color: wp-colors.$gray-200;
46
46
  }
47
47
 
48
48
  &--variant-tertiary {
@@ -103,10 +103,9 @@
103
103
  }
104
104
 
105
105
  &-right {
106
- display: grid;
106
+ display: flex;
107
107
  gap: 8px;
108
- grid-template-columns: repeat(2, 1fr);
109
- justify-self: end;
108
+ justify-content: flex-end;
110
109
  margin-left: auto;
111
110
  }
112
111
  }
@@ -1,6 +1,6 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
3
- var _excluded = ["children", "position", "type", "actions"];
3
+ var _excluded = ["children", "type", "actions"];
4
4
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
5
5
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
6
6
  /**
@@ -27,25 +27,30 @@ import classnames from 'classnames';
27
27
  /**
28
28
  * WizardSnackbar component.
29
29
  *
30
+ * Wraps core's Snackbar with the wizard-store `onRemove` glue. Positioning and
31
+ * styling are neutral (bottom-centered via the snackbar list container). The
32
+ * notice `type` no longer drives any visual styling, but it still maps to the
33
+ * screen-reader announcement politeness: only `error` announces assertively,
34
+ * every other severity announces politely.
35
+ *
36
+ * Remaining props are spread onto core's Snackbar. See:
37
+ * https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
38
+ *
30
39
  * @param {Object} props - The component props.
31
- * @param {Object[]} props.actions - The actions to display in the snackbar.
32
40
  * @param {JSX.Element} props.children - The component children.
33
- * @param {Object} props.props - The component props. See: https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
34
- * @param {string} props.position - The snackbar position.
35
- * @param {string} props.type - The snackbar type: 'info', 'success', 'warning', or 'error'.
41
+ * @param {string} props.type - The notice severity ('error' announces assertively, anything else politely).
42
+ * @param {Object[]} props.actions - The actions to display in the snackbar.
36
43
  * @return {JSX.Element} The component.
37
44
  */
38
45
  import { jsx as _jsx } from "react/jsx-runtime";
39
46
  var WizardSnackbar = function WizardSnackbar(_ref) {
40
47
  var children = _ref.children,
41
- _ref$position = _ref.position,
42
- position = _ref$position === void 0 ? 'bottom-left' : _ref$position,
43
- _ref$type = _ref.type,
44
- type = _ref$type === void 0 ? 'info' : _ref$type,
48
+ type = _ref.type,
45
49
  _ref$actions = _ref.actions,
46
50
  actions = _ref$actions === void 0 ? [] : _ref$actions,
47
51
  props = _objectWithoutProperties(_ref, _excluded);
48
- var className = classnames('newspack-wizard__snackbar', props.className, "newspack-wizard__snackbar--".concat(position), "newspack-wizard__snackbar--".concat(type));
52
+ var className = classnames('newspack-wizard__snackbar', props.className);
53
+ var politeness = 'error' === type ? 'assertive' : 'polite';
49
54
  var _useDispatch = useDispatch(WIZARD_STORE_NAMESPACE),
50
55
  removeNotice = _useDispatch.removeNotice,
51
56
  resetNotices = _useDispatch.resetNotices;
@@ -59,9 +64,9 @@ var WizardSnackbar = function WizardSnackbar(_ref) {
59
64
  resetNotices();
60
65
  }
61
66
  };
62
- return /*#__PURE__*/_jsx(BaseComponent, _objectSpread(_objectSpread({
63
- className: className
64
- }, props), {}, {
67
+ return /*#__PURE__*/_jsx(BaseComponent, _objectSpread(_objectSpread({}, props), {}, {
68
+ className: className,
69
+ politeness: politeness,
65
70
  onRemove: onRemove,
66
71
  actions: actions,
67
72
  children: children
@@ -1,39 +1,14 @@
1
+ @use "~@wordpress/base-styles/mixins" as wp-mixins;
2
+ @use "~@wordpress/base-styles/variables" as wp-vars;
3
+
1
4
  .newspack-wizard {
2
- &__snackbar {
3
- border-bottom: 2px solid transparent;
4
- bottom: 16px;
5
- left: 16px;
6
- max-width: calc(100vw - 32px);
7
- position: fixed;
5
+ &__snackbar-list {
6
+ @include wp-mixins.snackbar-container();
7
+ gap: wp-vars.$grid-unit-10;
8
8
  z-index: 99999;
9
- &--success {
10
- border-color: var(--newspack-ui-color-success-60);
11
- }
12
- &--error {
13
- border-color: var(--newspack-ui-color-error-60);
14
- }
15
- &--warning {
16
- border-color: var(--newspack-ui-color-warning-40);
17
- }
18
- @media screen and ( min-width: 783px ) {
19
- left: calc(36px + 16px); /* 36px for admin menu bar */
20
- }
21
- @media screen and ( min-width: 960px ) {
22
- left: calc(160px + 16px); /* 160px for admin menu bar */
23
- }
24
- &.newspack-wizard__snackbar--bottom-right,
25
- &.newspack-wizard__snackbar--top-right {
26
- left: auto;
27
- right: calc(16px);
28
- }
29
- &.newspack-wizard__snackbar--top-left,
30
- &.newspack-wizard__snackbar--top-right {
31
- bottom: auto;
32
- top: calc(16px + 46px); /* 46px for admin bar */
33
- @media screen and ( min-width: 783px ) {
34
- top: calc(16px + 32px); /* 32px for admin bar */
35
- }
36
- }
9
+ }
10
+
11
+ &__snackbar {
37
12
  a,
38
13
  .components-button.is-link {
39
14
  color: var(--newspack-ui-color-neutral-0);
@@ -10,7 +10,7 @@ import classnames from 'classnames';
10
10
  /**
11
11
  * WordPress dependencies.
12
12
  */
13
- import { DropdownMenu, MenuItem } from '@wordpress/components';
13
+ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
14
14
  import { useDispatch, useSelect } from '@wordpress/data';
15
15
  import { useEffect, useState, forwardRef } from '@wordpress/element';
16
16
  import { __ } from '@wordpress/i18n';
@@ -226,17 +226,31 @@ var Wizard = function Wizard(_ref, ref) {
226
226
  className: 'newspack-wizard__header__actions__more'
227
227
  },
228
228
  children: function children() {
229
- return actions.map(function (action, index) {
230
- return /*#__PURE__*/_jsx(MenuItem, {
231
- className: action.type === 'primary' || action.type === 'secondary' ? 'newspack-wizard__header__actions__more__main' : 'newspack-wizard__header__actions__more__more',
232
- icon: action.icon,
233
- href: action.href,
234
- onClick: action.action,
235
- disabled: action.disabled || false,
236
- isDestructive: action.destructive || false,
237
- children: action.label
238
- }, index);
239
- });
229
+ return (
230
+ // Split actions into groups whenever an action opts in via `separator: true`.
231
+ // Consecutive MenuGroups render the WordPress-standard divider between them.
232
+ actions.reduce(function (groups, action) {
233
+ if (action.separator || groups.length === 0) {
234
+ groups.push([]);
235
+ }
236
+ groups[groups.length - 1].push(action);
237
+ return groups;
238
+ }, []).map(function (group, groupIndex) {
239
+ return /*#__PURE__*/_jsx(MenuGroup, {
240
+ children: group.map(function (action, index) {
241
+ return /*#__PURE__*/_jsx(MenuItem, {
242
+ className: action.type === 'primary' || action.type === 'secondary' ? 'newspack-wizard__header__actions__more__main' : 'newspack-wizard__header__actions__more__more',
243
+ icon: action.icon,
244
+ href: action.href,
245
+ onClick: action.action,
246
+ disabled: action.disabled || false,
247
+ isDestructive: action.destructive || false,
248
+ children: action.label
249
+ }, index);
250
+ })
251
+ }, groupIndex);
252
+ })
253
+ );
240
254
  }
241
255
  })]
242
256
  })]
@@ -278,13 +292,16 @@ var Wizard = function Wizard(_ref, ref) {
278
292
  })]
279
293
  })
280
294
  })]
281
- }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && notices.map(function (notice, index) {
282
- return /*#__PURE__*/_jsx(WizardSnackbar, {
283
- type: notice.type,
284
- id: notice.id,
285
- actions: notice.actions,
286
- children: notice.message
287
- }, notice.id || index);
295
+ }), (notices === null || notices === void 0 ? void 0 : notices.length) > 0 && /*#__PURE__*/_jsx("div", {
296
+ className: "newspack-wizard__snackbar-list",
297
+ children: notices.map(function (notice, index) {
298
+ return /*#__PURE__*/_jsx(WizardSnackbar, {
299
+ id: notice.id,
300
+ type: notice.type,
301
+ actions: notice.actions,
302
+ children: notice.message
303
+ }, notice.id || index);
304
+ })
288
305
  })]
289
306
  }), !isLoading && /*#__PURE__*/_jsx(Footer, {
290
307
  simple: hasSimpleFooter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newspack-components",
3
- "version": "4.4.0",
3
+ "version": "4.5.0-alpha.1",
4
4
  "description": "Newspack design system components",
5
5
  "author": "Automattic",
6
6
  "license": "GPL-2.0-or-later",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * CTA attribution hand-off (NPPD-1887).
3
+ *
4
+ * When a reader clicks a conversion-intent CTA inside a gate or prompt and is sent
5
+ * to a landing page, the surface that caused the conversion is left behind. This
6
+ * module persists the surface's identity so the landing page's checkout/donate form
7
+ * can replay it as a hidden field, letting the existing server chain
8
+ * (`INPUT_GET` -> cart item data -> `_gate_post_id` / `_newspack_popup_id` order meta)
9
+ * fire exactly as it does for a form rendered inside the gate itself.
10
+ *
11
+ * ── Storage contract ────────────────────────────────────────────────────────────
12
+ * This contract is DUPLICATED, by necessity, in newspack-blocks
13
+ * (`src/shared/js/cta-attribution.js`), which owns the checkout and donate forms and
14
+ * therefore owns the replay. Both plugins ship independently; there is no shared JS
15
+ * package between them. Change one, change the other.
16
+ *
17
+ * sessionStorage[ 'newspack_cta_attribution' ] = JSON.stringify( {
18
+ * v: 1, // SCHEMA — reject records written by a drifted shape
19
+ * type: 'gate' | 'prompt', // which surface
20
+ * id: '123', // gate_post_id or newspack_popup_id
21
+ * ts: 1751932800000, // Date.now() at click
22
+ * } )
23
+ *
24
+ * `sessionStorage`, not a cookie, on purpose:
25
+ * - survives multi-hop navigation (gate -> /subscribe -> /plans -> convert)
26
+ * - is copied into a new tab opened from `target="_blank"`
27
+ * - dies with the tab, which is a naturally conservative attribution window
28
+ * - cannot vary a page-cache key
29
+ *
30
+ * Last touch wins: a second CTA click overwrites the first.
31
+ */
32
+
33
+ /** Storage key. Shared with newspack-blocks. */
34
+ export const STORAGE_KEY = 'newspack_cta_attribution';
35
+
36
+ /**
37
+ * Storage-record schema version. The contract is duplicated across newspack-plugin and
38
+ * newspack-blocks (no shared JS package), so a mismatch is a real failure mode if the
39
+ * two ever drift — one side bumps TTL or the record shape, the other silently honors a
40
+ * stale record. Stamping the version and checking it on read makes drift fail safe: a
41
+ * record from a different version is ignored rather than trusted. Bump on any change to
42
+ * the record fields, the TTL semantics, or the storage key. (dkoo review, #575.)
43
+ */
44
+ export const SCHEMA = 1;
45
+
46
+ /**
47
+ * Attribution lifetime, in milliseconds.
48
+ *
49
+ * Session-scoped AND time-bounded (product decision, NPPD-1887): a reader who clicks
50
+ * the CTA, wanders off, and converts two hours later in the same tab gets no credit.
51
+ * Kept in sync with the same constant in newspack-blocks.
52
+ */
53
+ export const TTL_MS = 30 * 60 * 1000;
54
+
55
+ /**
56
+ * Persist the surface that owns this click.
57
+ *
58
+ * Never throws: Safari private mode and storage-disabled browsers make
59
+ * `sessionStorage.setItem` raise. Attribution is best-effort — losing it must never
60
+ * break the navigation the reader actually asked for.
61
+ *
62
+ * @param {string} type Surface type: 'gate' or 'prompt'.
63
+ * @param {string|number} id The gate_post_id or newspack_popup_id.
64
+ *
65
+ * @return {boolean} Whether the value was stored.
66
+ */
67
+ export function persistCtaAttribution( type, id ) {
68
+ if ( ! id || ( 'gate' !== type && 'prompt' !== type ) ) {
69
+ return false;
70
+ }
71
+ try {
72
+ window.sessionStorage.setItem( STORAGE_KEY, JSON.stringify( { v: SCHEMA, type, id: String( id ), ts: Date.now() } ) );
73
+ return true;
74
+ } catch ( e ) {
75
+ return false;
76
+ }
77
+ }
@@ -80,11 +80,16 @@ const CoreCard = ( {
80
80
  if ( noBorder ) {
81
81
  otherProps.isBorderless = true;
82
82
  }
83
+ // A button header would nest its interactive children (toggle, header action, actions menu, or
84
+ // drag controls) inside a <button>, which is invalid. Only render the header as a button when a
85
+ // real click handler is supplied and the header has no interactive children of its own.
86
+ const hasInteractiveHeaderChildren = actionType === 'toggle' || !! headerAction || actions?.length > 0 || isDraggable;
87
+ const headerIsButton = !! onHeaderClick && ! hasInteractiveHeaderChildren;
83
88
  return (
84
89
  <CardWrapper as={ as } className={ classes } { ...otherProps }>
85
90
  { ( header || icon ) && (
86
91
  <CardHeader
87
- as={ onHeaderClick ? 'button' : undefined }
92
+ as={ headerIsButton ? 'button' : undefined }
88
93
  className={ classNames(
89
94
  'newspack-card--core__header',
90
95
  isDraggable && 'newspack-card--core__header--is-draggable',
@@ -93,9 +98,9 @@ const CoreCard = ( {
93
98
  style={ headerStyle }
94
99
  size={ sizeProps }
95
100
  gap={ 4 }
96
- onClick={ disabled ? undefined : onHeaderClick }
97
- disabled={ onHeaderClick && disabled ? true : undefined }
98
- aria-disabled={ onHeaderClick && disabled ? true : undefined }
101
+ onClick={ headerIsButton && ! disabled ? onHeaderClick : undefined }
102
+ disabled={ headerIsButton && disabled ? true : undefined }
103
+ aria-disabled={ headerIsButton && disabled ? true : undefined }
99
104
  >
100
105
  { isDraggable && (
101
106
  <div className="newspack-card--core__header__draggable-controls">
@@ -0,0 +1,42 @@
1
+ /**
2
+ * External dependencies.
3
+ */
4
+ import { render } from '@testing-library/react';
5
+
6
+ /**
7
+ * Internal dependencies.
8
+ */
9
+ import CoreCard from './core-card';
10
+
11
+ const getHeader = container => container.querySelector( '.newspack-card--core__header' );
12
+
13
+ describe( 'CoreCard', () => {
14
+ it( 'renders the header as a <button> when onHeaderClick is supplied and it has no interactive children', () => {
15
+ const { container } = render( <CoreCard header="Settings" onHeaderClick={ () => {} } /> );
16
+ expect( getHeader( container ).tagName ).toBe( 'BUTTON' );
17
+ } );
18
+
19
+ it( 'renders the header as a non-button when it also has a toggle', () => {
20
+ const { container } = render( <CoreCard header="Settings" actionType="toggle" onHeaderClick={ () => {} } /> );
21
+ expect( getHeader( container ).tagName ).not.toBe( 'BUTTON' );
22
+ } );
23
+
24
+ it( 'renders the header as a non-button when it also has a header action', () => {
25
+ const { container } = render(
26
+ <CoreCard header="Settings" headerAction={ { label: 'Edit', onClick: () => {} } } onHeaderClick={ () => {} } />
27
+ );
28
+ expect( getHeader( container ).tagName ).not.toBe( 'BUTTON' );
29
+ } );
30
+
31
+ it( 'renders the header as a non-button when it also has an actions menu', () => {
32
+ const { container } = render(
33
+ <CoreCard header="Settings" actions={ [ { label: 'Delete', action: () => {} } ] } onHeaderClick={ () => {} } />
34
+ );
35
+ expect( getHeader( container ).tagName ).not.toBe( 'BUTTON' );
36
+ } );
37
+
38
+ it( 'renders the header as a non-button when it is also draggable', () => {
39
+ const { container } = render( <CoreCard header="Settings" isDraggable onHeaderClick={ () => {} } /> );
40
+ expect( getHeader( container ).tagName ).not.toBe( 'BUTTON' );
41
+ } );
42
+ } );
@@ -24,7 +24,7 @@ const CardSettingsGroup = ( {
24
24
  description = '',
25
25
  isActive = false,
26
26
  onEnable = () => {},
27
- onHeaderClick = () => {},
27
+ onHeaderClick,
28
28
  }: {
29
29
  actionType?: 'chevron' | 'toggle' | 'button' | 'link' | 'none';
30
30
  children?: React.ReactNode;
@@ -33,7 +33,7 @@ type DraggableItemAction = {
33
33
  type DraggableItem = {
34
34
  id: string | number;
35
35
  title: string;
36
- description?: string;
36
+ description?: React.ReactNode;
37
37
  badgeLevel: 'default' | 'success' | 'info' | 'warning' | 'error';
38
38
  badgeText: string;
39
39
  toggleChecked?: boolean;
@@ -10,7 +10,7 @@ Horizontal rule component with alignment, variant, and margin options.
10
10
  | `className` | `string` | — | Additional CSS class. |
11
11
  | `marginBottom` | `number` \| `string` | `64` | Bottom margin (e.g. `64` or `"2rem"`). Capped at 32px on viewports &lt; 783px. |
12
12
  | `marginTop` | `number` \| `string` | `64` | Top margin (e.g. `64` or `"2rem"`). Capped at 32px on viewports &lt; 783px. |
13
- | `variant` | `'default'` \| `'primary'` \| `'secondary'` \| `'tertiary'` | `'default'` | Line color: `default` uses `$gray-300`; `primary` uses the admin theme color (`--wp-admin-theme-color`); `secondary` uses `$gray-900`; `tertiary` uses `$gray-100`. |
13
+ | `variant` | `'default'` \| `'primary'` \| `'secondary'` \| `'tertiary'` | `'default'` | Line color: `default` uses `$gray-300`; `primary` uses the admin theme color (`--wp-admin-theme-color`); `secondary` uses `$gray-200`; `tertiary` uses `$gray-100`. |
14
14
 
15
15
  ## Usage
16
16
 
@@ -42,7 +42,7 @@
42
42
  }
43
43
 
44
44
  &--variant-secondary {
45
- background-color: wp-colors.$gray-900;
45
+ background-color: wp-colors.$gray-200;
46
46
  }
47
47
 
48
48
  &--variant-tertiary {
@@ -103,10 +103,9 @@
103
103
  }
104
104
 
105
105
  &-right {
106
- display: grid;
106
+ display: flex;
107
107
  gap: 8px;
108
- grid-template-columns: repeat(2, 1fr);
109
- justify-self: end;
108
+ justify-content: flex-end;
110
109
  margin-left: auto;
111
110
  }
112
111
  }
@@ -22,21 +22,24 @@ import classnames from 'classnames';
22
22
  /**
23
23
  * WizardSnackbar component.
24
24
  *
25
+ * Wraps core's Snackbar with the wizard-store `onRemove` glue. Positioning and
26
+ * styling are neutral (bottom-centered via the snackbar list container). The
27
+ * notice `type` no longer drives any visual styling, but it still maps to the
28
+ * screen-reader announcement politeness: only `error` announces assertively,
29
+ * every other severity announces politely.
30
+ *
31
+ * Remaining props are spread onto core's Snackbar. See:
32
+ * https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
33
+ *
25
34
  * @param {Object} props - The component props.
26
- * @param {Object[]} props.actions - The actions to display in the snackbar.
27
35
  * @param {JSX.Element} props.children - The component children.
28
- * @param {Object} props.props - The component props. See: https://wordpress.github.io/gutenberg/?path=/docs/components-snackbar--docs
29
- * @param {string} props.position - The snackbar position.
30
- * @param {string} props.type - The snackbar type: 'info', 'success', 'warning', or 'error'.
36
+ * @param {string} props.type - The notice severity ('error' announces assertively, anything else politely).
37
+ * @param {Object[]} props.actions - The actions to display in the snackbar.
31
38
  * @return {JSX.Element} The component.
32
39
  */
33
- const WizardSnackbar = ( { children, position = 'bottom-left', type = 'info', actions = [], ...props } ) => {
34
- const className = classnames(
35
- 'newspack-wizard__snackbar',
36
- props.className,
37
- `newspack-wizard__snackbar--${ position }`,
38
- `newspack-wizard__snackbar--${ type }`
39
- );
40
+ const WizardSnackbar = ( { children, type, actions = [], ...props } ) => {
41
+ const className = classnames( 'newspack-wizard__snackbar', props.className );
42
+ const politeness = 'error' === type ? 'assertive' : 'polite';
40
43
  const { removeNotice, resetNotices } = useDispatch( WIZARD_STORE_NAMESPACE );
41
44
  const onRemove = () => {
42
45
  if ( props.onRemove ) {
@@ -49,7 +52,7 @@ const WizardSnackbar = ( { children, position = 'bottom-left', type = 'info', ac
49
52
  }
50
53
  };
51
54
  return (
52
- <BaseComponent className={ className } { ...props } onRemove={ onRemove } actions={ actions }>
55
+ <BaseComponent { ...props } className={ className } politeness={ politeness } onRemove={ onRemove } actions={ actions }>
53
56
  { children }
54
57
  </BaseComponent>
55
58
  );
@@ -1,39 +1,14 @@
1
+ @use "~@wordpress/base-styles/mixins" as wp-mixins;
2
+ @use "~@wordpress/base-styles/variables" as wp-vars;
3
+
1
4
  .newspack-wizard {
2
- &__snackbar {
3
- border-bottom: 2px solid transparent;
4
- bottom: 16px;
5
- left: 16px;
6
- max-width: calc(100vw - 32px);
7
- position: fixed;
5
+ &__snackbar-list {
6
+ @include wp-mixins.snackbar-container();
7
+ gap: wp-vars.$grid-unit-10;
8
8
  z-index: 99999;
9
- &--success {
10
- border-color: var(--newspack-ui-color-success-60);
11
- }
12
- &--error {
13
- border-color: var(--newspack-ui-color-error-60);
14
- }
15
- &--warning {
16
- border-color: var(--newspack-ui-color-warning-40);
17
- }
18
- @media screen and ( min-width: 783px ) {
19
- left: calc(36px + 16px); /* 36px for admin menu bar */
20
- }
21
- @media screen and ( min-width: 960px ) {
22
- left: calc(160px + 16px); /* 160px for admin menu bar */
23
- }
24
- &.newspack-wizard__snackbar--bottom-right,
25
- &.newspack-wizard__snackbar--top-right {
26
- left: auto;
27
- right: calc(16px);
28
- }
29
- &.newspack-wizard__snackbar--top-left,
30
- &.newspack-wizard__snackbar--top-right {
31
- bottom: auto;
32
- top: calc(16px + 46px); /* 46px for admin bar */
33
- @media screen and ( min-width: 783px ) {
34
- top: calc(16px + 32px); /* 32px for admin bar */
35
- }
36
- }
9
+ }
10
+
11
+ &__snackbar {
37
12
  a,
38
13
  .components-button.is-link {
39
14
  color: var(--newspack-ui-color-neutral-0);
@@ -6,7 +6,7 @@ import classnames from 'classnames';
6
6
  /**
7
7
  * WordPress dependencies.
8
8
  */
9
- import { DropdownMenu, MenuItem } from '@wordpress/components';
9
+ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
10
10
  import { useDispatch, useSelect } from '@wordpress/data';
11
11
  import { useEffect, useState, forwardRef } from '@wordpress/element';
12
12
  import { __ } from '@wordpress/i18n';
@@ -190,23 +190,37 @@ const Wizard = (
190
190
  popoverProps={ { className: 'newspack-wizard__header__actions__more' } }
191
191
  >
192
192
  { () =>
193
- actions.map( ( action, index ) => (
194
- <MenuItem
195
- key={ index }
196
- className={
197
- action.type === 'primary' || action.type === 'secondary'
198
- ? 'newspack-wizard__header__actions__more__main'
199
- : 'newspack-wizard__header__actions__more__more'
193
+ // Split actions into groups whenever an action opts in via `separator: true`.
194
+ // Consecutive MenuGroups render the WordPress-standard divider between them.
195
+ actions
196
+ .reduce( ( groups, action ) => {
197
+ if ( action.separator || groups.length === 0 ) {
198
+ groups.push( [] );
200
199
  }
201
- icon={ action.icon }
202
- href={ action.href }
203
- onClick={ action.action }
204
- disabled={ action.disabled || false }
205
- isDestructive={ action.destructive || false }
206
- >
207
- { action.label }
208
- </MenuItem>
209
- ) )
200
+ groups[ groups.length - 1 ].push( action );
201
+ return groups;
202
+ }, [] )
203
+ .map( ( group, groupIndex ) => (
204
+ <MenuGroup key={ groupIndex }>
205
+ { group.map( ( action, index ) => (
206
+ <MenuItem
207
+ key={ index }
208
+ className={
209
+ action.type === 'primary' || action.type === 'secondary'
210
+ ? 'newspack-wizard__header__actions__more__main'
211
+ : 'newspack-wizard__header__actions__more__more'
212
+ }
213
+ icon={ action.icon }
214
+ href={ action.href }
215
+ onClick={ action.action }
216
+ disabled={ action.disabled || false }
217
+ isDestructive={ action.destructive || false }
218
+ >
219
+ { action.label }
220
+ </MenuItem>
221
+ ) ) }
222
+ </MenuGroup>
223
+ ) )
210
224
  }
211
225
  </DropdownMenu>
212
226
  </div>
@@ -263,12 +277,15 @@ const Wizard = (
263
277
  </Switch>
264
278
  </div>
265
279
  </HashRouter>
266
- { notices?.length > 0 &&
267
- notices.map( ( notice, index ) => (
268
- <WizardSnackbar key={ notice.id || index } type={ notice.type } id={ notice.id } actions={ notice.actions }>
269
- { notice.message }
270
- </WizardSnackbar>
271
- ) ) }
280
+ { notices?.length > 0 && (
281
+ <div className="newspack-wizard__snackbar-list">
282
+ { notices.map( ( notice, index ) => (
283
+ <WizardSnackbar key={ notice.id || index } id={ notice.id } type={ notice.type } actions={ notice.actions }>
284
+ { notice.message }
285
+ </WizardSnackbar>
286
+ ) ) }
287
+ </div>
288
+ ) }
272
289
  </div>
273
290
  { ! isLoading && <Footer simple={ hasSimpleFooter } /> }
274
291
  </div>